Overview
query_kit fetches, caches and updates the data your Dart or Flutter app gets
from a server, and keeps it fresh without you writing the plumbing. It is a
port of TanStack Query's query-core to Dart,
with a Flutter binding on top.
query_kit— the cache: staleness, background refetching, retries, cancellation, mutations, infinite queries. Pure Dart, no Flutter.query_kit_flutter— the binding: a provider, listenable controllers, builder widgets, aStatemixin andcontext.query(...). No third-party dependency.
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.
It is also a port of TanStack Query, published with gratitude under TanStack Query's MIT licence, and it is not affiliated with, endorsed by, or connected in any way to Tanner Linsley, the TanStack team, or the TanStack organisation. Problems with this package belong in this repository's issues, never theirs. Credits, and what this is not says more.
The problem: server state
Most state management tools are good at client state — the selected tab, a form's contents, the theme. Data that lives on a server is a different kind of thing:
- it is stored somewhere you do not control, and fetched asynchronously;
- somebody else can change it without your app knowing — another phone, a colleague, a device reporting in;
- the copy on screen starts going out of date the moment it arrives.
Treat it like client state and every screen grows the same machinery: a loading flag, an error flag, a cache so the second visit is not a spinner, deduplication so five widgets do not send five requests, a refresh when the app comes back to the foreground, retries for a flaky network, cancellation when nobody is looking any more, garbage collection for data nobody reads, and a way for a write to tell every screen that its copy is now wrong. That machinery is hard to get right, and most of it is invisible until it is wrong.
query_kit is that machinery, written once. You describe what a piece of server data is — a key that names it and a function that fetches it — and the cache decides when to fetch it, how long to keep it, and who is told.
What it looks like
A smart-home app's device list, in full — the description of the data in one file, the screen that shows it in another:
// lib/data/device_queries.dart — what the data is, described once.
QueryObserverOptions<List<Device>> allDevicesQuery() => QueryObserverOptions(
queryKey: QueryKey(<Object?>['devices']),
queryFn: (context) => repository.devices(signal: context.signal),
);
// lib/ui/device_list.dart — a widget that shows it.
class DeviceList extends StatelessWidget {
const DeviceList({super.key});
Widget build(BuildContext context) {
return switch (context.query(allDevicesQuery())) {
QueryPending() => const Center(child: CircularProgressIndicator()),
QueryError(:final error) => Center(child: Text('Could not load: $error')),
QuerySuccess(:final data) => ListView(
children: <Widget>[
for (final device in data) ListTile(title: Text(device.name)),
],
),
};
}
}
There is no loading flag, no initState, no StreamSubscription and no
dispose. The widget asks for the query in build; the cache fetches it,
shares it with every other widget that asks for the same key, and rebuilds
this one when the result changes.
The result is a sealed type, so the switch is exhaustive — forget the
error case and the analyzer says so — and the data is simply there, with no
data!. A QueryError also carries the last good data, which is what lets a
screen keep showing the list above a "could not refresh" banner.
context.query is one of four equal ways
to read a query: a builder widget, a State mixin and a plain
ValueListenable are the other three, and this documentation names no
default.
This is useQuery({ queryKey, queryFn }). The options become a
QueryObserverOptions value you can name and reuse, and the result a sealed
class instead of status strings with optional fields. See differences from
TanStack Query.
Here is one query running in your browser — the showcase's simple screen, against an in-memory backend. Press the refresh icon and watch the data stay on screen while the refreshing pill shows the background refetch:
What you get without writing it
- One request for many readers. Read the same query in five widgets and the cache sends one request.
- Stale-while-revalidate. A second visit renders from the cache at once
and refetches behind it when the data is older than its
staleTime. - Refetch on return and on reconnect. When the app comes back to the foreground, stale queries on screen are refreshed; connectivity is yours to plug in, with any package.
- Retries with exponential backoff, and cancellation of requests nobody is waiting for any more — once the query function hands its cancel token to the HTTP client.
- Garbage collection of entries that nothing has shown for five minutes.
- Mutations with optimistic updates and rollback, and invalidation that refetches what a write made stale.
- Pagination and infinite lists that page in both directions.
- Testability. A client is an object, not a global, and time goes through
package:clock, so a test controls staleness and garbage collection completely.
Important defaults says which of these happen out of the box and how to change each one.
Who it is for
- Flutter apps that talk to a backend — REST, GraphQL, gRPC, Firebase
callables, a local device API: anything that returns a
Future. The query function is yours, so the transport is too. - Teams already using a state management package. query_kit handles
server state and nothing else;
provider,riverpod,blocor plainsetStatekeep the client state. A controller is a plainValueListenable, so it drops into any of them. See does this replace state management? - Pure Dart code. A CLI, a server, a shared data package: the core has no Flutter in it. See using the core without Flutter.
- Developers who know TanStack Query. The concepts, option names and behaviour carry over; the name map lists what is spelled differently.
It is not a networking library (bring dio, package:http or anything else),
not a database or an offline store, and not a replacement for the state that
lives only in your app.
A port, checked against the original
The behaviour is TanStack Query's, and TanStack Query's own tests say so: the
bulk of its core test suite was ported to Dart and runs against this code, and
every upstream case that was not ported is listed with the reason. Where the port
deliberately behaves differently — mostly because Dart has sealed types,
value equality and no undefined — the difference is written down: see
differences from TanStack Query and
how fidelity is proven.
Where to go next
- Installation, then the quick start — a provider, a first query and a first write, in four steps.
- Important defaults — read this before a refetch surprises you.
- Queries, query keys and query functions — the three ideas everything else builds on.
- Coming from JavaScript? The name map is the fastest route in.
- Wondering whether something is here at all? The feature matrix says what is out and why.
- Something behaves in a way you did not expect? Troubleshooting is symptom first.