query_kit_flutter
TanStack Query for Flutter: cached server state with background refetching,
retries, mutations and infinite queries, read from your widgets in whichever
of four equal styles suits the screen. No dependency beyond Flutter — not
flutter_hooks, not a signals package, not connectivity_plus.
AI-coded. query_kit is an entirely AI-coded project: all code, tests and documentation were written by AI coding agents (Anthropic's Claude). A human maintainer set the goals and reviews releases, but did not write the code.
A port, not affiliated. The behaviour is TanStack Query's, and upstream's own test suite is ported case by case — every case left out is listed with its reason — and run against the core, with thanks to Tanner Linsley and the TanStack team. This package is not affiliated with, endorsed by or connected to them; please take problems to this repository's issues, not to TanStack.
Install
flutter pub add query_kit_flutter
That brings query_kit, the core, with
it, and one import is enough — the binding re-exports the core:
import 'package:query_kit_flutter/query_kit_flutter.dart';
Requires Flutter 3.27 or later.
Quick start
1. Provide a client
One QueryClient, created once and placed above everything that reads it:
final client = QueryClient();
runApp(
QueryClientProvider(
client: client,
child: const MyApp(),
),
);
The provider wires the app lifecycle to the client, so queries refetch when
the app comes back to the foreground. It does not dispose the client; use
QueryClientProvider.create(create: QueryClient.new, child: …) if the
provider should own it.
2. Describe a query and read it
A query is a key plus a function that fetches. Describe it once:
QueryObserverOptions<List<Task>> tasksQuery() => QueryObserverOptions(
queryKey: QueryKey(<Object?>['tasks']),
queryFn: (context) => api.listTasks(signal: context.signal),
staleTime: const StaleTime.duration(Duration(seconds: 30)),
);
and read it from a widget:
class TasksScreen extends StatelessWidget {
const TasksScreen({super.key});
@override
Widget build(BuildContext context) {
final tasks = context.query(tasksQuery());
return switch (tasks) {
QueryPending() => const Center(child: CircularProgressIndicator()),
QueryError(:final error) => Center(child: Text('$error')),
QuerySuccess(:final data) => ListView(
children: [for (final task in data) Text(task.name)],
),
};
}
}
The result is a sealed type, so the switch is exhaustive and the data
needs no !. Every widget reading tasksQuery() shares one cache entry and
one request.
context.query is one of four equal ways to read a query. None is the
recommended default; they interoperate on one screen, so pick per situation:
// A builder widget, the StreamBuilder shape.
QueryBuilder<List<Task>>(
options: tasksQuery(),
builder: (context, result) => switch (result) { … },
)
// A mixin on a State.
class _TasksPageState extends State<TasksPage> with QueryMixin {
@override
Widget build(BuildContext context) {
final tasks = watchQuery(tasksQuery());
…
}
}
final tasks = QueryController.create(client, tasksQuery());
// … tasks.value, tasks.addListener, tasks.refetch() …
tasks.dispose();
A QueryController is a ValueListenable<QueryResult<T>>, so it also works
with ValueListenableBuilder and with any state-management package that can
listen to a Listenable.
3. A first mutation
A mutation changes something on the server; invalidating the list afterwards refetches it for every widget that reads it:
@override
Widget build(BuildContext context) {
// Take the client in build, not in the callback: a mutation can outlive
// the widget that started it.
final client = QueryClientProvider.of(context);
final add = context.mutation(
MutationOptions.simple(
mutationFn: api.addTask,
// Mark the list stale; everyone reading it refetches.
onSuccess: (_, __, ___) => client.invalidateQueries(
filters: QueryFilters(queryKey: QueryKey(<Object?>['tasks'])),
),
),
);
return FilledButton(
onPressed: add.value.isPending ? null : () => add.mutate('New task'),
child: Text(add.value.isPending ? 'Adding…' : 'Add'),
);
}
Mutations come in the same four styles: context.mutation, watchMutation,
MutationBuilder and MutationController.
Features
- Four equal call styles for queries, infinite queries and mutations:
context.query,QueryBuilder,QueryMixinandQueryController. - Precise rebuilds. A widget rebuilds when its result changes;
selectnarrows the data it sees andbuildWhendecides when it rebuilds. - Everything from the core: caching with
staleTimeandgcTime, request deduplication, retries with backoff, cancellation, optimistic updates with rollback,MutationScope, infinite queries withmaxPages, initial and placeholder data, structural sharing. - App lifecycle as focus: coming back to the foreground refetches stale
queries, with the
inactivestate read per platform. - Connectivity you bring: pass an
OnlineStatusbuilt fromconnectivity_plusor anything else; nothing is installed by default. - Side effects off the build phase:
QueryListener,InfiniteQueryListenerandMutationListenerfor snackbars and navigation. - Lists and combinations:
QueriesBuilder/QueriesControllerfor a dynamic list of queries, and(a, b).combine(…)for results of different types. - App-wide indicators:
IsFetchingControllerfor a global loading bar,MutationStateControllerfor a "saving…" badge. - Testable without magic: controllers work without widgets, and widget tests need only a short, documented teardown.
Connectivity
Nothing listens to the network by default. Pass an OnlineStatus and the
client follows it — here with connectivity_plus, which stays your
dependency:
// Built once: a stream built in `build` would be resubscribed on every rebuild.
final connectivity = Connectivity()
.onConnectivityChanged
.map((results) => !results.contains(ConnectivityResult.none));
QueryClientProvider(
client: client,
onlineStatus: OnlineStatus.stream(connectivity, initial: online),
child: const MyApp(),
)
initial is required because a stream has no current value; answer it at
startup with Connectivity().checkConnectivity(). Use a broadcast stream.
connectivity_plus reports a link, not reachability: a captive-portal
wifi counts as connected.
Widget tests
A QueryClient outlives the widget tree and owns timers, and Flutter's test
binding checks for pending timers before tearDown runs. So a widget test
ends by taking the tree down and clearing the client:
testWidgets('the list loads', (tester) async {
final client = QueryClient();
await tester.pumpWidget(QueryClientProvider(
client: client,
child: const MaterialApp(home: TasksScreen()),
));
await tester.pumpAndSettle();
expect(find.byType(ListView), findsOneWidget);
// Let the widgets go, and the frame after them run.
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
// Then the cache and its timers.
client.clear();
// A mutation the clear dropped fails a moment later and its callbacks
// run then; let them, then clear what they wrote.
await tester.pump();
client.clear();
});
The testing guide wraps these steps once per suite.
Learn more
- Overview, quick start and important defaults
- Four ways to read a query and what rebuilds, and when
- Mutations, optimistic updates and infinite queries
- App focus and connectivity
- Testing
- Coming from React Query and troubleshooting
- API reference
- A runnable one-file tour:
example/lib/main.dart
License and credits
MIT. query_kit is a port of TanStack Query by
Tanner Linsley and contributors, whose MIT notice is kept in
LICENSE-TANSTACK. Bugs and questions go to
the issue tracker.
Libraries
- query_kit_flutter
- The Flutter binding for
query_kit: server state in Flutter apps — fetching, caching, background refetching, pagination and mutations — without writing any of that by hand.