flutter_query_client
A TanStack Query-inspired server state management library for Flutter.
Handles fetching, caching, synchronizing, and updating server state with minimal boilerplate — automatic background refetching, stale-while-revalidate caching, infinite pagination, mutation support, and a rich widget layer out of the box.
Features
- QueryController — fetch and cache server data automatically
- MutationController — user-triggered mutations; emits
QueryStateso all Query* widgets work with it - InfiniteQueryController — paginated / infinite-scroll data with
loadMore() - Stale-while-revalidate — serve cached data instantly, refetch in background
- Configurable stale time & garbage collection — control cache lifetime precisely
- Automatic retry with exponential backoff — resilient to transient failures
- Network-aware fetching — pauses when offline, resumes on reconnect
keepPreviousData— show old results while fetching new ones aftersetParams()- Global defaults with per-controller overrides
- BLoC-based state — integrates naturally with
flutter_bloc; observe all controllers viaQueryObserverset inQueryClientProvider - Freezed immutable state — safe pattern matching on
QueryState
Installation
dependencies:
flutter_query_client: ^2.0.0
Quick Start
1. Wrap your app
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return QueryClientProvider(
defaults: const QueryDefaults(
staleTime: Duration(minutes: 5),
gcTime: Duration(minutes: 10),
retryCount: 3,
),
// Optional: attach a global observer (no flutter_bloc import needed).
observer: AppQueryObserver(),
child: const MaterialApp(home: HomeScreen()),
);
}
}
2. Define a controller
class PostsController extends QueryController<List<Post>, void> {
PostsController() : super('posts');
@override
Future<List<Post>> queryFn(void params) => postService.getPosts();
}
3. Provide and build
QueryProvider<PostsController, List<Post>>(
create: (_) => PostsController(),
child: QueryBuilder<PostsController, List<Post>>(
builder: (context, state) {
if (state.isLoading) return const CircularProgressIndicator();
if (state.isError) return Text('Error: ${state.error}');
return PostListView(posts: state.data!);
},
),
),
Core Concepts
QueryState
Every controller emits QueryState<T>:
| Property | Type | Description |
|---|---|---|
data |
T? |
The cached data |
error |
Object? |
The last error |
status |
QueryStatus |
idle · loading · success · error |
fetchStatus |
FetchStatus |
idle · fetching · refetching · paused |
isStale |
bool |
Data is past its stale time |
isPlaceholderData |
bool |
Showing previous data during a setParams() fetch |
isLoadingMore |
bool |
Infinite query is fetching the next page |
isLoading |
bool |
status == loading |
isSuccess |
bool |
status == success |
isError |
bool |
status == error |
isIdle |
bool |
status == idle |
isFetching |
bool |
Any active background fetch |
isRefetching |
bool |
Background refetch with data already present |
isPaused |
bool |
Fetch paused (offline) |
hasData |
bool |
data != null |
hasError |
bool |
error != null |
Widget Reference
Provider Widgets
| Widget | Purpose |
|---|---|
QueryProvider<C, T> |
Provides a QueryController or MutationController via BlocProvider; handles remount |
InfiniteQueryProvider<C> |
Provides an InfiniteQueryController; handles remount |
MultiQueryProvider |
Nest multiple providers without indentation |
QueryClientProvider |
Inject QueryClient + global QueryDefaults into the tree |
Builder / Listener Widgets
| Widget | Purpose |
|---|---|
QueryBuilder<C, T> |
Rebuild on QueryState<T> changes |
InfiniteQueryBuilder<C, T> |
Rebuild on QueryState<List<T>> changes |
QueryListener<C, T> |
Side-effects on QueryState<T> changes — no rebuild |
InfiniteQueryListener<C, T> |
Side-effects on QueryState<List<T>> — no List<T> in type arg |
MultiQueryListener |
Multiple QueryListeners without extra widget layers |
Consumer Widgets (builder + listener combined)
| Widget | Purpose |
|---|---|
QueryConsumer<C, T> |
QueryBuilder + QueryListener in one widget |
InfiniteQueryConsumer<C, T> |
Same for infinite queries; List<T> baked in |
Selector Widgets (minimal rebuilds)
| Widget | Purpose |
|---|---|
QuerySelector<C, T, S> |
Rebuilds only when a derived value S changes |
InfiniteQuerySelector<C, T, S> |
Same for infinite queries; List<T> baked in |
Tip — mutations use the same widgets.
MutationController<T>emitsQueryState<T>, soQueryBuilder,QueryListener,QueryConsumer, andQuerySelectorall work with mutations out of the box.
Provider Widgets
QueryProvider
QueryProvider<PostsController, List<Post>>(
create: (_) => PostsController(),
child: const PostsListView(),
)
Automatically calls controller.handleRemount() when a widget transitions from hidden → visible (e.g. inside IndexedStack or Visibility).
InfiniteQueryProvider
InfiniteQueryProvider<ProductsController>(
create: (_) => ProductsController(),
child: const ProductsView(),
)
MultiQueryProvider
Provide multiple controllers without deep nesting — identical ergonomics to MultiBlocProvider. Pass each provider without a child; MultiQueryProvider injects it automatically. Providers are applied top-to-bottom (first entry = outermost ancestor).
MultiQueryProvider(
providers: [
QueryProvider<PostsController, List<Post>>(
create: (_) => PostsController(),
),
QueryProvider<CreatePostMutation, Post>(
create: (_) => CreatePostMutation(),
),
InfiniteQueryProvider<ProductsController>(
create: (_) => ProductsController(),
),
],
child: const HomeScreen(),
)
Builder Widgets
QueryBuilder
QueryBuilder<PostsController, List<Post>>(
buildWhen: (prev, next) => prev.status != next.status,
builder: (context, state) {
if (state.isLoading) return const CircularProgressIndicator();
if (state.isError) return ErrorView(error: state.error);
if (state.isRefetching) return PostsView(posts: state.data!, loading: true);
return PostsView(posts: state.data ?? []);
},
)
InfiniteQueryBuilder
InfiniteQueryBuilder<ProductsController, Product>(
builder: (context, state) {
final products = state.data ?? [];
return ListView.builder(
itemCount: products.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (_, i) {
if (i >= products.length) return const LoadingTile();
return ProductTile(products[i]);
},
);
},
)
Listener Widgets
QueryListener
Side-effects only — no rebuild. Works with both query and mutation controllers.
QueryListener<PostsController, List<Post>>(
listenWhen: (prev, next) => !prev.isError && next.isError,
listener: (context, state) => showErrorSnackBar(state.error),
child: const PostsView(),
)
// With a MutationController
QueryListener<CreatePostMutation, Post>(
listenWhen: (_, next) => next.isSuccess || next.isError,
listener: (context, state) {
if (state.isSuccess) Navigator.pop(context, state.data);
if (state.isError) showErrorSnackBar(state.error);
},
child: const CreatePostForm(),
)
InfiniteQueryListener
Identical to QueryListener for infinite queries — the List<T> wrapper is baked into the type so you only specify the item type:
// Before (verbose)
QueryListener<ProductsController, List<Product>>(...)
// After
InfiniteQueryListener<ProductsController, Product>(
listenWhen: (prev, next) => prev.isLoadingMore && !next.isLoadingMore,
listener: (context, state) {
if (state.isError) showErrorSnackBar('Failed to load more');
},
child: const ProductsView(),
)
MultiQueryListener
Attach multiple listeners without extra widget layers:
MultiQueryListener(
listeners: [
QueryListener<PostsController, List<Post>>(
listenWhen: (_, next) => next.isError,
listener: (context, state) => showErrorSnackBar(state.error),
),
QueryListener<CreatePostMutation, Post>(
listenWhen: (_, next) => next.isSuccess || next.isError,
listener: (context, state) { /* handle mutation result */ },
),
],
child: const HomeScreen(),
)
Consumer Widgets
Combine builder + listener without nesting — useful when you need both a reactive UI and a side-effect on the same controller.
QueryConsumer
QueryConsumer<PostsController, List<Post>>(
listenWhen: (_, next) => next.isError,
listener: (context, state) => showErrorSnackBar(state.error),
buildWhen: (prev, next) => prev.data != next.data,
builder: (context, state) {
if (state.isLoading) return const CircularProgressIndicator();
return PostsView(posts: state.data ?? []);
},
)
// With a MutationController
QueryConsumer<CreatePostMutation, Post>(
listenWhen: (_, next) => next.isSuccess,
listener: (context, state) => Navigator.pop(context, state.data),
builder: (context, state) => FilledButton(
onPressed: state.isLoading ? null : _submit,
child: state.isLoading
? const CircularProgressIndicator()
: const Text('Submit'),
),
)
InfiniteQueryConsumer
InfiniteQueryConsumer<ProductsController, Product>(
listenWhen: (prev, next) => prev.isLoadingMore && !next.isLoadingMore && next.isError,
listener: (context, state) => showErrorSnackBar('Failed to load more'),
builder: (context, state) {
final products = state.data ?? [];
if (state.isLoading) return const CircularProgressIndicator();
return ProductsView(products: products, isLoadingMore: state.isLoadingMore);
},
)
Selector Widgets
Rebuilds only when the selected value changes — use for badges, counters, or flags that don't need the full state.
QuerySelector
// Only rebuilds when post count changes — ignores refetch/stale events.
QuerySelector<PostsController, List<Post>, int>(
selector: (state) => state.data?.length ?? 0,
builder: (context, count) => Badge(label: Text('$count')),
)
// Only rebuilds when the mutation's loading flag toggles.
QuerySelector<CreatePostMutation, Post, bool>(
selector: (state) => state.isLoading,
builder: (context, isLoading) => FilledButton(
onPressed: isLoading ? null : _submit,
child: const Text('Submit'),
),
)
InfiniteQuerySelector
// Only rebuilds when total item count changes.
InfiniteQuerySelector<ProductsController, Product, int>(
selector: (state) => state.data?.length ?? 0,
builder: (context, count) => Text('$count items loaded'),
)
QueryController
Base class for any single-resource fetch.
class PostByIdController extends QueryController<Post, int> {
PostByIdController() : super('post');
// Optional overrides
@override Duration? get staleTime => const Duration(seconds: 30);
@override int get retryCount => 1;
@override RefetchOnMount get refetchOnMount => RefetchOnMount.always;
@override
Future<Post> queryFn(int? id) => postService.getPostById(id!);
// Lifecycle hooks
@override void onSuccess(Post data) { /* analytics, logging */ }
@override void onQueryError(Object error) { /* report error */ }
}
Key methods
// Trigger the query (or re-trigger with new params)
controller.setParams(postId);
// Manual refetch
controller.refetch();
// Get or fetch data imperatively (returns cached if fresh)
final post = await controller.ensureData(postId);
// Optimistic cache update
controller.updateCache((current) => current?.copyWith(title: 'New title'));
// Invalidate — marks stale and refetches if active
controller.invalidate();
Override contract
| Override | Default | Purpose |
|---|---|---|
queryFn(P? params) |
— | Required. The fetch function |
enabled |
auto | false prevents execution (auto: false when P ≠ void and params == null) |
staleTime |
null |
Duration until data is considered stale (null = never) |
refetchInterval |
null |
Auto-poll interval (null = disabled) |
refetchOnMount |
always |
always · stale · never |
refetchOnReconnect |
ifStale |
always · ifStale · never |
networkMode |
online |
online · always · offlineFirst |
retryCount |
3 |
Retry attempts on failure |
retryDelay |
1s |
Base delay; actual uses exponential backoff |
keepPreviousData |
false |
Show old data during setParams() refetch |
onSuccess(T data) |
no-op | Called after successful fetch |
onQueryError(Object e) |
no-op | Called after all retries fail |
InfiniteQueryController
For cursor- or page-based pagination. T = item type, PageParam = page parameter type, P = filter type (void if no filters).
initialPageParam and limit are optional — they inherit from QueryDefaults (0 and 20 by default) and only need to be overridden when a controller differs from the global setting.
// Minimal — initialPageParam and limit come from QueryDefaults.
class ProductsController extends InfiniteQueryController<Product, int, String> {
ProductsController() : super('products');
@override bool get keepPreviousData => true;
@override
int? getNextPageParam(List<Product> lastPage, List<List<Product>> allPages) =>
lastPage.length < limit ? null : allPages.length;
@override
Future<List<Product>> queryFn(int pageParam, String? query) =>
productService.getProducts(page: pageParam, search: query ?? '');
}
// Override only when this controller differs from the global default.
class CursorProductsController extends InfiniteQueryController<Product, String, void> {
CursorProductsController() : super('cursor-products');
@override String get initialPageParam => ''; // cursor, not an int
@override int get limit => 10; // smaller page size
@override
String? getNextPageParam(List<Product> last, List<List<Product>> all) =>
last.isEmpty ? null : last.last.cursor;
@override
Future<List<Product>> queryFn(String cursor, void _) =>
productService.getProductsAfter(cursor, limit: limit);
}
Key methods
// Trigger next page
await controller.loadMore();
// Apply new filter params — resets pagination and refetches
controller.setParams('running shoes');
// Refetch from page 0
controller.refetch();
// Invalidate and reset
controller.invalidateAndRefresh();
// Optimistic item updates across all pages
controller.updateItem((p) => p.id == updated.id, updated);
controller.removeItem((p) => p.id == deletedId);
controller.prependItem(newItem);
controller.appendItem(newItem);
Key getters
controller.hasMore // bool — whether getNextPageParam returns non-null
controller.currentPage // int — number of pages loaded
controller.totalItems // int — flat item count across all pages
controller.filters // P? — current filter params
MutationController
For create / update / delete operations. Takes a typed params generic P matching QueryController<T, P>. Override mutationFn(P params) — no closures needed. Mutations emit QueryState<T>, so every Query* widget works with them out of the box.
// Define a record typedef for the params (Dart 3 named records work well).
typedef CreatePostParams = ({int userId, String title, String body});
class CreatePostMutation extends MutationController<Post, CreatePostParams> {
@override
Future<Post> mutationFn(CreatePostParams params) {
return postService.createPost(
userId: params.userId,
title: params.title,
body: params.body,
);
}
@override
void onSuccess(Post data) => QueryLogger.info('Created: ${data.id}');
@override
void onMutationError(Object error) => QueryLogger.warning('Failed: $error');
@override
void onSettled(Post? data, Object? error) { /* always called */ }
}
// For mutations that need no params, use void as the second type argument.
class DeletePostMutation extends MutationController<bool, int> {
@override
Future<bool> mutationFn(int id) async {
await postService.deletePost(id);
return true;
}
}
// Provide like a QueryController
QueryProvider<CreatePostMutation, Post>(
create: (_) => CreatePostMutation(),
child: const CreatePostForm(),
)
// Trigger with typed named-record params
context.query<CreatePostMutation>().mutate((
userId: 1,
title: 'Hello',
body: 'World',
));
// Reset state to idle
context.query<CreatePostMutation>().reset();
// Use standard query widgets — mutations emit QueryState<T>
QueryConsumer<CreatePostMutation, Post>(
listenWhen: (_, next) => next.isSuccess || next.isError,
listener: (context, state) { /* navigate / show toast */ },
builder: (context, state) => FilledButton(
onPressed: state.isLoading ? null : _submit,
child: state.isLoading
? const CircularProgressIndicator()
: const Text('Create'),
),
)
Override contract
| Override | Default | Purpose |
|---|---|---|
mutationFn(P params) |
— | Required. The mutation function; receives typed params |
retryCount |
0 |
Global retryCount intentionally not applied to mutations |
onSuccess(T data) |
no-op | Called after mutation succeeds |
onMutationError(Object e) |
no-op | Called after mutation fails |
onSettled(T? data, Object? error) |
no-op | Always called after settle |
QueryClient
Singleton cache manager. Access via QueryClient.instance or context.queryClient.
final client = QueryClient.instance;
// Invalidate a specific key + params (removes from cache)
client.invalidate('posts', serializedParams);
// Invalidate all param variants for a key and notify active controllers
client.invalidateQueries(['posts', 'products']);
// Update cached data for all param variants of a key
client.update<List<Post>>('posts', (posts) => [newPost, ...posts]);
// Update infinite query pages
client.updateInfiniteQuery<Product>('products', (pages) => pages);
// Direct cache access
final cached = client.get<List<Post>>('posts');
// Register a global observer (prefer QueryClientProvider(observer:) at startup)
client.setObserver(AppQueryObserver());
// Set global defaults
client.setDefaults(const QueryDefaults(staleTime: Duration(minutes: 5)));
BuildContext extensions
// Get a controller from the widget tree
final ctrl = context.query<PostsController>();
// Get the QueryClient
final client = context.queryClient;
// Read cached data directly
final posts = context.cachedQuery<List<Post>>('posts');
final post = context.cachedQueryWithParams<Post>('post', serializedId);
QueryObserver
A BlocObserver subclass that filters events to query and mutation controllers only, exposing typed hooks. Pass it to QueryClientProvider alongside your other global defaults — no direct flutter_bloc import needed in your app.
QueryClientProvider(
defaults: const QueryDefaults(...),
observer: AppQueryObserver(), // ← registered here, alongside defaults
child: const MyApp(),
)
class AppQueryObserver extends QueryObserver {
/// Called when any QueryController, InfiniteQueryController,
/// or MutationController is created.
@override
void onQueryCreate(String cacheKey) =>
debugPrint('created: $cacheKey');
/// currentState + nextState — same semantics as BlocObserver.onChange.
@override
void onQueryChange(
String cacheKey,
QueryState<dynamic> currentState,
QueryState<dynamic> nextState,
) {
debugPrint(
'[$cacheKey] ${currentState.status.name} → ${nextState.status.name}',
);
}
@override
void onQueryError(String cacheKey, Object error, StackTrace stackTrace) =>
FirebaseCrashlytics.instance.recordError(error, stackTrace);
@override
void onQueryClose(String cacheKey) =>
debugPrint('closed: $cacheKey');
}
To also observe non-query cubits, override the raw BlocObserver hooks and call super:
@override
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
super.onChange(bloc, change); // ← fires onQueryChange for query controllers
if (bloc is MyOtherCubit) { /* handle */ }
}
Global Configuration
All global setup lives in a single QueryClientProvider:
QueryClientProvider(
client: QueryClient.instance, // optional — defaults to singleton
observer: AppQueryObserver(), // optional — global lifecycle observer
defaults: QueryDefaults(
staleTime: const Duration(minutes: 5),
gcTime: const Duration(minutes: 10),
retryCount: 3,
retryDelay: const Duration(seconds: 1),
refetchOnMount: RefetchOnMount.stale,
refetchOnReconnect: RefetchOnReconnect.ifStale,
networkMode: NetworkMode.online,
refetchInterval: null,
// Infinite query defaults — applied to every InfiniteQueryController
// that doesn't override them individually.
initialPageParam: 0, // first page param (default: 0)
limit: 20, // items per page (default: 20)
transformError: (error) => AppException.from(error),
connectivityEndpoints: [
InternetCheckOption(uri: Uri.parse('https://api.example.com/health')),
],
enableLogging: true,
logLevel: Level.INFO,
),
child: const MyApp(),
)
All QueryDefaults values apply globally but are overridable on each controller.
Network Modes
| Mode | Behaviour |
|---|---|
NetworkMode.online |
Pauses fetching when offline, resumes on reconnect |
NetworkMode.always |
Fetches regardless of network status |
NetworkMode.offlineFirst |
Executes once without network check, then requires connectivity |
Logging
// Enable via QueryDefaults
QueryDefaults(enableLogging: true, logLevel: Level.FINE)
// Or directly
QueryLogger.enable(level: Level.INFO, onLog: (record) => Sentry.addBreadcrumb(...));
QueryLogger.disable();
Example
A full example app demonstrating all features is in the example/ directory:
- Posts tab —
QueryController,QueryBuilder,MutationController<T, P>typed params,QueryClient.instance.updatecache patching from mutation listeners, background polling, offline support - Products tab —
InfiniteQueryController,setParams()live search,loadMore()on scroll,keepPreviousData,updateInfiniteQuery+prependItemafter create, optimistic item removal - Widgets tab — live showcase of every widget:
MultiQueryProvider,QueryConsumer,QuerySelector,InfiniteQueryListener,InfiniteQueryConsumer,InfiniteQuerySelector,MultiQueryListener,QueryObserver, and using mutation controllers with all Query* widgets - Issues tab — interactive before/after benchmarks for all v2.0.0 performance and memory optimizations; detailed documentation in
example/lib/features/inefficiency_demos/OPTIMIZATIONS.md