Quick start
Four pieces: a client at the root of the app, a function that describes the query, a widget that reads it, and a write that tells the cache what it made stale. Ten minutes, and every later page builds on them.
0. Install
flutter pub add query_kit_flutter
One import, package:query_kit_flutter/query_kit_flutter.dart, brings in the
binding and the whole core with it. Pure Dart, versions and SDK floors are on
installation.
The samples below talk to an api object — whatever your app already uses to
reach its backend. All it has to offer is methods that return a Future and
throw when the request fails; query functions
shows one built on dio and one on package:http.
1. A client at the root
void main() {
runApp(
QueryClientProvider(
client: QueryClient(),
child: const MaterialApp(home: TasksScreen()),
),
);
}
The provider mounts the client it is given, which is what wires up
refetch-on-focus, refetch-on-reconnect and the resuming of paused mutations.
It does not dispose it: a QueryClient outlives the tree by design, so
client.clear() is yours to call — at sign-out, or at the end of a widget test.
If you want the provider to own the client as well, use
QueryClientProvider.create, which builds it and clear()s it when the tree
comes down:
void main() {
runApp(
QueryClientProvider.create(
create: QueryClient.new,
child: const MaterialApp(home: TasksScreen()),
),
);
}
Create the client once — never in a build method, where every rebuild would
start an empty cache.
2. Describe the query once
Put the options behind a function. Nothing forces this, but it is what makes the same query readable from several widgets without drift:
final QueryKey tasksKey = QueryKey(<Object?>['tasks']);
QueryObserverOptions<List<Task>> tasksQuery() => QueryObserverOptions(
queryKey: tasksKey,
queryFn: (context) => api.listTasks(signal: context.signal),
staleTime: const StaleTime.duration(Duration(seconds: 30)),
);
- The key identifies the data in the cache.
QueryKeyis a value type: two keys built from equal parts are the same key. See query keys. - The function fetches it, and must throw when it fails.
context.signallets it cancel its request. See query functions. - The one type argument is the data type. A query that shows a projection
of its data uses the other shape,
QuerySelectOptions. See describing a query once. StaleTime.duration(…)rather than a number: every option with a real "off" value is a sealed value type. See important defaults.
3. Read it
class TasksScreen extends StatelessWidget {
const TasksScreen({super.key});
Widget build(BuildContext context) {
final tasks = context.query(tasksQuery());
return Scaffold(
appBar: AppBar(title: const Text('Tasks')),
body: switch (tasks) {
QueryPending() => const Center(child: CircularProgressIndicator()),
QueryError(:final error) => Center(child: Text('$error')),
QuerySuccess(:final data) => ListView(
children: <Widget>[
for (final task in data) TaskTile(task),
],
),
},
);
}
}
QueryResult is sealed, so the switch is exhaustive and there is no data!
anywhere. QueryError also carries staleData — the last good value — which
is what lets an error banner sit above the data that is still on screen
rather than replacing it. Queries explains every state a
result can be in.
This is one of four equal ways to read a
query. The other three are a builder
widget, a State mixin and a plain ValueListenable; none of them is the
default.
4. Write something, and invalidate
Widget build(BuildContext context) {
// Take the client here, in build — not inside the callback. A mutation
// outlives the widget that started it, so `onSuccess` can run after this
// element is gone, and looking an ancestor up from a deactivated element
// throws.
final client = QueryClientProvider.of(context);
final add = context.mutation(
MutationOptions.simple(
mutationFn: api.addTask,
onSuccess: (_, __, ___) => client.invalidateQueries(
filters: QueryFilters(queryKey: tasksKey),
),
),
);
return FilledButton(
onPressed: add.value.isPending ? null : () => add.mutate('New task'),
child: Text(add.value.isPending ? 'Adding…' : 'Add'),
);
}
A mutation hands back a MutationController rather than a result, because you
need mutate as well as the state: add.value is the MutationResult,
add.mutate(vars) starts it. Invalidating the list marks it stale and
refetches it while it is on screen. See mutations and
invalidation from mutations.
The same four steps as TanStack Query's quick start: QueryClientProvider
at the root, useQuery (here context.query, or one of the other three
call styles), useMutation (here context.mutation, which returns a
controller) and invalidateQueries in onSuccess. The options are a value
you name and reuse rather than an object literal at the call site. See
differences from TanStack Query.
What you just got
Without writing any of it:
- One request for many readers. Mount the same query in five widgets and the cache deduplicates it.
- Stale-while-revalidate. A second visit renders from cache immediately and
refetches behind it if the data is older than
staleTime. - Refetch when the app returns to the foreground — and on reconnect, once you plug in connectivity — retries with exponential backoff, and garbage collection of entries nobody is watching.
- Cancellation the moment nothing is observing the query any more, when
the query function hands
context.signalto its HTTP client.
Which of those fire, and when, is important defaults.
A runnable version
The showcase's simple screen is this page's query, running in your browser:
one read, its loading and success states, and a refetch that keeps the data on
screen while it runs. The backend is in memory, with the same 300 ms latency
as the real one.
packages/query_kit_flutter/example/ is a one-file tour of the same ground —
a provider, a query read two ways and a mutation that invalidates it, with
no server. flutter run in that directory.
For every feature as its own screen, see the examples.
Next steps
- Important defaults — why the list refetched when you came back to the app, and how to change it.
- Queries — every state a result can be in, and the flags for a spinner, a refresh bar and an error banner.
- Query keys — how to name data so one invalidation reaches exactly what a write changed.
- Four ways to read a query — the
builder, the mixin and the controller, if
context.queryis not the shape your widget wants. - Mutations — callbacks, errors and optimistic updates.
- Testing — the teardown every widget test with a client needs at its end.