Next to Riverpod, Bloc or Provider
The problem. The app already uses Riverpod, Bloc or Provider. Adding query_kit seems to mean choosing: either the store holds the server data and the cache is wasted, or the cache holds it and the store is left out. You want both. The screen's filter, the selected tab and the form draft stay in the store; the projects the server returned are cached, deduplicated and refetched by query_kit; and a widget sees both.
The recipe. Split state by owner. Anything the server owns lives in the
query cache, under a key. Anything the app owns lives in your store. Where one
depends on the other, the store holds the input (a filter string) and
derives the query's options from it. It never holds a copy of the result.
Every package in this recipe connects through the same adapter:
QueryController, a ChangeNotifier and ValueListenable<QueryResult>
that follows one query while something listens to it.
Does this replace state management? explains the split. This page shows how to wire it up.
The queries: one file, no package
The options are plain functions in lib/data/project_queries.dart. Every
integration below calls them, and so does every widget that reads a query
directly.
QueryObserverOptions<Project> projectQuery(String id) => QueryObserverOptions(
queryKey: ProjectKeys.detail(id),
queryFn: (context) => projectApi.get(id, signal: context.signal),
staleTime: const StaleTime.duration(Duration(seconds: 30)),
);
QueryObserverOptions<List<Project>> projectsQuery(String filter) =>
QueryObserverOptions(
queryKey: ProjectKeys.list(filter),
queryFn: (context) =>
projectApi.list(filter: filter, signal: context.signal),
);
Without a package: a view model
This is the shape the three integrations below all share. The view model
owns a controller, the controller owns the subscription, and a filter change
is one setOptions call. The controller moves to the new key in place. The
old entry stays cached, so switching back shows it at once.
class ProjectsViewModel extends ChangeNotifier {
ProjectsViewModel(QueryClient client)
: projects = QueryController.create(client, projectsQuery('open'));
/// Server state: a listenable of its own, owned and disposed here.
final QueryController<List<Project>, List<Project>> projects;
/// Client state: this app's alone, and never stale.
String _filter = 'open';
String get filter => _filter;
void showFilter(String filter) {
if (filter == _filter) return;
_filter = filter;
// The key follows the filter; the controller switches entries in place.
projects.setOptions(projectsQuery(filter));
notifyListeners();
}
void dispose() {
projects.dispose();
super.dispose();
}
}
The screen listens to both parts of the model: the filter, and the controller's results.
Widget build(BuildContext context) => ListenableBuilder(
// Rebuilds for either kind of state: a new filter, or a new result.
listenable: Listenable.merge(<Listenable>[model, model.projects]),
builder: (context, _) => Column(
children: <Widget>[
SegmentedButton<String>(
segments: const <ButtonSegment<String>>[
ButtonSegment(value: 'open', label: Text('Open')),
ButtonSegment(value: 'archived', label: Text('Archived')),
],
selected: <String>{model.filter},
onSelectionChanged: (selection) =>
model.showFilter(selection.single),
),
for (final project
in model.projects.value.dataOrNull ?? const <Project>[])
ListTile(title: Text(project.name)),
],
),
);
The view model is created in a State with
QueryClientProvider.read(context) and disposed with it. Dependency
injection covers where the client comes from.
Riverpod
This section targets Riverpod 3.x (flutter_riverpod 3). The client is a
provider, so notifiers can reach it, and it is also handed to a
QueryClientProvider, so the widgets below it can read queries directly.
final queryClientProvider = Provider<QueryClient>((ref) {
final client = QueryClient();
ref.onDispose(client.clear);
return client;
});
void main() {
runApp(
ProviderScope(
child: Consumer(
builder: (context, ref, child) => QueryClientProvider(
client: ref.watch(queryClientProvider),
child: child!,
),
child: const ProjectsApp(),
),
),
);
}
A query becomes a Notifier whose state is the controller's result. The
family argument arrives through the constructor, which is how Riverpod 3
passes it.
final projectProvider = NotifierProvider.autoDispose
.family<ProjectNotifier, QueryResult<Project>, String>(ProjectNotifier.new);
class ProjectNotifier extends Notifier<QueryResult<Project>> {
ProjectNotifier(this.id);
final String id;
QueryResult<Project> build() {
final controller = QueryController.create(
ref.watch(queryClientProvider),
projectQuery(id),
);
void publish() => state = controller.value;
controller.addListener(publish); // subscribes, and fetches if needed
ref.onDispose(() {
controller.removeListener(publish);
controller.dispose();
});
return controller.value;
}
Future<void> refresh() => ref
.read(queryClientProvider)
.invalidateQueries(filters: QueryFilters(queryKey: ProjectKeys.detail(id)));
}
A widget watches it the usual way:
final project = ref.watch(projectProvider(id));, then switches on the
QueryResult as it would on anything else.
The Notifier is not the only option. context.query(projectQuery(id)) works
inside a ConsumerWidget too, because the QueryClientProvider is above it.
Use a Notifier when other providers need the result. Read the query directly
when only the widget does.
Bloc
This section targets flutter_bloc 9 (bloc 9). Code written for version 8
is the same here. A Cubit wraps a controller and emits its results.
class ProjectCubit extends Cubit<QueryResult<Project>> {
factory ProjectCubit(QueryClient client, String id) =>
ProjectCubit._(QueryController.create(client, projectQuery(id)));
ProjectCubit._(this._project) : super(_project.value) {
_project.addListener(_publish);
}
final QueryController<Project, Project> _project;
void _publish() => emit(_project.value);
Future<void> refresh() => _project.refetch();
Future<void> close() {
_project
..removeListener(_publish)
..dispose();
return super.close();
}
}
BlocProvider(
create: (context) =>
ProjectCubit(QueryClientProvider.read(context), projectId),
child: BlocBuilder<ProjectCubit, QueryResult<Project>>(
builder: (context, project) => switch (project) {
QueryPending() => const CircularProgressIndicator(),
QueryError(:final error) => Text('$error'),
QuerySuccess(:final data) => Text(data.name),
},
),
)
BlocProvider calls close() when it unmounts, which disposes the
controller. A QueryResult has value equality, and a Cubit drops a state
equal to the current one, so an unchanged notification does not rebuild the
BlocBuilder.
Provider
This section targets provider 6. QueryController is a ChangeNotifier,
so ChangeNotifierProvider provides it and disposes it without any adapter
code.
ChangeNotifierProvider(
create: (context) => QueryController.create(
QueryClientProvider.read(context),
projectQuery(projectId),
),
child: const ProjectHeader(),
)
// Anywhere below:
final project = context.watch<QueryController<Project, Project>>().value;
Steps
- Write each query's options once, as a function of its inputs
(
lib/data/…_queries.dart). - Put one
QueryClientProviderabove the app, even when your package holds the client. The four built-in call styles need it. - For each query your store needs, create one
QueryControllerin the store's unit (Notifier, Cubit, ChangeNotifierProvider). Dispose it together with that unit. - Keep only the inputs in the store. When an input changes, call
setOptionswith the new options. Do not copy the data into the store.
Traps
- Copying the result into the store. A
state = controller.valuethat also savesdatain a second field creates two truths. The copy does not change when a background refetch does. Publish theQueryResultitself. - Forgetting
dispose. A controller that still has a listener keeps its query active: it goes on refetching on focus, on reconnect and on its interval, and the entry is never garbage collected. Every recipe above removes its listener and disposes the controller at the same point where its owner is disposed. - A second client. A
QueryClient()created inside a provider that rebuilds (a Riverpod provider thatwatches something that changes, or acreatethat runs more than once) starts over with an empty cache. The client is created once, per app or per signed-in user. - Mutations through the store. A write can be a store method that calls
client.invalidateQueries(...)afterwards, or a mutation read withcontext.mutation. Both are fine. Do not also apply the server's answer to the store yourself: the invalidation refetches it into the cache, and the controller publishes it.
Variations
- No store at all. Many screens need none of this. Reading queries in widgets shows the four equal call styles, which read the cache directly.
- A derived value only. When the store needs one field of the result,
create the controller with the unnamed constructor,
QueryController(client, projectQuery(id).withSelect((p) => p.openTasks)).QueryController.createtakes options without aselectonly. The controller then publishes the selected value, and render optimisations explain when it notifies.
See it run
The four call styles demo reads one cache entry through context.query,
QueryBuilder, QueryMixin and QueryController. The controller is the
adapter this whole page builds on. Press Refetch at the top, which goes
through card 4's controller: one request goes out, and all five readers show
the new data from the same entry.
The same split applies there: server state in the cache, client state in
Redux, Zustand or context. QueryController corresponds to what
useQuery is built on (a QueryObserver that a framework subscribes to),
exposed as a Flutter ValueListenable so that any state package can listen
to it. See differences from TanStack
Query.