tanquery_flutter 0.8.0 copy "tanquery_flutter: ^0.8.0" to clipboard
tanquery_flutter: ^0.8.0 copied to clipboard

Flutter adapter for tanquery. Widget builders for automatic caching, stale-while-revalidate, and background refetching. QueryBuilder, MutationBuilder, InfiniteQueryBuilder.

tanquery_flutter #

pub package License: MIT Flutter 3

Flutter adapter for tanquery. Drop-in widget builders that replace your FutureBuilder + loading + error + retry boilerplate with one widget.


Table of Contents #


The Problem #

Every Flutter screen that loads data looks like this:

// Without tanquery_flutter -- repeated on every screen
class TodoScreen extends StatefulWidget { ... }

class _TodoScreenState extends State<TodoScreen> {
  late Future<List<Todo>> _future;
  bool _isLoading = true;
  Object? _error;
  List<Todo>? _data;

  @override
  void initState() {
    super.initState();
    _loadData();
  }

  Future<void> _loadData() async {
    setState(() { _isLoading = true; _error = null; });
    try {
      final data = await api.fetchTodos();
      setState(() { _data = data; _isLoading = false; });
    } catch (e) {
      setState(() { _error = e; _isLoading = false; });
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_isLoading) return CircularProgressIndicator();
    if (_error != null) return Text('Error: $_error');
    return ListView(children: _data!.map(TodoTile.new).toList());
  }
}

With tanquery_flutter:

// That entire class becomes:
QueryBuilder<List<Todo>>(
  queryKey: QueryKey(['todos']),
  queryFn: () => api.fetchTodos(),
  builder: (context, state) {
    if (state.isLoading) return CircularProgressIndicator();
    if (state.isError) return Text('Error: ${state.error}');
    return ListView(children: state.data!.map(TodoTile.new).toList());
  },
)

And you get caching, retries, background refetching, and deduplication for free.


Installation #

dependencies:
  tanquery_flutter: ^0.1.0
flutter pub get

This package re-exports tanquery, so you only need this one import:

import 'package:tanquery_flutter/tanquery_flutter.dart';

Quick Start (3 minutes) #

Step 1: Wrap your app with DartQueryProvider #

import 'package:tanquery_flutter/tanquery_flutter.dart';

void main() {
  final queryClient = QueryClient();

  runApp(
    DartQueryProvider(
      client: queryClient,
      child: MaterialApp(home: HomeScreen()),
    ),
  );
}

DartQueryProvider automatically:

  • Mounts the QueryClient lifecycle
  • Detects app focus changes (refetch when user returns to app)
  • Handles disposal on unmount

Step 2: Use QueryBuilder anywhere in your widget tree #

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Todos')),
      body: QueryBuilder<List<Todo>>(
        queryKey: QueryKey(['todos']),
        queryFn: () => api.fetchTodos(),
        staleTime: Duration(minutes: 5),
        builder: (context, state) {
          if (state.isLoading) return Center(child: CircularProgressIndicator());
          if (state.isError) return Center(child: Text('Error: ${state.error}'));

          return ListView.builder(
            itemCount: state.data!.length,
            itemBuilder: (_, i) => ListTile(title: Text(state.data![i].title)),
          );
        },
      ),
    );
  }
}

That's it. Navigate away and back -- cached data shows instantly. Pull to refresh -- background refetch updates the list. Network error -- automatic retry with backoff.


QueryBuilder #

The core widget. Wraps a QueryObserver and rebuilds when data changes.

QueryBuilder<T>(
  queryKey: QueryKey(['key']),       // Required: unique cache identifier
  queryFn: () => fetchData(),        // Required: the async function to call
  builder: (context, state) { ... }, // Required: build your UI from state

  // Optional:
  staleTime: Duration(minutes: 5),   // How long data is "fresh" (default: 0)
  gcTime: Duration(minutes: 30),     // How long unused data stays cached (default: 5min)
  enabled: true,                     // Set false to pause fetching
  retryCount: 3,                     // Retry attempts on failure (default: 3)
  placeholderData: [],               // Show while loading
  select: (data) => data.length,     // Transform data before it reaches builder
  refetchInterval: Duration(seconds: 30), // Auto-refetch on interval
)

Understanding the State Object #

The state parameter in the builder gives you everything:

builder: (context, state) {
  // Loading states
  state.isLoading    // First load, no data yet -- show spinner
  state.isFetching   // Any network activity (including background refresh)
  state.isRefetching // Has data AND is refetching (background refresh)

  // Data states
  state.isSuccess    // Data available
  state.data         // The actual data (T?)
  state.isPending    // No data yet (before first fetch completes)

  // Error states
  state.isError         // Fetch failed
  state.error           // The error object
  state.isLoadingError  // Error with no data (first load failed)
  state.isRefetchError  // Error but stale data still available

  // Meta
  state.isStale          // Past staleTime
  state.isPlaceholderData // Showing placeholder, not real data
  state.isFetched        // At least one fetch completed
  state.dataUpdatedAt    // When data was last updated
}

Common Builder Patterns #

Simple loading/error/data:

builder: (context, state) {
  if (state.isLoading) return CircularProgressIndicator();
  if (state.isError) return Text('Error: ${state.error}');
  return DataWidget(data: state.data!);
}

Show stale data during refetch:

builder: (context, state) {
  if (state.isLoading) return CircularProgressIndicator();

  return Column(
    children: [
      if (state.isFetching) LinearProgressIndicator(), // subtle refresh indicator
      DataWidget(data: state.data!),
      if (state.isRefetchError)
        Text('Failed to refresh, showing cached data'),
    ],
  );
}

Pull to refresh:

builder: (context, state) {
  if (state.isLoading) return CircularProgressIndicator();

  return RefreshIndicator(
    onRefresh: () async {
      DartQuery.of(context).invalidateQueries(queryKey: QueryKey(['todos']));
    },
    child: ListView(...),
  );
}

MutationBuilder #

For creating, updating, or deleting data. Gives you a mutate function and tracks the mutation state.

MutationBuilder<Todo, CreateTodoInput>(
  mutationFn: (input) => api.createTodo(input),
  onSuccess: (data, input, context) async {
    // Invalidate related queries after successful mutation
    DartQuery.of(context).invalidateQueries(queryKey: QueryKey(['todos']));
  },
  builder: (context, state, mutate, mutateAsync) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: state.isPending
              ? null  // Disable while in progress
              : () => mutate(CreateTodoInput(title: 'New todo')),
          child: Text(state.isPending ? 'Saving...' : 'Add Todo'),
        ),
        if (state.isError) Text('Failed: ${state.error}', style: TextStyle(color: Colors.red)),
        if (state.isSuccess) Text('Created: ${state.data!.title}', style: TextStyle(color: Colors.green)),
      ],
    );
  },
)

mutate vs mutateAsync:

  • mutate(input) -- Fire and forget. Errors are handled via onError callback.
  • mutateAsync(input) -- Returns a Future. You can await it and handle errors with try/catch.

InfiniteQueryBuilder #

For paginated lists and infinite scroll. Manages page params and exposes fetchNextPage / fetchPreviousPage.

InfiniteQueryBuilder<List<Todo>, int>(
  queryKey: QueryKey(['todos', 'infinite']),
  queryFn: (pageParam) => api.fetchTodos(page: pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage, allPages, lastParam, allParams) {
    // Return null when there are no more pages
    return lastPage.length == 20 ? lastParam + 1 : null;
  },
  builder: (context, state, fetchNextPage, fetchPreviousPage) {
    if (state.isLoading) return Center(child: CircularProgressIndicator());

    final allItems = state.data?.pages.expand((page) => page).toList() ?? [];

    return NotificationListener<ScrollNotification>(
      onNotification: (scrollInfo) {
        // Auto-fetch next page when near bottom
        if (scrollInfo.metrics.extentAfter < 200) {
          fetchNextPage();
        }
        return false;
      },
      child: ListView.builder(
        itemCount: allItems.length,
        itemBuilder: (_, i) => ListTile(title: Text(allItems[i].title)),
      ),
    );
  },
)

Cursor-based pagination:

InfiniteQueryBuilder<List<Post>, String>(
  queryKey: QueryKey(['posts']),
  queryFn: (cursor) => api.fetchPosts(cursor: cursor),
  initialPageParam: '',
  getNextPageParam: (lastPage, allPages, lastCursor, allCursors) {
    return lastPage.isNotEmpty ? lastPage.last.cursor : null;
  },
  builder: (context, state, fetchNextPage, _) { ... },
)

QueriesBuilder #

Fetch multiple queries in parallel with a single widget:

QueriesBuilder(
  queries: [
    QueryConfig(key: QueryKey(['user']), fn: () => api.fetchUser()),
    QueryConfig(key: QueryKey(['todos']), fn: () => api.fetchTodos()),
    QueryConfig(key: QueryKey(['settings']), fn: () => api.fetchSettings()),
  ],
  builder: (context, results) {
    final userState = results[0];
    final todosState = results[1];
    final settingsState = results[2];

    if (results.any((r) => r.isLoading)) {
      return CircularProgressIndicator();
    }

    return Dashboard(
      user: userState.data,
      todos: todosState.data,
      settings: settingsState.data,
    );
  },
)

Common Patterns #

Access the Client Anywhere #

final client = DartQuery.of(context);

// Invalidate queries (triggers refetch)
client.invalidateQueries(queryKey: QueryKey(['todos']));

// Update cached data directly
client.setQueryData<User>(QueryKey(['user']), updatedUser);

// Prefetch data for the next screen
client.prefetchQuery(queryKey: QueryKey(['todo', id]), queryFn: () => api.fetchTodo(id));

Conditional Fetching #

QueryBuilder<UserProfile>(
  queryKey: QueryKey(['profile', userId]),
  queryFn: () => api.fetchProfile(userId!),
  enabled: userId != null, // Won't fetch until userId is set
  builder: (context, state) { ... },
)

Refetch Interval (Polling) #

QueryBuilder<StockPrice>(
  queryKey: QueryKey(['stock', ticker]),
  queryFn: () => api.fetchPrice(ticker),
  refetchInterval: Duration(seconds: 5), // Auto-refetch every 5 seconds
  builder: (context, state) {
    return Text('\$${state.data?.price ?? '---'}');
  },
)

API Reference #

DartQueryProvider #

Property Type Description
client QueryClient Required. The query client instance.
child Widget Required. Your app widget tree.

DartQuery.of(context) #

Returns the QueryClient from the nearest DartQueryProvider. Throws if no provider found.

QueryBuilder #

Property Type Default Description
queryKey QueryKey Required Cache key
queryFn Future<T> Function() Required Fetch function
builder Widget Function(context, state) Required Build UI from state
staleTime Duration Duration.zero Freshness window
gcTime Duration 5 minutes Cache retention after unmount
enabled bool true Enable/disable fetching
retryCount int 3 Retry attempts
placeholderData T? null Show while loading
select T Function(T)? null Transform data
refetchInterval Duration? null Auto-refetch interval

DevTools #

Add visual cache inspection to your app:

DartQueryProvider(
  client: queryClient,
  child: DartQueryDevtools(
    enabled: kDebugMode,
    child: MaterialApp(...),
  ),
);

See tanquery_devtools for details.


License #

MIT

0
likes
150
points
0
downloads

Documentation

API reference

Publisher

verified publisherottomancoder.com

Weekly Downloads

Flutter adapter for tanquery. Widget builders for automatic caching, stale-while-revalidate, and background refetching. QueryBuilder, MutationBuilder, InfiniteQueryBuilder.

Repository (GitHub)
View/report issues
Contributing

Topics

#flutter #cache #state-management #query #widget

License

MIT (license)

Dependencies

flutter, tanquery

More

Packages that depend on tanquery_flutter