# query_kit > TanStack Query for Dart and Flutter, ported test for test. 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 community port, not affiliated with or endorsed by TanStack. query_kit is a port of TanStack Query's `query-core` to Dart (the package `query_kit`, pure Dart) with a Flutter binding on top (`query_kit_flutter`), both on pub.dev. It caches server state per query key, deduplicates and retries fetches, refetches stale data on focus, on reconnect or on an interval, and covers mutations, optimistic updates and infinite queries. Its behaviour follows TanStack Query's, proven by porting upstream's test suite; where it differs is listed on the "Differences from TanStack Query" page below. Install with `flutter pub add query_kit_flutter` in a Flutter app, or `dart pub add query_kit` in pure Dart. Every page below is Markdown at its own URL with `.md` appended, and all of them together are https://dualmeta-gmbh.github.io/query_kit/llms-full.txt. Source: https://github.com/dualmeta-gmbh/query_kit --- # Overview > What query_kit is, the server-state problem it solves, what reading a query looks like in Flutter, and who it is for. 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](https://tanstack.com/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, a `State` mixin and `context.query(...)`. No third-party dependency. > **Danger: Read this first** > > 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](https://github.com/dualmeta-gmbh/query_kit/issues), > never theirs. [Credits, and what this is not](https://dualmeta-gmbh.github.io/query_kit/docs/project/credits.md) 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: ```dart // lib/data/device_queries.dart — what the data is, described once. QueryObserverOptions> allDevicesQuery() => QueryObserverOptions( queryKey: QueryKey(['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}); @override 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: [ 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](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) to read a query: a builder widget, a `State` mixin and a plain `ValueListenable` are the other three, and this documentation names no default. > **Note: In React Query** > > 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](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). 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: Live demo: [Simple](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/simple), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/simple)). One query, its states, and a 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](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md). - **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](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md) 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`, `bloc` or plain `setState` keep the client state. A controller is a plain `ValueListenable`, so it drops into any of them. See [does this replace state management?](https://dualmeta-gmbh.github.io/query_kit/docs/guides/does-this-replace-state-management.md) - **Pure Dart code.** A CLI, a server, a shared data package: the core has no Flutter in it. See [using the core without Flutter](https://dualmeta-gmbh.github.io/query_kit/docs/guides/pure-dart.md). - **Developers who know TanStack Query.** The concepts, option names and behaviour carry over; [the name map](https://dualmeta-gmbh.github.io/query_kit/docs/coming-from-react-query.md) 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](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md) and [how fidelity is proven](https://dualmeta-gmbh.github.io/query_kit/docs/project/fidelity.md). ## Where to go next - [Installation](https://dualmeta-gmbh.github.io/query_kit/docs/installation.md), then the [quick start](https://dualmeta-gmbh.github.io/query_kit/docs/quick-start.md) — a provider, a first query and a first write, in four steps. - [Important defaults](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md) — read this before a refetch surprises you. - [Queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md), [query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md) and [query functions](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-functions.md) — the three ideas everything else builds on. - Coming from JavaScript? [The name map](https://dualmeta-gmbh.github.io/query_kit/docs/coming-from-react-query.md) is the fastest route in. - Wondering whether something is here at all? [The feature matrix](https://dualmeta-gmbh.github.io/query_kit/docs/reference/feature-matrix.md) says what is out and why. - Something behaves in a way you did not expect? [Troubleshooting](https://dualmeta-gmbh.github.io/query_kit/docs/reference/troubleshooting.md) is symptom first. --- # Installation > Which package to add, what it depends on, the Dart and Flutter floors, and the analyzer settings that make the types work for you. query_kit comes as two packages, and you add the one that matches what you are building: | Package | For | Depends on | |---|---|---| | `query_kit` | Pure Dart: a CLI, a server, a shared data package | `clock`, `meta` | | `query_kit_flutter` | A Flutter app | Flutter, `meta`, `query_kit` | ## In a Flutter app ```bash flutter pub add query_kit_flutter ``` Or by hand, in `pubspec.yaml`: ```yaml dependencies: flutter: sdk: flutter query_kit_flutter: ^1.0.0 ``` The binding re-exports the core, so one import gives you the whole surface — the client, the options, the results and the widgets: ```dart import 'package:query_kit_flutter/query_kit_flutter.dart'; ``` If a file of yours imports `package:query_kit/query_kit.dart` directly — a data layer you keep free of Flutter, say — list `query_kit` in your `pubspec.yaml` as well. Dart's `depend_on_referenced_packages` lint asks for it, and it is right to: a package you import is a package you depend on. ## In pure Dart ```bash dart pub add query_kit ``` ```dart import 'package:query_kit/query_kit.dart'; ``` Everything the cache does — staleness, retries, cancellation, mutations, infinite queries — is in the core. What the binding adds is the Flutter side: the provider that maps the app lifecycle onto focus, and the four ways to read a query in a widget. Without it, you call `client.mount()` yourself; see [using the core without Flutter](https://dualmeta-gmbh.github.io/query_kit/docs/guides/pure-dart.md). A common split in a larger app: a `data` package that depends on `query_kit` only and holds the keys and the options functions, and the app, which depends on `query_kit_flutter` and reads them. The data package then runs its tests with `dart test`, no Flutter needed. ## No third-party dependency Neither package pulls in anything beyond the Dart team's `clock` and `meta` — not `flutter_hooks`, not a signals package, not `connectivity_plus`, not an HTTP client. You bring the HTTP client ([query functions](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-functions.md) shows dio and `package:http`) and, if you want reconnect refetches, the connectivity source ([connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md) shows how to plug any package in). You should not have to adopt somebody's state management to use a cache. ## Requirements | | Floor | Notes | |---|---|---| | `query_kit` | Dart SDK `^3.6.0` | No Flutter. Runs on the VM and compiled to JavaScript; both are tested. | | `query_kit_flutter` | Flutter `>=3.27.0` (which ships Dart 3.6) | Tested on 3.27 as well as current stable. | Platforms: the core runs everywhere Dart does. The binding is exercised on the web by the examples' end-to-end suites; the other platforms are untested rather than unsupported — it uses nothing platform-specific beyond `AppLifecycleState`. ## Recommended analyzer settings Most of the type safety is there without configuration. Three analyzer settings make the rest of it visible, and the packages themselves are built with them: ```yaml # analysis_options.yaml include: package:flutter_lints/flutter.yaml analyzer: language: strict-casts: true strict-inference: true strict-raw-types: true linter: rules: - unawaited_futures ``` - **`strict-inference`** reports the one options literal inference cannot type: a key-only one with neither a `queryFn` nor a type argument, which Dart would otherwise make `dynamic`. See [type safety in Dart](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md). - **`strict-raw-types`** reports a `QueryResult` or `QueryObserverOptions` written without its type argument. - **`unawaited_futures`** reports a `client.invalidateQueries(...)` or `client.query(...)` whose future nobody awaits. Await it, or mark a deliberate fire-and-forget with `unawaited(...)` from `dart:async`. ## Next The [quick start](https://dualmeta-gmbh.github.io/query_kit/docs/quick-start.md) — a provider, a first query, and a write that refreshes it. > **Note: In React Query** > > The split mirrors `@tanstack/query-core` and `@tanstack/react-query`, except > that the Flutter package re-exports the core, so an app needs one import. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Quick start > A provider at the root, an options function, a widget that reads it — and the mutation that invalidates it. 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 ```bash 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](https://dualmeta-gmbh.github.io/query_kit/docs/installation.md). 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](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-functions.md) shows one built on dio and one on `package:http`. ## 1. A client at the root ```dart 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: ```dart 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: ```dart final QueryKey tasksKey = QueryKey(['tasks']); QueryObserverOptions> 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. `QueryKey` is a value type: two keys built from equal parts are the same key. See [query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md). - The **function** fetches it, and must throw when it fails. `context.signal` lets it cancel its request. See [query functions](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-functions.md). - 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](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md). - **`StaleTime.duration(…)`** rather than a number: every option with a real "off" value is a sealed value type. See [important defaults](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md). ## 3. Read it ```dart class TasksScreen extends StatelessWidget { const TasksScreen({super.key}); @override 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: [ 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](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md) explains every state a result can be in. This is one of [four equal ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md). 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 ```dart @override 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](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) and [invalidation from mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md). > **Note: In React Query** > > 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](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). ## 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](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md) — 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.signal` to its HTTP client. Which of those fire, and when, is [important defaults](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md). ## 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. Live demo: [Simple](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/simple), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/simple)). One query, its states, and a refetch. `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](https://dualmeta-gmbh.github.io/query_kit/docs/examples.md). ## Next steps - [Important defaults](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md) — why the list refetched when you came back to the app, and how to change it. - [Queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md) — every state a result can be in, and the flags for a spinner, a refresh bar and an error banner. - [Query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md) — how to name data so one invalidation reaches exactly what a write changed. - [Four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) — the builder, the mixin and the controller, if `context.query` is not the shape your widget wants. - [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) — callbacks, errors and [optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md). - [Testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md) — the teardown every widget test with a client needs at its end. --- # Important defaults > What the cache does out of the box — stale at once, kept five minutes, retried three times, refetched on return to the app — and how to change each default. Out of the box, query_kit is configured with **aggressive but sane** defaults — the same numbers TanStack Query uses. They keep data fresh without any configuration, and every one of them can surprise you once: a refetch you did not ask for, an error that took seven seconds to appear, an entry that is gone when you come back after lunch. This page lists them, says why each one is what it is, and shows how to change it. ## At a glance | What | Default | Change it with | |---|---|---| | How long data counts as fresh | `StaleTime.zero` — stale at once | `staleTime` | | Refetch when a reader mounts | if stale | `refetchOnMount` | | Refetch when the app returns to the foreground | if stale | `refetchOnWindowFocus`, the provider's `isAppShown` | | Refetch when the network comes back | if stale — but nothing reports the network by default | `refetchOnReconnect`, the provider's `onlineStatus` | | Polling | off | `refetchInterval`, `refetchIntervalInBackground` | | How long an unused entry stays cached | `GcTime.defaultValue` — five minutes | `gcTime` | | Retries of a failed query | three, 1 s → 2 s → 4 s apart | `retry`, `retryDelay` | | Retries of a failed mutation | none | `retry` on the mutation or the mutation defaults | | Unchanged data after a refetch | keeps its old instances | `structuralSharing` | | Fetching while offline | paused until online | `networkMode` | ## Queries ### Cached data is stale at once `staleTime` defaults to `StaleTime.zero`: data counts as stale the moment it arrives. Stale does not mean hidden — stale data is shown like any other; it only means the next *trigger* below refetches it in the background. A longer `staleTime` is the first thing most apps change, and the one with the biggest effect: data that is fresh is read from the cache without asking anybody, however many screens read it. Three values do more than a duration: - **`StaleTime.infinite`** — never stale by time. Nothing refetches it on its own, but an [invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md) still does. For data only your own writes change — the invalidation after the write is then the only refresh. - **`StaleTime.static`** — never stale once it has data and, while a widget reads it, never refetched by any trigger, invalidation included; only an explicit `refetch()` or a `refetchInterval` fetches it. For data that cannot change while the app runs: a feature-flag set fetched at start-up, reference tables. - **`StaleTime.dynamic((query) => …)`** — computed per query, from its state. ### Stale queries refetch on three triggers A stale query that a widget is reading is refetched in the background when: - **another reader mounts** — a second screen opens on the same key (`refetchOnMount`); - **the app returns to the foreground** (`refetchOnWindowFocus`); - **the network comes back** (`refetchOnReconnect`). All three default to `RefetchOn.ifStale`. `RefetchOn.always` refetches even fresh data, `RefetchOn.never` switches the trigger off, and `RefetchOn.when((query) => …)` decides per query. A query with `NetworkMode.always` defaults to `RefetchOn.never` on reconnect: it never waited for the network in the first place. If a refetch surprises you, it was one of these three. The usual fix is not to switch a trigger off but to give the data a `staleTime` it deserves. ### No polling `refetchInterval` defaults to `RefetchInterval.off`. An interval you switch on refetches whether the data is stale or not, and pauses while the app is in the background unless `refetchIntervalInBackground` is `true`. See [polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md). ### Unused data is kept for five minutes A query nothing reads any more — every widget that showed it is gone — stays in the cache for `gcTime`, `GcTime.defaultValue`, which is five minutes, and is then garbage collected. A screen that comes back within that time renders at once from the cache (and refetches behind it, if stale). `GcTime.never` keeps an entry until you remove it or clear the client. `staleTime` and `gcTime` answer different questions — *when do I ask again?* and *when do I forget?* — and the showcase's *stale and gc* screen sets each one live. Pick `5 s` under *Stale time* and watch `isStale` flip five seconds after a fetch; press the *Detach reader* icon with GC time `5 s` and watch the strip turn to `status=absent` five seconds later. `static` ignores the *Invalidate* icon; `infinite` does not: Live demo: [Stale time and garbage collection](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/stale-and-gc), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/stale_and_gc)). When data goes stale, and when an unused entry is dropped. ### A failed query is retried three times Before an error reaches the screen, a failed query is retried **three times** — `RetryPolicy.times(3)`, four attempts in all — with `RetryDelay.defaultValue` between them: one second, then two, then four, doubling up to a cap of thirty seconds. So the first error of a query against a server that is down appears about seven seconds after the first attempt. While it retries, the result stays what it was — pending, or the old data — with `failureCount` and `failureReason` telling you it is struggling. A query that failed is also retried when a new reader mounts (`retryOnMount`, default `true`). See [query retries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md). ### Unchanged data keeps its instances After a refetch, the new data is compared with the cached data and every part that is deep-equal keeps the **cached instance** — `replaceEqualDeep`. A refetch that brings back the same list hands every reader the identical object, so nothing rebuilds for it. Lists are walked element by element; maps and sets are compared deeply and kept whole when equal; your own classes are compared with their `==`, so give model classes value equality (by hand, `equatable` or `freezed`) or each refetch renews them. Switch it off per query with `structuralSharing: noStructuralSharing()`. See [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md). ### Queries wait for the network `networkMode` defaults to `NetworkMode.online`: while the client believes it is offline, a fetch does not start — the query is *paused*, and resumes when the network comes back. Since nothing reports the network by default (see below), the client believes it is online until you [tell it otherwise](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md). See [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md). ## Mutations - **Mutations are not retried.** `retry` defaults to `RetryPolicy.never`: a write that half-happened is not safe to send again blindly. Opt in, per mutation or in the client's mutation defaults, for writes that are idempotent. - **A mutation started offline is paused**, not failed, and resumes when the client learns it is back online — the same `NetworkMode.online` default. - **A finished mutation is kept for five minutes**, so cache-wide [mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md) can still see it. ## The client and the app - **Create the client once.** A `QueryClient` *is* the cache. Build it at start-up, outside any `build` method: a client built in `build` is a new, empty cache on every rebuild. The client, its focus manager and its online manager are objects, not globals, so two clients in one process — two widget tests — never see each other. - **The provider wires the client to the app, and does not dispose it.** `QueryClientProvider` mounts the client it is given: it maps the app lifecycle onto focus and makes listener notifications safe during a build. `client.clear()` stays yours to call; `QueryClientProvider.create` builds a client and clears it when the provider goes. - **Focus follows the app lifecycle.** `resumed` counts as focused; `hidden`, `paused` and `detached` do not. `inactive` counts as focused on iOS and Android, where it is a passing interruption — the notification shade, a call — and as unfocused on macOS, Windows and Linux, where it means the window lost focus. See [app focus and refetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md). - **No connectivity source is installed.** The core ships no network check and the binding depends on no connectivity package, so a client assumes it is online, and `refetchOnReconnect` never fires, until you pass the provider an `onlineStatus`. See [connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md). - **An imperative `client.query` makes one attempt.** The three retries above are for queries a widget reads. A one-off fetch or prefetch that configures no `retry` — in its options or in any defaults — does not retry. See [prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md). ## Changing a default Every default can be set at three levels, and a field set closer to the query wins: **the query's own options**, then **defaults for a key prefix**, then **defaults for the whole client**, then the built-in value. A field you leave `null` is "not configured" and falls through to the next level. For the whole app, pass `defaultOptions` to the client: ```dart // lib/main.dart final QueryClient queryClient = QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults( // Fresh for 30 seconds: a second screen within that time reads the // cache and asks nobody. staleTime: StaleTime.duration(Duration(seconds: 30)), // One retry, not three, before the error reaches the screen. retry: RetryPolicy.times(1), ), mutations: MutationDefaults( // Our writes are idempotent PUTs, so sending one twice is safe. retry: RetryPolicy.times(2), ), ), ); ``` For a family of keys, register defaults under their common prefix — every query whose key starts with `['energy']` picks them up: ```dart // Live readings: refetched on every return to the app, and dropped soon // after no screen shows them. client.setQueryDefaults( QueryKey(['energy']), const QueryDefaults( refetchOnWindowFocus: RefetchOn.always, gcTime: GcTime.duration(Duration(seconds: 30)), ), ); ``` For one query, set the field in its options: ```dart QueryObserverOptions firmwareQuery(String deviceId) => QueryObserverOptions( queryKey: DeviceKeys.firmware(deviceId), queryFn: (context) => repository.firmware(deviceId, signal: context.signal), // Only an update changes it, and the update invalidates this key. staleTime: StaleTime.infinite, // Coming back to the app is no reason to ask again. refetchOnWindowFocus: RefetchOn.never, ); ``` `client.setDefaultOptions(...)` replaces the client-wide defaults later. They take effect wherever options are resolved next: a new reader, or an existing one the next time its options are applied — every build for `context.query` and `watchQuery`, a rebuild by its parent for a builder widget, a `setOptions` for a controller. A query already fetching keeps what it started with. The showcase's *playground* screen does exactly that — its *Stale time* and *GC time* knobs call `setDefaultOptions`. Set *Error rate* to `100 %` and press the refresh icon on *Todos* to watch `failureCount` climb through the retries, or pick *Stale time* `30 s` and see `isStale=false` hold with no fetch: Live demo: [Playground](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/playground), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/playground)). Todos with live knobs for stale time, gc time, latency and errors. The app-lifecycle mapping is the provider's to change. To count only a fully resumed app as focused, on every platform: ```dart QueryClientProvider( client: queryClient, // Only a fully resumed app counts as focused, on every platform. isAppShown: (state) => state == AppLifecycleState.resumed, child: app, ) ``` > **Note: In React Query** > > These are TanStack Query's [important > defaults](https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults), > with the same numbers. What differs is the spelling — sealed values like > `StaleTime.infinite` and `RefetchOn.ifStale` for `Infinity` and `true` — and > the source of focus and connectivity: the app lifecycle instead of the > browser's `visibilitychange`, and no online listener until you install one. > See [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). ## The mount contract Outside Flutter — or when you manage a client's life yourself — these three calls are the whole lifecycle: ```dart client.mount(); // once, at start-up client.unmount(); // to balance your own mount() client.clear(); // at the end: drop the caches and their timers ``` Without a mount, **nothing** reacts to the app returning to the foreground or the device coming back online: no `refetchOnWindowFocus`, no `refetchOnReconnect`, no resuming of paused mutations, and a `query` that paused offline waits for a reconnect only while mounted. > **Warning: In Flutter, the provider owns this** > > `QueryClientProvider` mounts the client it is given and unmounts it again when > it goes. That count is what keeps focus and reconnect refetches wired, so **an > extra `unmount()` of your own unbalances it** and the client stops listening to > either. Call `unmount()` only to balance a `mount()` you made yourself. ## Further reading The [caching walkthrough](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md) follows one query through these defaults step by step, and [default query function](https://dualmeta-gmbh.github.io/query_kit/docs/guides/default-query-function.md) shows key defaults carrying a shared `queryFn`. --- # Coming from TanStack Query (JS) > Every JavaScript name mapped to its Dart counterpart — reading a query, the client, options, infinite queries, mutations. The behaviour is TanStack Query's — the tests say so — but the surface is Dart's. This page maps the JavaScript names to the ones here. Where the port deliberately *behaves* differently, [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md) says how. Two rules shape most of the table: - **`null` means "not configured"** on every option field, so an option with a real "off" value is a sealed value type (`StaleTime`, `GcTime`, `Enabled`, `RetryPolicy`, `RetryDelay`, `RefetchOn`, `RefetchInterval`), never a magic number, string or boolean. - **The result is a sealed type** — `QueryPending`, `QuerySuccess`, `QueryError` — so a `switch` is exhaustive and there is no `data!`. A `QueryError` still carries `staleData`. ## Reading a query `useQuery` has no single counterpart. The binding offers four **equal** alternatives — there is no recommended default, pick per situation: | JS | Here | |---|---| | `useQuery(options)` in a component | `QueryBuilder(options: …, builder: …)` | | | `QueryController(client, options)` — a `ValueListenable` | | | `with QueryMixin` on a `State`, then `watchQuery(options)` in `build` | | | `context.query(options)` in any widget under a `QueryClientProvider` | | `useInfiniteQuery` | `InfiniteQueryBuilder` / `InfiniteQueryController` / `watchInfiniteQuery` / `context.infiniteQuery` | | `useMutation` | `MutationBuilder` / `MutationController` / `watchMutation` / `context.mutation` | | `useQueryClient()` outside a build (a handler) | `QueryClientProvider.read(context)` — the same client, without subscribing | | `useQueryClient()` | `QueryClientProvider.of(context)` | | `QueryClientProvider` | `QueryClientProvider(client: …, child: …)` | | `new QueryObserver(client, options)` | `client.observe(options)` or `QueryObserver(client, options)` | ## The client | JS | Here | |---|---| | `fetchQuery`, `prefetchQuery`, `ensureQueryData` | one `QueryClient.query(options)`; prefetch is `client.query(…).ignore()`, "only if nothing is cached" is `staleTime: StaleTime.static` | | `ensureQueryData({ revalidateIfStale: true })` | `client.query(options, revalidateIfStale: true)` — cached data now, refresh behind it | | `useQueries({ queries })` | `QueriesObserver` / `QueriesBuilder`, homogeneous: one data type per collection, `select` when the selected type differs. For different types, `(a, b).combine(…)` over a record of results | | `useMutationState({ filters, select })` | `MutationStateObserver` / `MutationStateController` | | `useIsFetching(filters)` | `IsFetchingController(client, filters: …)`, a `ValueListenable`; the snapshot is `client.isFetching()` | | `useIsMutating(filters)` | a `MutationStateController` filtered on `MutationStatus.pending`, read for its length; the snapshot is `client.isMutating()` | | `getQueryData>(key)` | `getInfiniteQueryData(key)` | | `fetchQuery({ select })` | none — `await` the future and map it | | `setQueryData(key, value)` | `setQueryData(key, value)` | | `setQueryData(key, updaterFn)` | `updateQueryData(key, (previous) => …)`; returning `null` leaves the cache untouched | | `setQueriesData(filters, updaterFn)` | `updateQueriesData(updater, filters: QueryFilters(…))` — every filter parameter in the core is a named `filters:` | | `getQueryData(key)` | `getQueryData(key)` — a type mismatch throws `QueryDataTypeError` | | a key read as a looser type (`number \| undefined` where `number` was cached) | throws too: one key, one exact type — `int` and `int?`, `List` and `List` are different types, and `getQueriesData` checks like `getQueryData` | | `invalidateQueries({ queryKey })` | `invalidateQueries(filters: QueryFilters(queryKey: …))` | | `queryKeyHashFn` | gone: `QueryKey` is a value type with structural equality; `debugString` is the readable form | | `focusManager`, `onlineManager`, `notifyManager` (module-level) | per-client instances: `client.focusManager`, `client.onlineManager`, `client.notifyManager` | | `setMutationDefaults(key, { onSuccess, … })` | `MutationDefaults` carries `mutationFn`, `retry`, `retryDelay`, `networkMode`, `gcTime`, `scope`, `meta` — no callbacks | ## Options | JS | Here | |---|---| | `queryKey: ['tasks', id]` | `QueryKey(['tasks', id])` | | `queryFn: ({ signal }) => …` | `queryFn: (context) => …`; `context.signal` is a `QueryCancelToken`, and `signal.onCancel(…)` is the interop point for `dio` and friends | | `enabled: false` | `Enabled.no` | | `enabled: () => bool` | `Enabled.when((query) => …)` | | `skipToken` | `Enabled.no`, which keeps `enabled: false`'s meaning where the two differ: a cached query nobody observes is still refetched by `refetchQueries` / `invalidateQueries(refetchType: RefetchType.all)` | | `staleTime: 0` (the default) | `StaleTime.zero` | | `staleTime: 30_000` | `StaleTime.duration(Duration(seconds: 30))` | | `staleTime: Infinity` | `StaleTime.infinite` — never stale by time, still refetched when asked | | `staleTime: 'static'` | `StaleTime.static` — never stale and, while an observer holds the query, skipped by every refetch trigger | | `staleTime: (query) => …` | `StaleTime.dynamic((query) => …)` | | `gcTime: 300_000` | `GcTime.duration(Duration(minutes: 5))` (`GcTime.defaultValue`) | | `gcTime: Infinity` | `GcTime.never` | | `retry: 3` | `RetryPolicy.times(3)` | | `retry: false` / `retry: true` | `RetryPolicy.never` / `RetryPolicy.always` | | `retry: (count, error) => bool` | `RetryPolicy.when((failureCount, error, stackTrace) => …)` | | `retryDelay: 1000` | `RetryDelay.fixed(Duration(seconds: 1))`; the default backoff is `RetryDelay.exponential()` | | `refetchOnWindowFocus: false` | `RefetchOn.never` | | `refetchOnWindowFocus: true` | `RefetchOn.ifStale` | | `refetchOnWindowFocus: 'always'` | `RefetchOn.always` | | `refetchOnMount`, `refetchOnReconnect` | the same `RefetchOn` values | | `refetchInterval: 5000` | `RefetchInterval.every(Duration(seconds: 5))` | | `refetchInterval: false` | `RefetchInterval.off` | | `refetchInterval: (query) => …` | `RefetchInterval.dynamic((query) => …)` | | `networkMode: 'online'` | `NetworkMode.online` (also `always`, `offlineFirst`) | | `initialData: value` | `InitialData.value(value)` | | `initialData: () => value \| undefined` | `InitialData.compute(() => …)`; returning `null` means "none", while `InitialData.value(null)` is a value of `null` | | `initialDataUpdatedAt: number` | `initialDataUpdatedAt: DateTime?` | | `initialDataUpdatedAt: () => number` | `initialDataUpdatedAtCompute: () => DateTime?` — evaluated only when data is actually seeded; `null` means now. Give one form or the other, never both | | `placeholderData: value` / `(previous) => …` | `PlaceholderData.value(…)` / `PlaceholderData.compute(…)` | | `placeholderData: keepPreviousData` | `const PlaceholderData.keepPrevious()` | | `select: (data) => …` | its own options shape: `QuerySelectOptions`, `select` required. Without one, `QueryObserverOptions` has a single type argument | | `notifyOnChangeProps` | gone: `select` narrows what is reported, and every builder and keyless read takes `buildWhen` — a mutation's too, where there is no `select` | | `throwOnError` | gone: errors are the `QueryError` case of the sealed result | | `structuralSharing` | on by default: lists are shared element by element, maps and sets whole when deep-equal, everything else by `==`, so typed models need `==`/`hashCode`; `structuralSharing: (previous, next) => …` replaces it for the cache write and placeholder data; what `select` produced is still shared by the default comparison, because the hook is typed for the query's data and cannot be handed a selection | | `structuralSharing: false` | `structuralSharing: noStructuralSharing()` — off for the cache write, placeholder data and `select` output alike; a hook of your own such as `(_, next) => next` does not reach `select` | | `queryCache.find({ queryKey })` | `queryCache.find(filters: QueryFilters(queryKey: …))` — exact by default, as in TanStack Query; `exact: false` for a prefix | ## Infinite queries | JS | Here | |---|---| | `queryFn: ({ pageParam }) => page` | `pageFn: (context) => page`, where `InfinitePageContext` carries `pageParam` and `direction` | | `initialPageParam`, `getNextPageParam`, `getPreviousPageParam` | the same names on `InfiniteQueryOptions` | | `data.pages`, `data.pageParams` | `InfiniteData.pages`, `InfiniteData.pageParams` | | `hasNextPage`, `fetchNextPage()` on the result | on `InfiniteQueryObserver` / `InfiniteQueryController`; the sealed result keeps one shape | | `maxPages` | `maxPages` | ## Mutations | JS | Here | |---|---| | `mutationFn: (variables, context) => …` | `mutationFn: (variables) => …`, or `mutationFnWithContext: (variables, context) => …` — two fields, because Dart has no optional-arity function types. The context also carries `onMutateResult` and a `signal` | | `mutate(vars, { onSuccess })` | `mutate(vars, callbacks: MutateCallbacks(onSuccess: …))` | | `mutateAsync(vars)` | `mutateAsync(vars)` | | `onMutate` returning rollback context | `onMutate` returning `TOnMutateResult`, the observer's third type parameter | | `useMutation` without `onMutate` | `MutationOptions.simple(mutationFn: …)` — fixes the third type parameter to `void`, so the other two infer from `mutationFn` | | `result.context` (what `onMutate` returned, on the mutation result) | not on `MutationResult`: its job is the rollback, which `onError` and `onSettled` receive as their last argument | | `scope: { id }` | `scope: MutationScope(id)` | | — | `cancel()` on a `MutationController`, observer or `Mutation`: fails the run with a `CancelledError` — see [cancelling a mutation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md) | ## Caches and events | JS | Here | |---|---| | `new QueryCache({ onError, onSuccess, onSettled })` | `QueryCache(onError: …, onSuccess: …, onSettled: …)` — final constructor arguments; see [global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md) | | `new MutationCache({ onMutate, onSuccess, onError, onSettled })` | `MutationCache(…)`, the same four | | `queryCache.subscribe(listener)` | `queryCache.subscribe((event) => …)`; the event is a sealed `QueryCacheEvent` — `QueryAdded`, `QueryRemoved`, `QueryUpdated`, … | | `meta` | `meta`, an `Object?`, on query and mutation options; `context.meta` in the function, `query.meta` in the cache callbacks | ## See it running Every row above has a screen in [the showcase](https://dualmeta-gmbh.github.io/query_kit/docs/examples.md) that shows the behaviour, with widget tests and end-to-end tests that prove it. Open the app and pick the feature, or read the screen's file — each starts with what it shows and how it is proven: | Topic | Screen | |---|---| | reading a query, the four call styles | `simple`, `four-call-styles` | | `select`, `buildWhen`, structural sharing | `select-and-sharing`, `build-when` | | `initialData`, `placeholderData` | `initial-and-placeholder` | | `staleTime`, `gcTime` | `stale-and-gc`, `cache-inspector` | | `enabled` | `dependent-queries` | | the client's imperative surface, filters | `invalidation-and-filters`, `prefetching`, `default-query-function` | | infinite queries | `load-more`, `max-pages`; `pagination` for the page-numbered shape | | mutations, optimistic updates | `mutations`, `optimistic-updates`, `playground` | | cancelling a mutation, `mutationFnWithContext` | `mutation-cancel` | | retries, cancellation | `retry`, `cancellation` | | `refetchInterval`, focus, online | `auto-refetching`, `focus-refetch`, `offline` | | cache callbacks, `meta` | `global-callbacks` | | a list of queries (`useQueries`) | `query-collections`, `parallel-queries` | | `useQueries`' `combine` | `combine` | | cache-wide mutation state (`useMutationState`) | `mutation-state` | | the provider's knobs — `QueryClientProvider.create`, `isAppShown`, `onlineStatus`, `maybeOf` | `focus-refetch` | | the errors the port adds — `QueryDataTypeError`, `MissingMutationFunctionError` | `diagnostics` | ## Not here at all - **Suspense.** React-only. The sealed result is the answer: a `switch` over `QueryPending` / `QuerySuccess` / `QueryError` puts the loading and error states in the same place as the data, with no boundary to set up. - **SSR and hydration.** There is no server rendering in Flutter, so `isServer`, `dehydrate`/`hydrate` and `HydrationBoundary` have nothing to do. - **Devtools.** None. The cache is observable, though: [inspecting the cache](https://dualmeta-gmbh.github.io/query_kit/docs/guides/debugging.md) shows how to watch it, and the showcase's `cache-inspector` screen is a small inspector built that way. - **Persistence.** Not in 1.0. A persister would restore entries with `setQueryData` at start-up and save them from a `queryCache.subscribe` listener; there is no ready-made one. - **`streamedQuery`.** Not ported. A stream subscription that writes into the cache with `setQueryData`, or invalidates it, does the same job. [The feature matrix](https://dualmeta-gmbh.github.io/query_kit/docs/reference/feature-matrix.md) lists everything that is out. --- # Type safety in Dart > Sealed results, one key one exact type, two options shapes, and the analyzer setting that catches the one literal inference cannot type. TanStack Query leans on TypeScript: generics that thread a key's shape into its function, a data type the compiler trusts and nobody checks at runtime. Dart's type system is sound and has sealed classes, so the port leans on those instead. The result is fewer type parameters, no `!`, and one runtime check that is stricter than you may expect. What you feel at the call site: ## The result is sealed A query result is one of `QueryPending`, `QuerySuccess` or `QueryError`. A `switch` over it is exhaustive, so the compiler tells you when a state is not handled, and the data is a non-nullable field of `QuerySuccess` — no `data!`. `QueryError` carries `staleData`, the last good value, for a refetch that failed with data already on screen. See [queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md). ```dart Widget deviceTitle(QueryResult device) => switch (device) { QueryPending() => const Text('…'), QuerySuccess(:final data) => Text(data.name), // `staleData` is a Device? — the last good value, when there was one. QueryError(:final staleData?) => Text('${staleData.name} (offline)'), QueryError() => const Text('Unknown device'), }; ``` Case order matters in the usual Dart way: the `QueryError(:final staleData?)` arm matches only when there is stale data, so the plain `QueryError()` after it catches the rest. A mutation result is sealed the same way: `MutationIdle`, `MutationPending`, `MutationSuccess`, `MutationError`. ## Two type parameters, not five - `Query` at the cache layer, `QueryObserver` where a `select` needs a second. - There is no `TError`: errors are an `Object` plus a `StackTrace`, as everywhere in Dart. - There is no `TQueryKey`: a `QueryKey` is a value type, deep-frozen and compared by value. See [query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md). ## Two options shapes Observer options come in two shapes: `QueryObserverOptions`, with no `select` and one type argument, and `QuerySelectOptions`, with `select` required. The required `select` is what anchors the second type, so neither shape has a type argument inference cannot fill. See [describing a query once](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md). ## The one literal inference cannot type A key-only options literal — neither a `queryFn` nor a type argument, relying on a cached entry or a [default query function](https://dualmeta-gmbh.github.io/query_kit/docs/guides/default-query-function.md) — has nothing to infer its data type from, and Dart would silently make it `dynamic`. Two things catch it: - the binding's controllers refuse it in debug builds, with a message naming the cure; - the analyzer reports it at the literal once `analysis_options.yaml` asks for strict inference. Recommended: ```yaml analyzer: language: strict-inference: true ``` The cure is an explicit type argument: ```dart // Nothing to infer the type from — no queryFn, no expected type — so name // it. Without , this would be a QueryResult. final device = context.query( QueryObserverOptions(queryKey: DeviceKeys.detail(id)), ); ``` ## One key, one exact type A key is bound to the data type it was first used with, and reading it as any other type throws `QueryDataTypeError` — **related types included**. `int` and `int?` are two types. So are `List` and `List`. ```dart final key = DeviceKeys.list(); client.setQueryData>(key, const []); client.getQueryData>(key); // the entry's own type: fine client.getQueryData>(key); // throws QueryDataTypeError ``` The error is thrown by the call itself, synchronously, and names the key, the type asked for and the type the entry holds. TanStack Query casts blindly, and TypeScript cannot tell. Here `getQueryData`, `getQueriesData` and an observer's `TQueryData` all have to agree with the key's first use. It is the single most likely thing to catch you out when porting JavaScript, and it is catching a real bug. A **write** is the one place a related type is welcome. `setQueryData` infers its type from the value (and so do `updateQueryData` and `updateQueriesData` from the updater), so an entry that already exists takes any value its own type can hold — a `String` into a `String?` query, a sealed type's variant into a query of the sealed type — and keeps its type: ```dart // The type comes from the value: a Device, into an entry holding a Device. client.setQueryData( DeviceKeys.detail(device.id), device.copyWith(isOn: true)); // The type comes from the updater's parameter. client.updateQueryData( DeviceKeys.list(), (List? devices) => [ for (final each in devices ?? const []) each.id == device.id ? device : each, ], ); ``` Name the type when the write *creates* the entry, as when seeding a key before its query exists: `setQueryData>(key, const [])`. A bare `setQueryData(key, null)` writes nothing. The showcase's *diagnostics* screen holds an `int` under one key. Press *Read as String* and the facts read `read=QueryDataTypeError`, `expected=String`, `actual=int`; *Read as int* succeeds, and *Write a String* is refused the same way. The second card shows a mutation with no function failing with `MissingMutationFunctionError` until *Register a default mutationFn* supplies one: Live demo: [Diagnostics](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/diagnostics), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/diagnostics)). What the library throws, and when: the wrong type, the missing function. When one key prefix spans entries of different types, give each type its own key, or store a wrapper type that says what the entry is. See [troubleshooting](https://dualmeta-gmbh.github.io/query_kit/docs/reference/troubleshooting.md#querydatatypeerror-for-a-list-that-is-the-right-type). ## Sealed values instead of magic numbers `null` means "not configured" on every option field. An option with modes is a sealed value type — `StaleTime`, `GcTime`, `Enabled`, `RetryPolicy`, `RetryDelay`, `RefetchOn`, `RefetchInterval` — never a magic number, string or boolean. `staleTime: 0`, `staleTime: Infinity` and `staleTime: 'static'` are three ideas JavaScript squeezes into one field; here they are three constructors of one sealed class, and a `switch` over them is exhaustive. See [describing a query once](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md#two-rules) for the computed forms. ## Where the types live in an app The pattern the guides use: one file per data area under `lib/data/`, holding the key factory and one function per query returning fully typed options — `QueryObserverOptions> devicesQuery(...)`. Widgets call the function and never write a type argument; the options carry it, the result is typed from them, and the one place a key is bound to its type is the one function that builds it. See [query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md) and [describing a query once](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md). > **Note: In React Query** > > TanStack's `queryOptions()` helper and its `DataTag`-branded keys solve the > same problem — tying a key to its data type — at the type level only. Here the > options value carries the type, and the cache checks it at runtime. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). ## Cancellation and time - **Cancellation is `QueryCancelToken.onCancel`**, because Dart has no ecosystem-wide cancellation primitive. See [query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md). - **Time goes through `package:clock`**, so fake time in tests controls staleness and garbage collection completely. See [testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md). --- # Queries > What a query is, where it lives in an app, the three result states, status versus fetchStatus, and the flags that combine them. A screen that shows server data has to answer the same questions every time: is it loading, did it fail, is what I show still current, is a refresh running? Written by hand, that is a `Future` in a `State`, three flags and a `setState` per screen — and two screens showing the same data fetch it twice. A **query** answers those questions once. It is a declarative dependency on a piece of asynchronous data, tied to a **unique key**. You give it the key and a function that returns a `Future`; the cache decides when to call the function, keeps the result under the key, and tells every widget that reads it. ## Describing a query The smallest useful shape is a function that returns the options, kept next to the rest of the app's data layer: ```dart // lib/data/device_queries.dart QueryObserverOptions> devicesQuery({String? roomId}) => QueryObserverOptions( queryKey: DeviceKeys.list(roomId: roomId), queryFn: (context) => repository.devices(roomId: roomId, signal: context.signal), ); QueryObserverOptions deviceQuery(String id) => QueryObserverOptions( queryKey: DeviceKeys.detail(id), queryFn: (context) => repository.device(id, signal: context.signal), ); ``` - The **key** is how the cache stores, shares and invalidates the data. Two widgets that build the same key share one entry and one request. See [query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md). - The **function** resolves with the data or throws. Passing `context.signal` on lets the cache cancel a request nobody waits for any more. See [query functions](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-functions.md). - The **type** comes from the function: `devicesQuery()` is a `QueryObserverOptions>`, and so is everything read through it. A function per query, not a widget-local literal, is what lets the same description be read on one screen, prefetched on another and invalidated by a mutation — see [describing a query once](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md). ## Reading it on a screen Every call style hands you the same `QueryResult`. Here it is read with `context.query`, one of [four equal ways](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md): ```dart // lib/ui/devices_screen.dart class DevicesScreen extends StatelessWidget { const DevicesScreen({super.key}); @override Widget build(BuildContext context) { final devices = context.query(devicesQuery()); return Scaffold( appBar: AppBar( title: const Text('Devices'), // A background refetch: the list stays, and a thin bar says so. bottom: devices.isRefetching ? const PreferredSize( preferredSize: Size.fromHeight(2), child: LinearProgressIndicator(minHeight: 2), ) : null, ), body: switch (devices) { QueryPending() => const Center(child: CircularProgressIndicator()), // A refetch failed: say so above the data that is still good. QueryError(:final error, :final staleData?) => Column( children: [ Text('Could not refresh: $error'), Expanded(child: DeviceListView(staleData)), ], ), // The first load failed: there is nothing to show. QueryError(:final error) => Center(child: Text('Could not load devices: $error')), QuerySuccess(:final data) => RefreshIndicator( onRefresh: devices.refetch, child: DeviceListView(data), ), }, ); } } ``` Four things are happening without code of their own: - The first build starts the fetch; the second screen that reads `devicesQuery()` does not start another. - Pull-to-refresh awaits `refetch()`, which completes when the fetch does. - A failed *refetch* keeps the list: `staleData` is the last good value, and the banner sits above it. - Leaving the screen and coming back within five minutes renders the cached list at once and refetches behind it — the [important defaults](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md) at work. The showcase's *basic* screen is that last point, live. Tap a post, go back — its row now says `cached` — and open it again: the title is there at once, with a `refreshing` pill while the refetch runs. The screen sets a `gcTime` of ten seconds, so wait that long on the list and the entry is gone: Live demo: [Basic](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/basic), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/basic)). A list, a detail, and what the cache already knows. ## The three states A `QueryResult` is sealed, and is exactly one of: - **`QueryPending`** — there is no data yet. - **`QuerySuccess`** — `data` holds the data. - **`QueryError`** — the query failed. `error` and `stackTrace` say why, and `staleData` holds the last good data when there was any (`hasStaleData` tells a real `null` from none). `result.status` gives the same thing as a `QueryStatus` value, and `isPending`, `isSuccess` and `isError` are there for when a `switch` is more than you need. `result.dataOrNull` is the data of a success or the stale data of an error. ## What the query is doing: `fetchStatus` The status says whether there is data. It says nothing about whether a fetch is running — a query can hold data *and* be refetching it. That is the second axis, `fetchStatus`: - **`FetchStatus.fetching`** — the function is running (a first load, a retry, or a background refetch). - **`FetchStatus.paused`** — it wanted to fetch but may not go on yet. A fetch under the default network mode that finds the client offline waits before its first attempt. A fetch that failed and is due for a retry waits between attempts while the client is offline, and also while the app is in the background — a retry resumes when the app is back in the foreground. See [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md). - **`FetchStatus.idle`** — nothing is running. Any status combines with any fetch status. The flags on every result name the common combinations: | Flag | Means | |---|---| | `isFetching` | `fetchStatus` is `fetching` — anything is running | | `isPaused` | `fetchStatus` is `paused` | | `isLoading` | pending **and** fetching: the first load is in flight | | `isRefetching` | fetching but **not** pending: a background refetch of data already there | | `isLoadingError` (on `QueryError`) | failed with no data to show | | `isRefetchError` (on `QueryError`) | a refetch failed; `staleData` is still good | A `switch` can use them directly: ```dart Widget taskTitle(QueryResult task) => switch (task) { QueryPending(isPaused: true) => const Text('Waiting for the network…'), QueryPending() => const Text('Loading…'), QueryError(:final staleData?) => Text('${staleData.name} (not refreshed)'), QueryError(:final error) => Text('Could not load: $error'), QuerySuccess(:final data, isRefetching: true) => Text('${data.name} …'), QuerySuccess(:final data) => Text(data.name), }; ``` ## Other fields worth knowing - `dataUpdatedAt` and `errorUpdatedAt` — when the data or the error arrived. - `isStale` — whether the data is older than its `staleTime`, or was invalidated, and `true` while there is no data yet. A **disabled** query is never stale, and neither is a `StaleTime.static` query that has data — invalidation included. - `failureCount` and `failureReason` — how many attempts of the current fetch have failed, while it retries. See [retries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md). - `consecutiveErrorCount` — fetches in a row that ended in an error. See [polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md). - `isPlaceholderData` — the data is a placeholder, not in the cache. See [placeholder data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md). - `refetch()` — fetch again now. Its future completes with the new result. A fetch already running over data is cancelled and replaced; pass `cancelRefetch: false` to join it instead. A first load, with no data yet, is always joined. ## Traps - **Build the options, not the client, in `build`.** An options function is cheap to call on every build: the reader compares it by value and changes nothing when it is equal. A `QueryClient` built in `build` is a new, empty cache every time. - **`isLoading` is not "a spinner is needed".** It is false during a background refetch, when the data is on screen — which is what you want — and also false for a pending query that is *paused*. Match `QueryPending(isPaused: true)` if that deserves its own message. - **An error does not clear the data.** Match `QueryError(:final staleData?)` before `QueryError()`, or the screen blanks on the first failed refresh. > **Note: In React Query** > > This is `useQuery`. `status` and `fetchStatus` are the same two axes; the > sealed result replaces `data | undefined` with a field that exists only on the > states that have it, and `isLoadingError`/`isRefetchError` move onto > `QueryError`. See [differences from > TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Four ways to read a query > context.query, QueryBuilder, QueryMixin and QueryController — four equal call styles, how to pick between them, and when a read is released. `useQuery` has no single counterpart here. The binding offers **four equal ways** to read a query, and **the documentation names no default**. They are layered, not competing — each is a thin shell over the one below — and they interoperate inside one screen. The showcase's *four call styles* screen puts them all on one key. Under its first card, *One entry, five readers*, the strip reads `observers=5` and `fetches=1`: press *Refetch* and every reader moves together on one request. Further down, *6. QueryListener, a side effect* counts `listener-calls` as you press *Drop a post*: Live demo: [Four call styles](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/four-call-styles), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/four_call_styles)). The same query through context, builder, mixin and controller. Two rules hold for all of them: - **Read in `build`.** A read is reconciled against the previous build; that is how a key you stopped reading is released. - **One widget per list row or dialog.** A row or a dialog that shows a query reads it in its own widget's `build`, so its reads come and go with it. The details are under [how reads are released](#how-reads-are-released). ## `context.query(...)` ```dart class TaskScreen extends StatelessWidget { // … the id and its constructor … @override Widget build(BuildContext context) { final task = context.query(taskQuery(id)); return switch (task) { QueryPending() => const CircularProgressIndicator(), QuerySuccess(:final data) => TaskCard(data), QueryError(:final error, :final staleData) => ErrorBanner(error, staleData), }; } } ``` Flat, works in a `StatelessWidget`, and **rebuilds only the widgets that read that query** — a change to one query does not touch the rest of the screen. - `context.selectQuery` is the form with a `select`; it takes a `QuerySelectOptions` (see [describing a query once](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md)). - `context.query`, `context.selectQuery`, `context.infiniteQuery` and `context.mutation` take [`buildWhen`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md#buildwhen), exactly as the builders do. It is per read; a change any read lets through rebuilds the whole widget. - Each reading widget gets observers of its own. What is shared is the query in the cache, which the core deduplicates. - `context.query` always reads the provider's client and takes no `client:` — a `BuildContext` names exactly one provider. For a different client, use a builder (`client:`), a controller, or override `queryClient` on a `QueryMixin` `State`. ## `QueryBuilder` ```dart QueryBuilder( options: taskQuery(id), builder: (context, result) => switch (result) { /* … */ }, ) ``` The `StreamBuilder` shape: the read is a widget in the tree, and a change rebuilds that widget's subtree. Being a widget, it is also a row of its own when an `itemBuilder` returns one. Several queries on one screen means several nested builders. `QuerySelectBuilder` is the same widget for a query with a `select` — a `QuerySelectOptions`. Builders take [`buildWhen`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md#buildwhen). ## `QueryMixin` ```dart class _TaskScreenState extends State with QueryMixin { @override Widget build(BuildContext context) { final task = watchQuery(taskQuery(widget.id)); final rename = watchMutation(renameTask(widget.id)); // … } } ``` Flat like `context.query`, but owned by the `State`. Entries are identified by their `QueryKey` and types, not by call order, so **`watchQuery` inside an `if` is fine** — there is no equivalent of the rules of hooks. A key read in the previous build but not in this one is released after the frame. Two reads of one key with different selectors of the same output type, or two mutations of the same shape, are told apart by an `id:` argument — and reading two of them *without* one is caught by an assertion in debug builds. `watchQuery`, `watchSelectQuery`, `watchInfiniteQuery` and `watchMutation` also take [`buildWhen`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md#buildwhen). It is per read; a change any of them lets through rebuilds the whole `State`, because a `State` is one reader. An `id:` is then the read's identity, which matters when the key changes: ```dart // With an id, the observer follows the key — so keepPrevious has a previous. final page = watchQuery(pageQuery(widget.page), id: 'page'); ``` A read that carries an `id:` keeps its observer when its key changes, so `PlaceholderData.keepPrevious()` shows the previous key's data while the next one loads. Without an `id:`, a new key is a new observer and the placeholder has nothing previous to show. See [paginated queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/paginated-queries.md). ## `QueryController` ```dart final task = QueryController.create(client, taskQuery(id)); // … task.value, task.addListener, task.refetch() … task.dispose(); ``` A `ValueListenable>`. Nothing hidden, testable without widgets, and the foundation the other three stand on. `QueryController(client, options)` is the constructor form for a `select` whose output type differs from the cache's. Its `value` before the first listener is the **optimistic** result — `fetching` for a query that will fetch on subscribe — the same thing a widget sees on its first build. It notifies only when something a reader can see has moved since the last time it said anything, so a `ValueListenableBuilder` over one does not rebuild for the fetch its own subscription started. A controller takes no `buildWhen`: it **is** the notifier, and a predicate on it would impose one listener's filter on every listener. Filter in your listener instead. Because it is a plain listenable, it drops into `ValueListenableBuilder`, `ListenableBuilder`, `Listenable.merge`, `provider`, `riverpod` and `bloc` unchanged. See [does this replace state management?](https://dualmeta-gmbh.github.io/query_kit/docs/guides/does-this-replace-state-management.md) and [signals and other reactive packages](#signals-hooks-and-other-reactive-packages). ## Picking one There is no right answer, which is why there is no default. What the examples converged on: | Situation | What fits | |---|---| | A leaf that renders one query | `context.query` — flat, and only that widget rebuilds | | Inside a `ListView.builder` or a sliver | a widget per row that reads its own query — a `QueryBuilder`, or a small row widget reading with `context.query` or `watchQuery` in its own `build` — so each row's reads go with it. Not a read through the `itemBuilder`'s own `context` (see [lazily built lists](#how-reads-are-released)) | | A `State` that is already stateful (a form, a controller) | `QueryMixin` — the query and its mutations go flat at the top of `build` | | Two siblings that need the same result | `QueryController` held by the parent | | You want `buildWhen` | a builder, `watchQuery` or `context.query`; a controller filters in its listener instead | | No widgets at all | `QueryController`, or the core's `QueryObserver` | ## How reads are released `context.query` and the mixin keep a subscription alive for as long as a build reads it. Most of the time that needs no thought; these are the cases where it does. - **A widget that stops calling `context.query` altogether** gives no signal Flutter can see, so its last observers stay until it unmounts. Put a conditional read in its own small widget. - **Nested builder callbacks.** A read through the outer `context` inside a `ValueListenableBuilder`, an `AnimatedBuilder` or a `LayoutBuilder` is *added* to the enclosing widget's reads and does not release what its own `build` read. A key such a callback stops reading is released on that widget's next own build that reads, or when it goes — so read in `build` itself: a `build` that leaves every read to a nested builder never starts over. - **A `LayoutBuilder`'s or `OrientationBuilder`'s own `context`.** A read through it starts over when its builder provably runs: new constraints (the wide layout's key goes after a resize), a notification from one of its own reads, or a new widget from its parent. One rebuild carries no signal — an `InheritedWidget` the builder depends on changing — and a key picked from an inherited value stays subscribed until the next of those. **A key that depends on anything the builder reads belongs in a widget of its own below the `LayoutBuilder`**: `LayoutBuilder(builder: (_, c) => c.maxWidth > 600 ? const WideTasks() : const NarrowTasks())`, each reading in its own `build`. - **Dialogs and bottom sheets.** A read rebuilds the element whose `context` it went through. A dialog reading through the page's `context` is not rebuilt by a change, and its key goes at the page's next build. Give a dialog a reader of its own: read through the `context` the dialog builder is given. - **Lazily built lists.** A `ListView.builder`, `GridView.builder`, `PageView.builder` or any other lazily built list hands its item builder the whole list's context, not the row's, and builds rows piecemeal as they scroll in, so no rule for it both keeps the rows on screen subscribed and lets go of the ones scrolled away. A `context.query` (or any other `context.` read) through that item-builder `context` is refused: a debug build throws a `FlutterError` naming the fix. (In a release build such a read is additive: no row on screen loses its subscription, and the rows scrolled away stay subscribed until the list is rebuilt or goes.) A `watchQuery` inside the `itemBuilder` does not throw — it reads for the `State` around the list, additively, as a nested builder does — so the rows scrolled away stay subscribed until that `State`'s next own `build` that reads, or its disposal. The fix for both is the same: give each row a widget of its own — `itemBuilder: (_, i) => TaskTile(ids[i])` — and read in `TaskTile.build`. ### A widget per row The fix for a lazily built list, in full. The list builds one widget per id, and each row reads its own query in its own `build`: ```dart ListView.builder( itemCount: ids.length, // Each row is a widget of its own, so each row's read is its own. itemBuilder: (context, index) => DeviceRow(ids[index]), ) ``` ```dart class DeviceRow extends StatelessWidget { const DeviceRow(this.id, {super.key}); final String id; @override Widget build(BuildContext context) { final device = context.query(deviceQuery(id)); return ListTile(title: deviceTitle(device)); } } ``` A row scrolled away unmounts and releases its read; one scrolled back in reads again, from the cache if the entry is still there. Fifty rows are fifty observers of fifty entries — and each entry, fetched once, is what a detail screen for that device opens on at once. [Troubleshooting](https://dualmeta-gmbh.github.io/query_kit/docs/reference/troubleshooting.md) has each of these as a symptom, with the fix. ## Signals, hooks and other reactive packages Not dependencies here, and not planned as such. Because a controller is a `ValueListenable`, a signals package reads it with whatever it offers for listenables — `signals_flutter` has `valueListenableToSignal`, for one: ```dart final task = QueryController.create(client, taskQuery(id)); final signal = valueListenableToSignal(task); // signals_flutter final done = computed(() => signal.value.dataOrNull?.done ?? false); ``` Nothing is needed from this package for that. > **Note: In React Query** > > `useQuery` is one hook; here it is four shapes of the same observer, because > Flutter has no hooks in the framework. `context.query` and `watchQuery` > read flat in `build`, the way a hook call does. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Query keys > QueryKey as a value type, hierarchical keys and prefix matching, key factories, and the parts that do not compare the way you expect. A cache needs a name for each thing it holds. Name two different requests the same and one screen shows the other's data; name one request two ways and it is fetched twice and invalidated half the time. Query keys are those names, and most of what the cache does — sharing, refetching, invalidating — it does by key. A `QueryKey` is a list of parts, from the most general to the most specific: ```dart final key = QueryKey(['devices', 'detail', 'd1']); ``` ## A value, not a string A `QueryKey` is a **value type**: deep-frozen, and compared part by part. Two keys built from equal parts *are* the same key, wherever they were built: - strings, numbers, booleans and `null` compare with `==`; - lists, maps and sets inside a key are compared deeply — `{'page': 1, 'done': false}` and `{'done': false, 'page': 1}` are the same part; - a `DateTime` part compares by instant, so UTC and local of one moment are one key; - anything else compares with its own `==`, so a class used as a key part needs value equality. There is no hashing function to configure. `key.debugString` is the readable form, for logs. ## Everything the function depends on goes in the key If the query function uses a variable, the key must contain it. A detail query is keyed by its id; a filtered list by its filter: - `['devices', 'list', {'room': null}]` — every device - `['devices', 'list', {'room': 'kitchen'}]` — the kitchen's devices - `['devices', 'detail', id]` — one device Otherwise two different requests share one cache entry, and one of them shows the other's data. ## Hierarchy and prefixes Keys are hierarchical. The bulk operations — [invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md), `refetchQueries`, `removeQueries`, `cancelQueries`, `resetQueries` — match a key as a **prefix** unless you pass `exact: true`, so invalidating `['devices']` reaches every list and every detail under it. See [filters](https://dualmeta-gmbh.github.io/query_kit/docs/guides/filters.md). A **map** part matches partially: a filter's map matches any key map that holds the same entries, and more. So `['devices', 'list', {'room': 'kitchen'}]` in a filter reaches `['devices', 'list', {'room': 'kitchen', 'on': true}]` too. That is why filters go in a map rather than in positional parts — a new filter field does not move the others. ## Key factories Build keys in one place, from the most general part down, so a prefix is always a real parent. In an app that is one file per data area, next to the options functions that use it: ```dart // lib/data/device_keys.dart abstract final class DeviceKeys { static final QueryKey all = QueryKey(['devices']); static final QueryKey lists = all.append(['list']); static QueryKey list({String? roomId}) => lists.append([ {'room': roomId}, ]); static QueryKey detail(String id) => all.append(['detail', id]); static QueryKey firmware(String id) => detail(id).append(['firmware']); } ``` `key.append(parts)` returns a new key with the parts added at the end. The firmware key sits *under* the device's detail key on purpose: invalidating one device reaches its firmware as well. Each prefix then is an invalidation target of its own: ```dart // One device: its detail, and its firmware, which sits under it. await client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.detail(id)), ); // Every device list, whatever room it is filtered by. await client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.lists), ); // Only the unfiltered list: the key exactly, nothing under it. await client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.list(), exact: true), ); // Everything about devices. await client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.all), ); ``` The showcase's *invalidation and filters* screen holds a posts list, post details on screen, and a post 3 that nobody observes. Press *Invalidate posts prefix* and everything on screen under `[posts]` refetches while post 3 only turns `isStale=true`; *Invalidate posts exactly* reaches the list alone, and *Invalidate inactive too* refetches post 3 as well: Live demo: [Invalidation and filters](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/invalidation-and-filters), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/invalidation_and_filters)). Invalidate, refetch, reset and remove, by prefix, type or predicate. ## One key, one data type A key is bound to the type it was first used with; reading it as another type throws `QueryDataTypeError`. Give data of a different shape a different key. See [type safety in Dart](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md#one-key-one-exact-type). ## A record of a list is not a stable key part A **record** compares its fields with their own `==`, and a `List`'s `==` is identity. So a key part like `(ids: [1, 2],)` is new every time it is built, never matches the key built from the same values again, and every read fetches anew. Lists and maps as key *parts* are compared deeply; inside a record they are not. Put the list in the key directly, or use a value class with deep `==` and `hashCode`. ## Traps - **A key that forgets a variable.** A key of `['devices']` for a function that filters by room makes every room share one entry. The rule has no exceptions: if `queryFn` reads it, the key holds it. - **A key built from mutable state.** A key part is frozen when the key is built; mutate the list you passed in afterwards and the key does not change. Build the key from the current values each time. - **Keys spelled in two places.** `['device', id]` in one file and `['devices', id]` in another are two entries and two requests. A factory makes that impossible. > **Note: In React Query** > > Query keys are arrays hashed with `hashKey`. Here a `QueryKey` is a value > type compared part by part, with no hashing function to configure, and a > `queryKeyHashFn` does not exist. Prefix and partial map matching behave as > `partialMatchKey` does. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Query functions > A query function returns a Future and throws on failure — a repository on dio or package:http, error types a retry policy can read, cancellation, and what the function context carries. The cache does not know about HTTP. It knows one thing about a fetch: whether the `Future` it was handed completed with a value or with an error. Everything else — retries, `QueryError`, `failureCount`, the error callbacks — starts there, and so does the most common bug in a new integration: a server error that never became a Dart error, cached as if it were the data. A query function is any function that returns a `Future` of the data. It receives a `QueryFunctionContext`, and it has one job besides fetching: **throw when it fails.** ```dart QueryObserverOptions deviceQuery(String id) => QueryObserverOptions( queryKey: DeviceKeys.detail(id), queryFn: (context) => repository.device(id, signal: context.signal), ); … ``` ## Where the function lives A `queryFn` is usually one line that calls a repository — the class in `lib/data/` that knows the backend's paths, parses its JSON and turns its refusals into exceptions. The query layer stays free of HTTP, and the repository stays free of caching. The repository's error type is worth its few lines. Whatever you throw is the `error` on the result, what a `QueryListener` shows and what your `retry` policy is handed, so throw something your UI and your policy can tell apart: ```dart /// What the repository throws when the backend refuses. class ApiException implements Exception { const ApiException(this.message, {this.statusCode}); final String message; /// The HTTP status, or null when no response arrived at all. final int? statusCode; /// A 4xx: asking again will not change the answer. bool get isClientError => statusCode != null && statusCode! >= 400 && statusCode! < 500; @override String toString() => message; } ``` ## Throwing is how a query fails Here the two common HTTP clients differ, and the difference matters: - **dio** throws a `DioException` for a non-2xx response by default. A function built on it fails correctly as written; the repository's job is to turn the exception into its own type. - **`package:http` does not throw** on a 4xx or 5xx response: it returns the response. A repository built on it has to check `statusCode` and throw itself, or a 404 page is cached as a success. The same repository method on each, with the cache's cancellation handed on to the transport: **dio** ```dart // lib/data/device_repository.dart class DeviceRepository { DeviceRepository(this._dio); final Dio _dio; Future device(String id, {QueryCancelToken? signal}) async { final json = await _get('/devices/$id', signal); return Device.fromJson(json! as Map); } Future _get(String path, QueryCancelToken? signal) async { // The cache's cancellation, handed on to dio. final cancelToken = CancelToken(); signal?.onCancel(cancelToken.cancel); try { final response = await _dio.get(path, cancelToken: cancelToken); return response.data; } on DioException catch (error) { // Cancelled because nobody wants the answer: not a failure to report. if (CancelToken.isCancel(error)) rethrow; // dio throws for any non-2xx status; make it an error the app knows. throw ApiException( error.message ?? 'Request failed', statusCode: error.response?.statusCode, ); } } } ``` **package:http** ```dart // lib/data/device_repository.dart class DeviceRepository { DeviceRepository(this._client, this._baseUrl); final http.Client _client; final Uri _baseUrl; Future device(String id, {QueryCancelToken? signal}) async { final json = await _get('devices/$id', signal); return Device.fromJson(json! as Map); } Future _get(String path, QueryCancelToken? signal) async { // package:http aborts a request when this future completes. final abort = Completer(); signal?.onCancel(abort.complete); final request = http.AbortableRequest( 'GET', _baseUrl.resolve(path), abortTrigger: abort.future, ); final response = await http.Response.fromStream( await _client.send(request), ); // A 404 or a 500 is a response here, not an exception: check it, or the // error page is cached as the data. if (response.statusCode < 200 || response.statusCode >= 300) { throw ApiException( 'Request failed with ${response.statusCode}', statusCode: response.statusCode, ); } return jsonDecode(response.body); } } ``` `AbortableRequest` needs `package:http` 1.5 or later. On an older version, leave the signal unread: the request then runs to its end and the cache drops the answer it no longer wants. Both use `import 'package:query_kit/query_kit.dart'` for `QueryCancelToken`, so the data layer needs no Flutter. With an error type in hand, a retry policy can stop wasting attempts on answers that will not change: ```dart // A 404 or a 403 will not change on the third try; a timeout might. retry: RetryPolicy.when( (failureCount, error, _) => failureCount < 3 && !(error is ApiException && error.isClientError), ), ``` See [query retries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md) for the rest of the policy. ## The context `QueryFunctionContext` carries: - **`signal`** — a `QueryCancelToken` that is cancelled when the fetch is no longer wanted: the query was cancelled, or its last reader left while it ran. Reading `signal` is what marks the fetch as cancellable, so read it only if you hand it on. `onCancel(callback)` runs the callback on cancel — at once, if that has already happened — and `isCancelled`, `throwIfCancelled()` and `whenCancelled` cover a function that does its own work in steps. See [query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md). - **`queryKey`** — the key being fetched, so one function can serve a whole family of keys. - **`meta`** — the query's `meta` option, for information that is not part of the key (a logging tag, a toast policy — see [global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md)). - **`client`** — the `QueryClient` running the fetch. One function for a family of keys reads what it needs back from the key: ```dart // One function for every detail key: the id is read back from the key. Future fetchDevice(QueryFunctionContext context) { final id = context.queryKey.parts[2]! as String; return repository.device(id, signal: context.signal); } ``` The key's parts are `Object?`, so reading one back is a cast. That is the price of one shared function; a function per query that closes over its `id` has none. See also [default query function](https://dualmeta-gmbh.github.io/query_kit/docs/guides/default-query-function.md). An infinite query's function is `pageFn`, handed an `InfinitePageContext` that also carries the `pageParam` and the `direction`; see [infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md). The showcase's *cancellation* screen shows what handing the signal on buys. Press *Start slow fetch*, then *Cancel*: the query goes back to where it was and `cancels` counts one `onCancel`. Switch *Ignore the signal* on and do it again — the query is still cancelled, but `cancels` stays put: the request ran to its end at the backend and only its answer was thrown away: Live demo: [Cancellation](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/cancellation), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/cancellation)). A query cancelled is a request aborted. ## Traps - **Swallowing the error.** A `try`/`catch` that logs and returns an empty list turns every failure into a success — no retry, no error state, and an empty list cached as the truth. Catch to *translate*, then throw. - **Catching the cancellation.** When the cache cancels, the transport throws (dio a cancelled `DioException`, `package:http` a `RequestAbortedException`). Rethrow it or let it pass; the cache has already settled the query's state and drops what the function does next. - **Reading widget state.** The function runs when the cache decides — on mount, on focus, on reconnect, on an invalidation — possibly after the widget that first asked is gone. It reads everything from the key and from stable objects it closes over (a repository), never from a `BuildContext`. - **A parse error is a failure too.** A `TypeError` from a missing JSON field fails the query like any other error — and is retried three times by default. Parse inside the repository and throw your own type if the message should reach a user. > **Note: In React Query** > > The same contract as `queryFn` in TanStack Query: resolve with data or throw. > `signal` is a `QueryCancelToken` rather than an `AbortSignal`, because Dart > has no cancellation primitive every HTTP client shares; `onCancel` is the > bridge. `fetch`'s "does not reject on 4xx" trap is `package:http`'s here. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Describing a query once > Options functions, the two observer options shapes, withSelect, what null means, and why options built in build are fine. A query read in three places is easy to describe three ways: one screen forgets the `staleTime`, another spells the key slightly differently, a prefetch uses a function the screen has since stopped using. Each difference is a second cache entry or a refetch nobody asked for. So a query is described by an options object — its key, its function and whatever it configures — and that object lives behind a function. Every widget that reads the query, every prefetch and every test calls the same function and gets the same description. ## Two rules **`null` means "not configured"** on every option field, so merging defaults is a plain `??` per field: an option you leave unset is decided by the next level down — the key's defaults, then the client's, then the built-in value. See [important defaults](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md). **An option with modes is a sealed value type**, never a magic number, string or boolean — `StaleTime`, `GcTime`, `Enabled`, `RetryPolicy`, `RetryDelay`, `RefetchOn`, `RefetchInterval`; `NetworkMode`, a closed set of three, is an enum. Each has its own page: [caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md), [dependent queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/dependent-queries.md), [retries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md), [app focus](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md), [polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md), [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md). The computed forms come in three families, named by what they compute: `.when(fn)` decides per query or per failure — yes or no for `Enabled` and `RetryPolicy`, a `RefetchOn` value for `RefetchOn` — `.dynamic(fn)` computes the value itself from the query or the attempt (`StaleTime`, `RefetchInterval`, `RetryDelay`), and `.compute(fn)` produces data (`InitialData`, `PlaceholderData`). ## Two shapes Observer options — what every reading style takes — come in two shapes over one sealed base: | | type arguments | `select` | |---|---|---| | `QueryObserverOptions` | one: the query's data | none — the observer reports the query's data | | `QuerySelectOptions` | two: the cache's data and the selection | **required** — it is what anchors `TData` | When supplied, `queryFn` anchors the raw data type; the required `select` anchors the selected type. `queryFn` is optional for cached or defaulted queries: without it or an expected type, supply an explicit type argument such as `QueryObserverOptions` to avoid inferring `dynamic` (see [type safety in Dart](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md)). A `select` that keeps the type is still a select and still goes on `QuerySelectOptions`. ```dart QueryObserverOptions taskQuery(String id) => QueryObserverOptions( queryKey: taskKey(id), queryFn: (context) => api.getTask(id, signal: context.signal), ); QuerySelectOptions taskNameQuery(String id) => QuerySelectOptions( queryKey: taskKey(id), queryFn: (context) => api.getTask(id, signal: context.signal), select: (task) => task.name, ); ``` The plain entry points (`QueryBuilder`, `context.query`, `watchQuery`, `QueryController.create`) take the first; the select ones (`QuerySelectBuilder`, `context.selectQuery`, `watchSelectQuery`) the second; the general `QueryController(client, options)` takes either. Infinite queries mirror this — see [infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md). `QueryOptions` — no observer, no `select` — is what the imperative `client.query` takes; see [prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md). ## `withSelect` A factory that builds the plain shape serves a projecting reader too. `withSelect` turns it into a `QuerySelectOptions` with every other field kept — no copying fields by hand, and no field forgotten when one is added: ```dart // The name only: this label rebuilds when the name changes, not when the // device is switched on or off. final name = context.selectQuery( deviceQuery(id).withSelect((device) => device.name), ); ``` Both readers share one cache entry and one request; only what each is told about differs. `InfiniteQueryObserverOptions.withSelect` does the same for the paged shape. What a `select` does to rebuilds is in [what rebuilds, and when](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md). The showcase's *select and sharing* screen puts five readers on one list of todos — four with a `select`, one without — each with a `data builds` count. Press *Refetch*: the server sends the same list, and no `data builds` count moves. *Toggle todo 1* moves only the reader that selects done and open counts, and the one without a `select`. Switch *Structural sharing off* and press *Refetch* again: now the reader without a `select` gets a new list every time: Live demo: [Select and structural sharing](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/select-and-sharing), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/select_and_sharing)). What a reader rebuilds on, and what it does not. ## One description, many uses The same function that a screen reads is what the client takes for a prefetch — `QueryObserverOptions` is a `QueryOptions`, so no second description is needed: ```dart // The same options, handed to the client: warm the detail before the // detail screen opens. A second reader within staleTime fetches nothing. Future prefetchDevice(QueryClient client, String id) => client.query(deviceQuery(id)); ``` And its key is what a mutation invalidates, so the three can never disagree about which entry they mean. See [prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md) and [invalidations from mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md). ## Options built in `build` are fine A computed option — `Enabled.when`, `StaleTime.dynamic`, a `select` — is equal to another when its function is. Two tear-offs of one top-level, static or instance method compare equal, and a `const` value is one value; a closure written inline in `build` is a new function on every build, so options carrying one never compare as unchanged. **That costs one defaulting pass and nothing else.** The observer resolves the defaults and compares the **defaulted** options by value; only a real difference emits an options-updated event or triggers a fetch, and timers are compared by their resolved values before one is touched, so an inline `RefetchInterval.dynamic` does not reset a poll on every frame. An options literal in `build` is not a leak and not a restart; hoisting it to a `static final` is a small optimisation. Keep the functions stable when you want the options themselves to read as unchanged — `select` and `queryFn` included. ## When options are read Retry policy, retry delay and network mode are captured when a fetch or mutation run starts; replacing them during that run affects the next one. A dynamic callback still reads whatever it closes over each time it is called. An imperative `client.query` hands its options to the cache entry, as an observer does: an explicit `retry` in them stays with the query for later refetches. See [prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md). ## Traps - **A literal per widget.** Two widgets that build their own options for one key share an entry, but the longest `gcTime` any of them asked for wins, and each refetches by its own `staleTime`. One function per query ends that. - **The same key with two data types.** A projection of a key's data is a `select`, not a second options function that fetches into the same key with another type — that one throws `QueryDataTypeError`. - **Expecting an inline `select` to be skipped.** A new closure is a new function, so it runs again on the next result. That is correct and cheap for a projection; hoist it to a top-level or static function only when it is expensive. > **Note: In React Query** > > This is the `queryOptions()` helper, made the only way: there is no > positional `useQuery(key, fn)` form. `select` moves into its own options > shape so its second type argument can be inferred, and `withSelect` adds one > to an existing description. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Parallel queries > Several queries at once — separate reads that run side by side in every call style, QueriesBuilder for a list that changes length or order, and one loading indicator over all of them. A home screen rarely needs one thing. The dashboard of a smart-home app wants the rooms and the devices; the energy panel wants one reading per device on screen, however many that is. Fetched one after the other, each request waits for the one before it — a waterfall, and a slower screen for no reason. Queries that do not depend on each other run in parallel, and there is nothing to set up: each read starts its fetch when it subscribes. ## A fixed number of queries Read each one. Both reads subscribe in the same build, so both requests are in flight at once. The same dashboard in each of the [four call styles](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md): **context.query** ```dart class HomeDashboard extends StatelessWidget { const HomeDashboard({super.key}); @override Widget build(BuildContext context) { // Both reads subscribe in this build, so both requests start now. final rooms = context.query(roomsQuery()); final devices = context.query(devicesQuery()); return DashboardView(rooms: rooms, devices: devices); } } ``` **QueryBuilder** ```dart Widget homeDashboard() => QueryBuilder>( options: roomsQuery(), builder: (context, rooms) => QueryBuilder>( options: devicesQuery(), builder: (context, devices) => DashboardView(rooms: rooms, devices: devices), ), ); ``` Nesting does not serialise the requests: the outer builder's child — the inner builder — is built in the same frame, before either answer arrives. **QueryMixin** ```dart class _HomeDashboardMixinState extends State with QueryMixin { @override Widget build(BuildContext context) { final rooms = watchQuery(roomsQuery()); final devices = watchQuery(devicesQuery()); return DashboardView(rooms: rooms, devices: devices); } } ``` **QueryController** ```dart class _HomeDashboardControllersState extends State { late final QueryController, List> _rooms; late final QueryController, List> _devices; @override void initState() { super.initState(); final client = QueryClientProvider.read(context); _rooms = QueryController.create(client, roomsQuery()); _devices = QueryController.create(client, devicesQuery()); } @override void dispose() { _rooms.dispose(); _devices.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ListenableBuilder( listenable: Listenable.merge([_rooms, _devices]), builder: (context, _) => DashboardView(rooms: _rooms.value, devices: _devices.value), ); } ``` Each result settles on its own: the rooms can be on screen while the devices still load, and one failing does not touch the other. When the screen wants the two as one value — loading while either loads, an error if either failed — [combine them](https://dualmeta-gmbh.github.io/query_kit/docs/guides/combining-queries.md). The showcase's *parallel queries* screen reads three posts side by side. Opening it shows `fetching=3` before any answer arrives. Switch *Slow post 3* on and press *Refetch all*: the other two settle while post 3 still fetches, and the count drops to `fetching=1`. *Refetch post 2* moves only that post's `fetches`: Live demo: [Parallel queries](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/parallel-queries), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/parallel_queries)). Several queries in one widget, and the global fetching count. ## A list of queries When the set of queries is data — one reading per device on screen — you cannot write one read per query in source. `QueriesBuilder` observes a list that may change length or order: ```dart QueryObserverOptions energyQuery(String deviceId) => QueryObserverOptions( queryKey: QueryKey(['energy', deviceId]), queryFn: (context) => repository.energy(deviceId, signal: context.signal), ); // One reading per device on screen — however many that is. Widget energyPanel(List deviceIds) => QueriesBuilder( queries: >[ for (final id in deviceIds) energyQuery(id).withSelect((reading) => reading.watts), ], builder: (context, results) => switch (results.combine( (watts) => watts.fold(0, (sum, each) => sum + each), )) { CombinedPending() => const Text('Measuring…'), CombinedError(:final error) => Text('No reading: $error'), CombinedData(:final data) => Text('${data.toStringAsFixed(1)} W now'), }, ); ``` - **One description per query.** `energyQuery(id)` is an ordinary options function; `withSelect` narrows each reading to its watts, so a refetch that brings back the same reading rebuilds nothing. - **Observers are reused by key and occurrence**, so reordering the list starts no requests, and adding an id fetches only the new one. - **Duplicate keys** share one cache entry while keeping their own observers. - **Each query fails and settles on its own**; `combine` then turns the list into one value, pending until every member has data. See [combining queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/combining-queries.md). It is homogeneous: one data type per collection, because a Dart `List` has one element type. For queries of **different** types, read each one as above and combine the results. The other call styles have the same collection: `QueriesController(client, queries)` is it as a `ValueListenable`, and `QueriesObserver` is it without Flutter. The showcase's *query collections* screen is a list of posts built from a list of ids. *Reverse* reorders them without a single request; *Duplicate first* adds a second reader of the same entry (`observers=2`, still one fetch); *Add missing id* adds a post the server does not have, which fails alone while its neighbours keep their data: Live demo: [Query collections](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/query-collections), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/query_collections)). A list of queries that grows, shrinks and reorders at runtime. ## One indicator for many queries Parallel queries each report their own `isFetching`. For a single "syncing" bar over all of them — whichever screen started them — count them instead: ```dart // A thin bar under the app bar while any device query is fetching — however // many there are, and whichever screen started them. class _DevicesSyncIndicatorState extends State { late final IsFetchingController _fetching = IsFetchingController( QueryClientProvider.read(context), filters: QueryFilters(queryKey: DeviceKeys.all), ); @override void dispose() { _fetching.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ValueListenableBuilder( valueListenable: _fetching, builder: (context, count, _) => count > 0 ? const LinearProgressIndicator(minHeight: 2) : const SizedBox(height: 2), ); } ``` `IsFetchingController` is a `ValueListenable` that notifies only when the count changes, and the filters narrow it — here to every key under `['devices']`. `client.isFetching(filters: …)` is the same count as a one-off snapshot. ## Traps - **A waterfall by accident.** Reading one query only in the success branch of another serialises them. That is right when the second needs the first's data — see [dependent queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/dependent-queries.md) — and a needless wait when it does not. - **A hand-written list of reads for a data-driven set.** When which queries run comes from data, give each item its own widget — see [a widget per row](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md#a-widget-per-row) — or use `QueriesBuilder`, so the set of observers follows the list as it changes. > **Note: In React Query** > > Two `useQuery` calls side by side are two reads here too. `useQueries` is > `QueriesBuilder` / `QueriesController`, homogeneous by design — a > heterogeneous tuple is a record of separate reads, combined with > `(a, b).combine(…)`. `useIsFetching` is `IsFetchingController`. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Combining queries > combine over a record or a list of results — the pending, error and data rules, optional sources, combineWith, CombineMemo and keys. Two reads side by side give you two results, and a screen that needs both has to decide what they amount to: a spinner while either loads, an error if either failed, and — the part hand-written code gets wrong — what to show when one of them fails *after* both were on screen. Written as nested `switch`es, that is nine cases per pair, most of them the same. A record has a type per position, so `combine` is a function over a **record of results** — from `context.query`, a builder, the mixin or a controller's `value`, it does not matter which. Nothing new observes anything: the reads you already have rebuild the widget, and `combine` says what they amount to together. ```dart class TaskWithComments extends StatelessWidget { const TaskWithComments(this.id, {super.key}); final String id; @override Widget build(BuildContext context) { final combined = ( context.query(taskQuery(id)), context.query(commentsQuery(id)), ).combine((task, comments) => '${task.name} (${comments.length})'); return switch (combined) { CombinedPending() => const CircularProgressIndicator(), CombinedError(:final error) => TextButton( onPressed: combined.retry, child: Text('$error — retry'), ), CombinedData(:final data, :final refetchError) => Text( refetchError == null ? data : '$data (could not refresh)', ), }; } } ``` In an app, the combiner is where two lists become the one the screen shows — here, the rooms, each with the devices in it: ```dart // lib/ui/rooms_overview.dart class RoomsOverview extends StatelessWidget { const RoomsOverview({super.key}); @override Widget build(BuildContext context) { final rooms = ( context.query(roomsQuery()), context.query(devicesQuery()), ).combine( (rooms, devices) => <({Room room, List devices})>[ for (final room in rooms) ( room: room, devices: [ for (final device in devices) if (device.roomId == room.id) device, ], ), ], ); return switch (rooms) { CombinedPending() => const Center(child: CircularProgressIndicator()), CombinedError(:final error) => Center( child: TextButton( onPressed: rooms.retry, child: Text('$error — try again'), ), ), CombinedData(:final data) => ListView( children: [ for (final entry in data) ListTile( title: Text(entry.room.name), trailing: Text('${entry.devices.length}'), ), ], ), }; } } ``` The two queries stay separate in the cache — the devices list is the same entry the devices screen reads, and invalidating it after a rename updates both screens — and only the view joins them. The rules, in order: 1. A source that **failed with nothing to show** makes the whole a `CombinedError` — it wins over a source that is still loading, because waiting does not cure it and `retry()` (which refetches only the failed sources) is something a user can press. 2. Otherwise a source with no data yet makes it `CombinedPending`. 3. Otherwise everything has data and the combiner runs. A background refetch that failed keeps its stale data in the combination and shows up as `refetchError`: content on screen is not blanked. `isFetching` is "any source is", and `refetch()` refetches all of them. Two to six results; past six, put the sources in a list typed by what they have in common — `>[a, b, …]` — combine that, and cast in the combiner. A `CombinedResult` is deliberately not a source, so two combinations do not nest. Controllers combine the same way under a `ListenableBuilder` over `Listenable.merge([a, b])`. `refetch()` and `retry()` call each source's own `refetch()`, with the same `cancelRefetch` (default `true`). So they cancel a fetch in flight and start again **only for a source that already has data**; a source still on its first load, with nothing cached, joins the fetch that is running instead of restarting it. Two combinations that share a source therefore each refetch it once it has data. To refresh several combinations at once without fetching a shared source twice, pass `refetch(cancelRefetch: false)` — the second call then joins the fetch the first one started. The combiner runs on every call — every build. For a constructor call that is nothing; for a join over long lists, keep a `CombineMemo` next to the reads (a `State` field) and pass it as `memo:`. The combiner is then skipped while every source holds the identical data instance — which structural sharing makes the normal case for a refetch that changed nothing — and an equal result keeps its instance, as TanStack Query shares the output of `combine`. A source the screen can do without is `optional()`: it never blocks and never fails the combination — its value is `null` until there is one — while `isFetching` still sees it and `retry()` still refetches it when the query behind it failed. And a **list** of results of one type, a `QueriesController`'s value, combines by the same rules: ```dart CombinedResult taskWithOptionalComments( QueryResult task, QueryResult> comments, ) => // Still loading, disabled or failed, `comments` is null here — and the // task is still the task. (task, comments.optional()).combine( (task, comments) => '${task.name} (${comments?.length ?? '–'})', ); CombinedResult doneCount(List> tasks) => // A QueriesController's value: one failure with nothing to show wins, // otherwise pending, otherwise every value in order. tasks.combine((tasks) => tasks.where((task) => task.done).length); ``` A list **and** a source of another type — typically the query the list of queries was derived from — is `combineWith`. It is one combination, not two nested ones: the rules read the same, and the deriving query's failure is an error rather than an empty list. More than one extra source goes the same way as more than six: one list typed by what the sources have in common, cast in the combiner. ```dart CombinedResult> allComments( QueryResult> feed, List>> perPost, ) => // One combination, `feed` first: if the query the list was derived from // failed, this is an error — not an empty list. perPost.combineWith( feed, (perPost, feed) => [for (final comments in perPost) ...comments], ); ``` **With a memo, the combiner must be a function of the sources and nothing else.** A memo cannot see what a closure captures: a combiner that filters by a search text it closes over keeps returning the list for the *old* text until a source changes. Do that work on the combined data, after `combine` — or name what the combiner reads with `keys: [search]`, which is compared with `==` and re-runs the combiner when it differs. ## See it running The showcase's *combine* screen joins a post, its comments and a counter. Set *The post read* to `is refused` and press *Refetch all*: the post's refetch fails, yet `state=data` holds, with `refetchError=` saying why. Press *Reset* instead and the post has nothing to show: `state=error`, and a *Retry* button. With the knob back on `answers` and everything loaded, *Refetch all* raises `builds` but not `combines` — nothing changed, so the memo skipped the combiner: Live demo: [Combine](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/combine), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/combine)). Three queries of three types, read as one result. ## Traps - **Combining in a place that does not rebuild.** `combine` observes nothing; it reads the results it is handed. Call it where those results are read — in the `build` that read them, or under a `ListenableBuilder` over the controllers. - **Blanking on a failed refresh.** Match `CombinedData(:final refetchError)` rather than treating every error alike: a refetch that failed keeps the data, and a banner is usually all it deserves. - **A memo over a closure.** See the rule above: with `memo:`, what the combiner reads beyond its sources goes in `keys:`. > **Note: In React Query** > > This is the `combine` option of `useQueries`, taken out of it: here it works > over any record of results — from any call style — and over a > `QueriesController`'s list. A failure with nothing to show wins over a > source still loading, and `CombineMemo` plays the part of TanStack's > memoised `combine`. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Dependent queries > A query that needs another query's result waits with enabled — the pattern in each call style, Enabled.yes, Enabled.no and Enabled.when, and why it is still a waterfall. Some requests need the answer to another one first. A smart-home app knows which home to show only once it has the signed-in account; the devices of that home cannot be asked for before. Started too early, the second request goes out with a `null` id — a 404, a retry loop, an error on screen for a moment that was never really an error. A query that needs the result of another must not run until that result is there. `enabled` holds it back, and the value it waits for goes in its key: ```dart QueryObserverOptions> homeDevicesQuery(String? homeId) => QueryObserverOptions( // The value the query waits for is part of its key. queryKey: DeviceKeys.all.append(['home', homeId]), queryFn: (context) => repository.homeDevices(homeId!, signal: context.signal), // No home yet: nothing to ask the server. enabled: homeId == null ? Enabled.no : Enabled.yes, ); ``` Read the first query and hand what it gave you — or `null` — to the second. While the home id is `null`, the second query is `pending` and not fetching (`fetchStatus: idle`). When the account lands, the reader rebuilds with an id, the options change, and the second query starts. **context.query** ```dart @override Widget build(BuildContext context) { final account = context.query(accountQuery()); // null until the account is there — and until then, this one waits. final devices = context.query( homeDevicesQuery(account.dataOrNull?.homeId), ); return switch ((account, devices)) { (QueryError(:final error), _) || (_, QueryError(:final error)) => Center(child: Text('Could not load your home: $error')), (_, QuerySuccess(:final data)) => DeviceListView(data), _ => const Center(child: CircularProgressIndicator()), }; } ``` **QueryBuilder** ```dart Widget myHome() => QueryBuilder( options: accountQuery(), builder: (context, account) => QueryBuilder>( // Rebuilt with the account's home once it is there. options: homeDevicesQuery(account.dataOrNull?.homeId), builder: (context, devices) => homeView(account, devices), ), ); ``` **QueryMixin** ```dart class _MyHomeMixinState extends State with QueryMixin { @override Widget build(BuildContext context) { final account = watchQuery(accountQuery()); final devices = watchQuery(homeDevicesQuery(account.dataOrNull?.homeId)); return homeView(account, devices); } } ``` **QueryController** ```dart class _MyHomeControllersState extends State { late final QueryController _account; late final QueryController, List> _devices; @override void initState() { super.initState(); final client = QueryClientProvider.read(context); _account = QueryController.create(client, accountQuery()); _devices = QueryController.create( client, homeDevicesQuery(_account.value.dataOrNull?.homeId), ); // No build to re-run the options: when the account changes, hand the // devices controller its new ones. _account.addListener(_followAccount); } void _followAccount() { _devices.setOptions(homeDevicesQuery(_account.value.dataOrNull?.homeId)); } @override void dispose() { _account.removeListener(_followAccount); _account.dispose(); _devices.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ListenableBuilder( listenable: Listenable.merge([_account, _devices]), builder: (context, _) => homeView(_account.value, _devices.value), ); } ``` A controller has no build that re-creates its options, so the dependency is spelled out: a listener on the first controller hands the second its new options through `setOptions`. Why the home id belongs in the key: without it, the waiting query and the running one would be the same cache entry, and a user who switches homes would see the first home's devices under the second's name until the refetch lands. The showcase's *dependent queries* screen loads a post's comments only once the post is there. Press *Choose post 1*: until the post lands, the *Comments* card shows *waiting for the post* and `comments enabled=false`, then it fetches. Tick *Pause comments* and choose another post: the card says *Paused: no request until the box is unticked.* Live demo: [Dependent queries](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/dependent-queries), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/dependent_queries)). A query that waits for another to have data. ## `Enabled` | | | |---|---| | `Enabled.yes` | the default: the query fetches on its own | | `Enabled.no` | it never fetches on its own | | `Enabled.when((query) => …)` | decided per query each time it matters | `Enabled.yes` and `Enabled.no` are constants, not constructors. A predicate given to `Enabled.when` is asked often; keep it cheap and free of side effects. It is asked again each time the reader's options are applied — every build for `context.query` and `watchQuery`, a rebuild by its parent for a builder widget, a `setOptions` for a controller — so it may read state outside the query, a setting or a feature flag, and that is when a change is picked up. A disabled query that already has data keeps it and stays `success`. See [disabling queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/disabling-queries.md) for everything a disabled query still does. ## Dependent queries are a waterfall The second request cannot start before the first has answered. That is inherent to the data, but it is still two round trips; if the server can answer both at once — an `/me/home/devices` endpoint — one query beats two. And if the first value is known earlier than the first query answers — the home id is in the login response — seed it with `setQueryData` or put it in the route, and the second query starts at once. See [request waterfalls](https://dualmeta-gmbh.github.io/query_kit/docs/guides/request-waterfalls.md). ## Traps - **A `!` without `enabled`.** `homeId!` in the query function is safe only because the query is disabled while `homeId` is `null`. Drop the `enabled` and the function throws on its first run. - **A spinner for a query that is not running.** A disabled query is pending and idle. Match on `isFetching` or on the first query's state when "not started" deserves its own message, as the showcase does. - **An error from the first query leaves the second pending for ever.** Show the first query's error — the screen above matches either failure first. > **Note: In React Query** > > The same pattern as `enabled: !!userId` in TanStack Query. `enabled` takes > `Enabled.yes`, `Enabled.no` or `Enabled.when(…)` instead of a boolean or a > function, and there is no `skipToken` — `Enabled.no` covers it. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Disabling queries > Enabled.no keeps a query from fetching on its own — a query run only on demand, a search that waits for input, what a disabled query still does, and why there is no skipToken. Most queries should fetch as soon as a screen shows them. A few should not: a network scan that floods the local network for ten seconds, a report that costs the server a minute, a search box with nothing typed in it yet. For those, the question is not *when is the data stale* but *should this run at all* — and the answer is `enabled`. `enabled: Enabled.no` keeps a query from fetching on its own: not on mount, not on focus or reconnect, not on an interval, not on an invalidation. ## On demand: `refetch` A disabled query still fetches when asked to. A scan for new devices in a smart-home app runs only when the user presses the button: ```dart QueryObserverOptions> discoveryQuery() => QueryObserverOptions( queryKey: QueryKey(['discovery']), queryFn: (context) => repository.discover(signal: context.signal), // A scan floods the local network: run it only when asked to. enabled: Enabled.no, ); class ScanButton extends StatelessWidget { const ScanButton({super.key}); @override Widget build(BuildContext context) { final scan = context.query(discoveryQuery()); return Column( children: [ FilledButton( onPressed: scan.isFetching ? null : scan.refetch, child: Text(scan.isFetching ? 'Scanning…' : 'Scan for devices'), ), if (scan case QuerySuccess(:final data)) Text('${data.length} new devices found'), ], ); } } ``` The button reads the same result it triggers: `isFetching` while the scan runs, `QuerySuccess` once it answered. Because the result is in the cache under its key, leaving the screen and coming back shows the last scan at once, for as long as the entry is cached — and does not start a new one. ## Waiting for input: a lazy query A search should not run for an empty box. Disable the query until there is input worth sending, and put the input in the key: ```dart QueryObserverOptions> deviceSearchQuery(String text) => QueryObserverOptions( queryKey: DeviceKeys.all.append(['search', text]), queryFn: (context) => repository.search(text, signal: context.signal), // One letter matches half the house: wait for two. enabled: text.length >= 2 ? Enabled.yes : Enabled.no, ); ``` Each text is its own cache entry, so typing back to an earlier text shows its results at once. The screen then tells "waiting to be enabled" from "loading" by `isFetching`: ```dart class _DeviceSearchState extends State with QueryMixin { String _text = ''; @override Widget build(BuildContext context) { final results = watchQuery(deviceSearchQuery(_text)); return Column( children: [ TextField( decoration: const InputDecoration(labelText: 'Find a device'), onChanged: (text) => setState(() => _text = text.trim()), ), switch (results) { // Disabled: pending, and nothing is running. A hint, not a spinner. QueryPending(isFetching: false) => const Text('Type two letters or more'), QueryPending() => const LinearProgressIndicator(), QueryError(:final error) => Text('Search failed: $error'), QuerySuccess(:final data) => Expanded(child: DeviceListView(data)), }, ], ); } } ``` While the box holds less than two letters, the result is `pending` and not fetching: a hint, not a spinner. `result.isLoading` — pending **and** fetching — is the flag for a spinner; it is `false` for a query that is only waiting to be enabled. A lazy query and a [dependent query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/dependent-queries.md) are the same mechanism: `enabled` computed from something the query needs. The showcase's *dependent queries* screen has both: its comments wait for a post, and the *Pause comments* box disables them outright. Choose a post with the box ticked and the *Comments* card says *Paused: no request until the box is unticked.*; untick it and the request goes out: Live demo: [Dependent queries](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/dependent-queries), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/dependent_queries)). A query that waits for another to have data. ## What a disabled query still does - It **serves cached data**. Without data it is `pending` with `fetchStatus: idle`; with data it stays `success`. - **`refetch()` still fetches**, as above. - **`invalidateQueries` marks it invalidated** but does not refetch it while a disabled observer holds it — and its result's `isStale` stays `false`, because a disabled query is never stale. Once it is enabled, the invalidation counts. `refetchQueries` skips it too. - Once **nothing observes** a query that has fetched before, `refetchQueries` and `invalidateQueries(refetchType: RefetchType.all)` refetch it whatever `enabled` its last observer had. ## Traps - **`Enabled.no` for data that should simply be fresh longer.** A query disabled so that it "does not refetch so often" also never loads on a new screen. That is a `staleTime`, not a switch — see [important defaults](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md). - **Driving a disabled query with `refetch` from `initState`.** That is an enabled query with extra steps, minus the refetches on focus and on reconnect. Reserve `Enabled.no` plus `refetch` for work a user asks for. - **A disabled query with the input outside its key.** A search disabled until there is text, keyed without the text, shows the previous search's results for the new text. > **Note: In React Query** > > `enabled: false` is `Enabled.no`, and there is no `skipToken`. Where the two > differ upstream, `Enabled.no` behaves like `enabled: false`: an unobserved > query that fetched before is refetched by `refetchQueries` and > `invalidateQueries(refetchType: RefetchType.all)`, where `skipToken` would > skip it. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Side effects > Navigation, snackbars and analytics on a change of a result — QueryListener, InfiniteQueryListener and MutationListener, mutation callbacks, and the global callbacks for the whole app. Some reactions to a result are not something to draw. A device was removed on another phone, so its detail screen should close; a refresh failed, so a snackbar should say so once; a rename was saved, so the sheet should go away. Put any of those in `build` and it runs on every rebuild — the snackbar appears three times, the navigator pops twice, and Flutter asserts because you navigated in the middle of a frame. A side effect belongs to a **change** in a result, not to a build. There are three places for one, from the narrowest to the widest: | Where | Runs for | Use it for | |---|---|---| | A listener widget | one controller, while that widget is mounted | navigation, a snackbar, anything that needs this screen's `context` | | A mutation's callbacks | one mutation, or one call of it | writing the server's answer into the cache, invalidating | | The caches' global callbacks | every query or mutation in the app | one error toast, logging, analytics | ## Listener widgets `QueryListener`, `InfiniteQueryListener` and `MutationListener` run a callback on a controller they **borrow** — the owner still disposes it — and never rebuild their `child`. A device's detail screen that leaves when the device is gone and says so when a refresh fails: ```dart class _DeviceDetailScreenState extends State { late final QueryController _device; @override void initState() { super.initState(); _device = QueryController.create( QueryClientProvider.read(context), deviceQuery(widget.id), ); } @override void didUpdateWidget(DeviceDetailScreen oldWidget) { super.didUpdateWidget(oldWidget); _device.setOptions(deviceQuery(widget.id)); } @override void dispose() { _device.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return QueryListener( controller: _device, // The moment it starts failing — not every notification while it stays // failed. listenWhen: (previous, next) => previous is! QueryError && next is QueryError, listener: (context, result) { if (result case QueryError(error: ApiException(statusCode: 404))) { // Removed on another phone: nothing left to show here. Navigator.of(context).pop(); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not refresh this device')), ); } }, child: ValueListenableBuilder>( valueListenable: _device, builder: (context, device, _) => deviceTitle(device), ), ); } } ``` Two properties make these safe for navigation and snackbars, which is the whole point of having them: - **Nothing fires on mount** — only later transitions. Opening the screen on a query that already failed shows the error in the body, not a snackbar. - **Callbacks are delivered off the build phase**, so a result that arrives mid-build reaches the listener after the frame, and `Navigator.pop` is safe. `listenWhen` picks the transitions that matter. A rejected `listenWhen` still advances the comparison state, so the next callback sees the transition it actually followed. (That is the opposite of `buildWhen`, where `previous` is what was last *built* — the two fields have genuinely different jobs.) A listener takes a controller, whichever way the screen reads its data: the controller shares the cache entry and the request with every other reader of the same key, so a screen that draws with `context.query` or a builder can own a controller for its listener alone. `context.mutation` and `watchMutation` hand back a controller already, as below. ## Mutation callbacks A mutation has callbacks of its own — `onMutate`, `onSuccess`, `onError`, `onSettled` — on its options, and again on each call. The options' callbacks are for what must happen whenever the write succeeds, wherever it was started — keeping the cache right: ```dart MutationOptions renameDevice( QueryClient client, String id, ) => MutationOptions.simple( mutationFn: (String name) => repository.rename(id, name), // The server's answer is the new detail: write it, no refetch needed. onSuccess: (device, _, __) { client.setQueryData(DeviceKeys.detail(id), device); }, ); ``` The screen's reaction — closing the sheet — is a listener on that mutation, so it happens only while the sheet is there: ```dart @override Widget build(BuildContext context) { final rename = context.mutation( renameDevice(QueryClientProvider.of(context), id), ); return MutationListener( controller: rename, listenWhen: (previous, next) => next is MutationSuccess, // Saved: the sheet has done its job. listener: (context, _) => Navigator.of(context).pop(), child: TextField( enabled: !rename.value.isPending, onSubmitted: rename.mutate, ), ); } ``` A per-call callback — `mutate(name, onSuccess: …)` — does the same for one call, and is skipped once nothing listens to the mutation any more — when the widget that made the call is gone by the time it settles — and once a later `mutate` of the same controller has replaced it. See [mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) for both, and [updates from mutation responses](https://dualmeta-gmbh.github.io/query_kit/docs/guides/updates-from-mutation-responses.md) for what to write into the cache. ## Global callbacks For a side effect of *every* query or mutation — one error toast for the whole app, a log line per failure — attach callbacks to the caches once, where the client is built, and let each query's `meta` say whether it wants the toast. See [global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md). The showcase's *global callbacks* screen wires both caches to a snackbar and a log. Press *Fetch a missing post*: the query's own screen knows nothing about it, yet a snackbar appears and the *Callback log* reads `query error post-999 (meta: toast)`. On the mutation side, *Create todo* logs the cache's callbacks and the options' callbacks in the order they run, and *Create failing todo* logs `mutation error`: Live demo: [Global callbacks](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/global-callbacks), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/global_callbacks)). Cache-level callbacks, and meta on its way through. ## Traps - **A side effect in `build`.** It runs once per rebuild, and a rebuild is not a change. Move it into a listener. - **A `listenWhen` that compares whole results.** Results differ on every fetch — `fetchStatus`, `dataUpdatedAt` — so `previous != next` fires for a refetch that changed nothing. Compare the part you react to, as the samples above do. - **A cache write in a per-call `onSuccess`.** It is skipped when the user leaves the screen before the write settles, or presses again before it does, and the cache stays stale. Writes to the cache go in the options' callbacks. > **Note: In React Query** > > TanStack Query removed `onSuccess`/`onError` from `useQuery` and points to > effects on `data` and `error`; the listener widgets are the Flutter-shaped > answer, shaped like bloc's `BlocListener`. The mutation callbacks and the caches' > global callbacks are the same as upstream's. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Background fetching indicators > Show that a query is refreshing without hiding the data it already has — isRefetching on one result, and IsFetchingController for an app-wide progress bar. A screen that already shows data should keep showing it while that data is refreshed. Dropping back to a full-screen spinner every time the app comes to the foreground, or every time a mutation invalidates the list, makes a fast app feel slow. What the screen wants instead is a quiet sign that something is in flight: a thin bar under the app bar, a small spinner in a header. A result answers two separate questions: - **`status`** — does the query have data? `QueryPending`, `QuerySuccess` or `QueryError`, the three variants of the sealed result. - **`fetchStatus`** — is a request running right now? `fetching`, `paused` or `idle`. A background refresh is the combination the spinner-only approach misses: `QuerySuccess` **and** `fetching`. See [queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md#what-the-query-is-doing-fetchstatus) for the full table. | On the result | True when | |---|---| | `isFetching` | any fetch of this query is running — the first load and every refresh | | `isLoading` | the first load: pending **and** fetching | | `isRefetching` | a refresh of data already there: fetching **and not** pending | | `isPaused` | a fetch wants to run but is waiting for the network or for focus | ## First load or refresh The device list of a smart-home app, with its keys and options in one file: ```dart // lib/data/device_queries.dart abstract final class DeviceKeys { static final QueryKey all = QueryKey(['devices']); static final QueryKey list = all.append(['list']); static QueryKey byKind(String kind) => all.append(['kind', kind]); static QueryKey page(int page) => all.append(['page', page]); static QueryKey detail(String id) => all.append(['detail', id]); static QueryKey activity(String id) => all.append(['activity', id]); } abstract final class DeviceQueries { static QueryObserverOptions> list() => QueryObserverOptions( queryKey: DeviceKeys.list, queryFn: (context) => deviceRepository.list(signal: context.signal), ); } ``` The body of the list screen switches on the result. The first load gets the whole area; a refresh gets two pixels above data that stays where it is: ```dart Widget deviceListBody(QueryResult> devices) => switch (devices) { // Nothing to show yet: the whole area is the spinner. QueryPending() => const Center(child: CircularProgressIndicator()), QueryError(:final error) => Center(child: Text('No devices: $error')), // Data on screen: keep it, and say quietly that it is being refreshed. QuerySuccess(:final data, :final isRefetching) => Column( children: [ if (isRefetching) const LinearProgressIndicator(minHeight: 2), Expanded( child: ListView( children: [ for (final device in data) DeviceTile(device), ], ), ), ], ), }; ``` A failed refresh does not throw the data away either: the result is a `QueryError` whose `staleData` still holds the list, and `hasStaleData` says so. Whether to show the rows with a warning or only the error is the screen's decision; see [queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md). ## One query's indicator, in each call style The same header — "Devices", and a small spinner while the list refreshes — in the four ways to read a query. They are equal: pick the one your widget is already written in. See [four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md). **context.query** ```dart class DevicesHeader extends StatelessWidget { const DevicesHeader({super.key}); @override Widget build(BuildContext context) { final devices = context.query(DeviceQueries.list()); return ListTile( title: const Text('Devices'), trailing: devices.isRefetching ? const RefreshingSpinner() : null, ); } } ``` **QueryBuilder** ```dart class DevicesHeaderBuilder extends StatelessWidget { const DevicesHeaderBuilder({super.key}); @override Widget build(BuildContext context) => QueryBuilder( options: DeviceQueries.list(), builder: (context, devices) => ListTile( title: const Text('Devices'), trailing: devices.isRefetching ? const RefreshingSpinner() : null, ), ); } ``` **QueryMixin** ```dart class _DevicesHeaderMixinState extends State with QueryMixin { @override Widget build(BuildContext context) { final devices = watchQuery(DeviceQueries.list()); return ListTile( title: const Text('Devices'), trailing: devices.isRefetching ? const RefreshingSpinner() : null, ); } } ``` **QueryController** ```dart class _DevicesHeaderControllerState extends State { late final QueryController, List> _devices = QueryController.create( QueryClientProvider.read(context), DeviceQueries.list(), ); @override void dispose() { _devices.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ValueListenableBuilder( valueListenable: _devices, builder: (context, devices, _) => ListTile( title: const Text('Devices'), trailing: devices.isRefetching ? const RefreshingSpinner() : null, ), ); } ``` The header and the list body can read the same key in two widgets. They share one cache entry and one request; each widget rebuilds for its own read. ## Every query: a global progress bar A bar that shows while *anything* loads is not about one query, so it does not read one. `IsFetchingController` counts the queries whose `fetchStatus` is `fetching` right now, as a `ValueListenable`: ```dart class FetchingBar extends StatefulWidget { const FetchingBar({super.key}); @override State createState() => _FetchingBarState(); } class _FetchingBarState extends State { late final IsFetchingController _fetching = IsFetchingController(QueryClientProvider.read(context)); @override void dispose() { _fetching.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ValueListenableBuilder( valueListenable: _fetching, builder: (context, count, _) => count == 0 ? const SizedBox(height: 2) : const LinearProgressIndicator(minHeight: 2), ); } ``` `QueryClientProvider.read` looks the client up without subscribing to the provider, which is what a `late final` field initialiser wants. The controller subscribes to the cache only while something listens to it, and notifies only when the count changes, not for every cache event. Put it where every screen shows it. Under the app bar's title, it costs no layout: two pixels, empty or filled. ```dart class DevicesScaffold extends StatelessWidget { const DevicesScaffold({super.key, required this.body}); final Widget body; @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( title: const Text('My home'), // Two pixels under the title: empty, or a bar while anything loads. bottom: const PreferredSize( preferredSize: Size.fromHeight(2), child: FetchingBar(), ), ), body: body, ); } ``` A paused fetch — offline under the default [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md) — is not counted: nothing is in flight, so a bar that stayed on would lie. ### Only some queries `filters:` narrows the count, with the same `QueryFilters` every bulk operation takes (see [filters](https://dualmeta-gmbh.github.io/query_kit/docs/guides/filters.md)). A sync badge on the devices tab counts only what lives under `['devices']`: ```dart // Every query under ['devices'] — the list, the pages, each detail. late final IsFetchingController _devicesFetching = IsFetchingController( QueryClientProvider.read(context), filters: QueryFilters(queryKey: DeviceKeys.all), ); ``` The filters are fixed for the controller's life; another set is another controller. Without a widget, `client.isFetching(filters: …)` is the same count, read once. Try it: open the screen below, turn on *Slow post 3* and press *Refetch all*. The `fetching=` count next to the button drops as the fast posts land and stays at one while post 3 is still on its way, and each post's own row says `refreshing` while its data stays on screen. Live demo: [Parallel queries](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/parallel-queries), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/parallel_queries)). Several queries in one widget, and the global fetching count. ### Mutations in flight `client.isMutating()` is the count of running mutations, read once. To subscribe to it — a "saving…" label — use a `MutationStateController` filtered on `MutationStatus.pending`; the length of its list is the count. See [mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md). > **Note: In React Query** > > `isFetching` and `isRefetching` are the fields of the same name on > `useQuery`'s result, and `IsFetchingController` is `useIsFetching`. The count > is the same; it is a `ValueListenable` because a Flutter widget subscribes > through one. See [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # App focus refetching > Stale queries refetch when the user comes back to the app — how Flutter's app lifecycle becomes focus on phones, desktop and the web, how to turn it off or narrow it, and how to bring your own focus source. A user switches to another app, answers a message, comes back five minutes later. The device list on screen is five minutes old, and the thermostat it shows may have been turned down in the meantime. When the app returns to the foreground, every query that has a reader and stale data refetches — the data stays on screen while it does (see [background fetching indicators](https://dualmeta-gmbh.github.io/query_kit/docs/guides/background-fetching-indicators.md)). That is `refetchOnWindowFocus`, and it takes a `RefetchOn`: | `RefetchOn` | When the app comes back | |---|---| | `RefetchOn.ifStale` | the default: refetch if the data is stale | | `RefetchOn.always` | refetch even if the data is fresh | | `RefetchOn.never` | do not refetch | | `RefetchOn.when((query) => …)` | decide per query, at that moment; return one of the other three | Only queries with at least one reader are refetched. An entry whose screen is gone waits in the cache until something reads it again, and the [`refetchOnMount`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md) rule decides then. The same `RefetchOn` type drives `refetchOnMount` and `refetchOnReconnect`. With the default `staleTime` of zero, every return refetches every screen's data. A `staleTime` is the usual way to make that rarer — data younger than it is fresh and skipped. See [important defaults](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md). Try it: in the screen below, set *Stale time* to `0` — the screen starts at thirty seconds, so its data is fresh and a return leaves it alone. Turn *App focused* off and on again and watch entry A's `serial=` grow; set A's *On focus* to `never` and the same round trip leaves it alone. Live demo: [Focus refetch](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/focus-refetch), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/focus_refetch)). What happens when the app comes back to the foreground. ## Turning it off For every query, in the client's defaults: ```dart QueryClient clientWithoutFocusRefetch() => QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults(refetchOnWindowFocus: RefetchOn.never), ), ); ``` For one query, in its options: ```dart // The settings form copies the device into its fields once. A refetch on // return would not change what the user typed, but it would make the // "discard changes" button compare against a newer device than the form. QueryObserverOptions deviceSettingsQuery(String id) => QueryObserverOptions( queryKey: DeviceKeys.detail(id), queryFn: (context) => deviceRepository.byId(id, signal: context.signal), refetchOnWindowFocus: RefetchOn.never, ); ``` An option set on the query wins over the client's default; see [query options](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md). ### A rule of your own `RefetchOn.when` is asked per query each time the app comes back. Here, a return refetches only data older than a minute, whatever `staleTime` says for everything else: ```dart const RefetchOn refetchIfOlderThanAMinute = RefetchOn.when(_olderThanAMinute); RefetchOn _olderThanAMinute(Query query) => query.isStaleByTime(const StaleTime.duration(Duration(minutes: 1))) ? RefetchOn.always : RefetchOn.never; ``` A top-level function keeps the value `const`, so the options compare equal on every rebuild; an inline closure is a new value each time. See [describing a query once](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md#options-built-in-build-are-fine). ## What "focused" means in Flutter A browser tab has one focus event. A Flutter app has an `AppLifecycleState`, and `QueryClientProvider` maps every state the app reports onto the client's focus while it is mounted: | `AppLifecycleState` | Focused? | |---|---| | `resumed` | yes | | `inactive` | **depends on the platform** — see below | | `hidden`, `paused`, `detached` | no | `inactive` means two different things: - On **iOS, Android and Fuchsia** it is a short interruption: the notification shade pulled down, a system dialog, the app switcher, an incoming call. The app counts as **focused** — treating each of those as a departure would refetch everything on the way back from a glance. - On **macOS, Windows and Linux** it is the window losing focus to another window, which is exactly the event this option is named after. The app counts as **unfocused**. **On the web**, Flutter reports `inactive` when the browser window loses focus and `hidden` when the tab is hidden, and the platform is the one the browser runs on. A desktop browser therefore refetches when its window comes back to the front, and a phone's browser when the tab is shown again. Without a provider — a pure-Dart client, or a widget test that builds none — nothing sets focus and the client counts as focused forever; see [the mount contract](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md#the-mount-contract). ### Your own mapping `isAppShown` replaces the mapping, for an app whose idea of "looking at it" differs from the platform's: ```dart QueryClientProvider( client: client, // Only a fully resumed app counts as focused, on every platform. isAppShown: (state) => state == AppLifecycleState.resumed, child: const MyApp(), ), ``` The mapping given on the latest build is the one in force; changing it needs no new client. ### Your own focus source Some apps know better than the lifecycle — a desktop app with several windows, a kiosk that counts "someone is standing in front of it" as focus. The client's focus manager takes an adapter over any source: ```dart void followWindowFocus(QueryClient client, Stream windowFocus) { client.focusManager.setEventListener((setFocused) { final subscription = windowFocus.listen(setFocused); return subscription.cancel; }); } ``` The adapter returns its cleanup. It is removed when the client unmounts and installed again when it mounts. `client.focusManager.setFocused(bool)` is the same report made by hand. **An adapter replaces the provider's lifecycle listener; it does not sit on top of it.** Both write through `setFocused`, so with both installed the last one to report wins and neither sees the other. Turn the lifecycle off when you install your own: ```dart QueryClientProvider( client: client, // The lifecycle listener and a setEventListener adapter are two sources // of focus for one manager. Pick one. observeAppLifecycle: false, child: const MyApp(), ), ``` ## Skipping refetches after a short absence A phone's user leaves the app for two seconds to copy a code from a message. Refetching every screen on the way back is wasted traffic. The focus manager takes a threshold: an absence shorter than it does not start focus refetches. ```dart QueryClient appClient() => QueryClient( focusManager: AppFocusManager( // A glance at another app is not a reason to reload every screen. refetchMinBackgroundDuration: const Duration(seconds: 30), ), ); ``` The default is `Duration.zero`: every return refetches. The threshold holds back only *new* refetches. A fetch that paused while the app was in the background — a retry waiting for focus — continues on the way back whatever the threshold, and reconnecting is never affected. The manager belongs to the client from construction, so the threshold is set where the client is built. ## Focus and other timers Focus decides two more things, both covered on their own pages: - **Polling** skips its turns while the app is unfocused, unless `refetchIntervalInBackground` is set. See [polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md#in-the-background). - **Retries** pause while the app is unfocused and continue when it comes back. See [query retries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md#in-the-background). > **Note: In React Query** > > `refetchOnWindowFocus` takes `true`, `false`, `'always'` or a function; here > it is `RefetchOn.ifStale`, `never`, `always` and `when`. The browser's > `visibilitychange` listener is TanStack Query's built-in focus source; here it > is the app lifecycle, installed by `QueryClientProvider`, and > `focusManager.setEventListener` has the same role as it does for a React > Native app's `AppState`. `refetchMinBackgroundDuration` has no counterpart: > TanStack Query refetches on every return. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Network mode and offline > What queries and mutations do while the client believes it is offline — online, always and offlineFirst — how paused work shows on the result, and how it resumes. A phone loses its connection in a lift. What should the device list do? A request that cannot leave the phone will fail, be retried three times with backoff, and end as an error the user did not cause and cannot fix — unless the query knows it is offline and waits instead. `networkMode` says how a query or mutation treats the client's belief about the network: | `NetworkMode` | Offline, it… | For | |---|---|---| | `NetworkMode.online` | does not start; a retry does not continue. It **pauses** and resumes when the client is online again | the default: anything that needs the internet | | `NetworkMode.always` | ignores connectivity: fetches, fails and retries as if online | work that needs no internet — a local database, a device on the home network | | `NetworkMode.offlineFirst` | makes the first attempt anyway, and pauses a retry | a transport with a cache of its own that may answer offline | "Offline" is what the client *believes*, and nothing is installed to tell it: a client with no connectivity source believes it is online forever, and a failed request is simply a failure. Giving it a source is the [connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md) guide. Try it: in the screen below, turn *Online* off, press *Refetch* and then *Add todo*. Under `online` neither sends anything: the query shows `fetchStatus=paused`, the mutation `isPaused=true`. Turn *Online* back on and both go out by themselves. Switch *Network mode* to `always` and repeat — the requests go out with *Online* off, and this demo's backend answers them: the switch changes only what the client believes, not the network. Live demo: [Offline](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/offline), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/offline)). Network modes, paused mutations, and coming back online. ## `online`: a paused query Under the default mode, a query that would fetch while offline does not. Its `fetchStatus` is `paused` and its result's `isPaused` is `true`: - with no data yet, it stays a `QueryPending` — neither loading nor failed; - with data, it stays a `QuerySuccess` showing that data. Both cases deserve a word on screen, because a spinner that never ends and a list that silently stopped updating look like bugs: ```dart String? offlineNote(QueryResult> devices) => switch (devices) { QueryPending(isPaused: true) => 'Waiting for a connection…', QuerySuccess(isPaused: true) => 'Offline — showing the last list loaded', _ => null, }; ``` A retry is subject to the same rule: a fetch whose first attempt failed and whose connection then dropped pauses between attempts instead of spending them. When the client is online again, a mounted client continues every paused fetch where it stopped — one request, not a fresh start — and then refetches stale queries that have a reader, per `refetchOnReconnect` (a `RefetchOn`, default `ifStale`). ## `always`: ignore connectivity A smart-home app talks to two things: the vendor's cloud account, which needs the internet, and a gateway on the home Wi-Fi, which answers whether or not the router has an uplink. For the gateway, "offline" is the wrong question. Defaults for a key prefix put every gateway query and mutation in `always`, and leave the cloud ones `online`: ```dart // The gateway is on the home network: it answers whether or not the phone // has a route to the internet. The cloud account's queries keep `online`. void talkToTheGatewayOffline(QueryClient client) { client.setQueryDefaults( DeviceKeys.all, const QueryDefaults(networkMode: NetworkMode.always), ); client.setMutationDefaults( DeviceKeys.all, const MutationDefaults(networkMode: NetworkMode.always), ); } ``` Key defaults match by prefix, so every query key under `['devices']` gets them; a mutation gets them only when it carries a `mutationKey` under `['devices']`. **Queries and mutations have separate defaults.** A `networkMode` in the query defaults does not reach mutations, and the other way round; an app that means both sets both, as above — or for the whole client, `DefaultOptions(queries: QueryDefaults(networkMode: …), mutations: MutationDefaults(networkMode: …))`. Each resolves option → the defaults registered for its key → the client's default → `online`. Under `always`, `refetchOnReconnect` defaults to `never`: a query that never waited for the network has nothing to catch up on when it returns. ## `offlineFirst`: try once Some transports can answer without the network — an HTTP client with a disk cache, a service worker on the web. For those, pausing before the first attempt would hide data that is there: ```dart // The repository sits behind an HTTP cache that can answer from disk. QueryObserverOptions> cachedDeviceTypesQuery() => QueryObserverOptions( queryKey: QueryKey(['device-types']), queryFn: (context) => deviceRepository.types(signal: context.signal), networkMode: NetworkMode.offlineFirst, ); ``` The first attempt runs even offline. If it fails, the retry after it pauses until the client is online, as under `online`. ## Mutations offline In the default `online` mode, a mutation started offline is not sent and not failed. It is `pending` with `isPaused: true` — the UI can show the write as queued — and it runs when the client is online again. Several writes made offline are all started again at once, in the order they were made; to send each only after the one before it has settled, give them a shared [scope](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-scopes.md). `client.resumePausedMutations()` is the manual door. A mounted client calls it by itself when it comes back online, so it is rarely needed. It decides per mutation: one that still cannot run (an `online` mutation while offline) is left where it is, and the returned future does not wait for the network. A mutation that could run offline (`always`) but is queued behind a [scope](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-scopes.md)-mate that cannot stays paused until that one moves. > **Warning: Paused work lives in memory** > > Paused queries and queued mutations belong to the running client. If the > operating system ends the app while they wait, they are gone — there is no > persistence layer in 1.0. A write that must survive a restart needs a queue > of your own, stored on the device. ## Resuming needs a mounted client Paused work resumes when the client *hears* that it is online, and it hears only while mounted. `QueryClientProvider` mounts its client for you; a pure-Dart client is mounted with `client.mount()`. See [the mount contract](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md#the-mount-contract). > **Note: In React Query** > > `networkMode` has the same three values and the same meaning, and a paused > query reports `fetchStatus: 'paused'`. The difference is in > `resumePausedMutations`: TanStack Query resumes nothing while offline, while > here each paused mutation is decided by its own network mode, so an `always` > mutation is resumed offline. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Connectivity > Telling the client whether it is online — nothing is installed by default; OnlineStatus.fixed and OnlineStatus.stream, a connectivity_plus adapter, and a reachability probe, because a link is not the internet. The [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md) guide says what a query does while the client believes it is offline. This one is about the belief: where it comes from, and why the obvious source is not quite enough. **Nothing is installed by default.** Neither package depends on a connectivity plugin, so a client nobody tells otherwise believes it is online — the same as TanStack Query with no listener. A request that cannot reach the network then simply fails and is retried like any other failure. For many apps that is fine. An app used on the move, where "no signal" is normal, gets a better experience from pausing instead: queries that wait, writes that queue, and everything continuing when the signal returns. ## `OnlineStatus` `QueryClientProvider` takes an `onlineStatus`, one value with two modes: | | | |---|---| | `OnlineStatus.fixed(online)` | this is the state, with no source of changes — a test, a desktop build, a developer's offline switch | | `OnlineStatus.stream(changes, initial: …)` | follow `changes`, and assume `initial` until the first event | | `null` (the default) | bring nothing: the client keeps believing it is online | `initial` is **required** on the stream form because a `Stream` has no current value. A provider that only listened would start out believing the default — online — however long the first event took, and an app launched in airplane mode would fetch once against a network that is not there. ### With `connectivity_plus` The plugin stays **your** dependency. Ask it once for the current state, then follow its changes: ```dart // lib/main.dart Future main() async { WidgetsFlutterBinding.ensureInitialized(); final connectivity = Connectivity(); bool isOnline(List results) => !results.contains(ConnectivityResult.none); // Built once, outside `build`: a new stream on every rebuild would be // listened to again each time. final changes = connectivity.onConnectivityChanged.map(isOnline); final online = isOnline(await connectivity.checkConnectivity()); runApp( QueryClientProvider.create( create: QueryClient.new, onlineStatus: OnlineStatus.stream(changes, initial: online), child: const MyApp(), ), ); } ``` A broadcast stream always works. A single-subscription one works only while exactly one provider listens to it, once — no remount, no second provider, no switching away and back — and a second listen fails with a `FlutterError` that points to `asBroadcastStream()`. Wrap it when in doubt. ### Fixed With no stream at all, a fixed status is the whole verdict. A changed value reaches the client on the rebuild that changes it — a developer setting that simulates offline, for instance: ```dart QueryClientProvider( client: client, onlineStatus: OnlineStatus.fixed(online), child: const MyApp(), ), ``` ### When the status changes hands - A **swapped client** under the same provider inherits the last value the stream reported. - **Taking the status away** (setting it to `null`) or disposing the provider puts the client back online — once no other provider has a status for that client. - A **replacement provider** on the same client, under a new key or moved to another parent, mounts before the old one goes and keeps its own verdict. ## A link is not reachability `connectivity_plus` reports a *link*: Wi-Fi joined, a cellular bearer up. It does not say that a request will get through. A phone on hotel Wi-Fi behind a captive portal reports "connected"; so does one whose router has lost its uplink, and so does every phone when your backend is down. In each case the client believes it is online, requests fail, and the retries run. That is not a disaster — it is what happens with no connectivity source at all — but an app that wants "offline" to mean "cannot reach *us*" can ask. `OnlineStatus.stream` takes any `Stream`, so a reachability check is one more stream: online when the link is up **and** a probe of the backend answers. ```dart /// `true` while the link is up *and* [probe] reaches the backend. Probes again /// every [recheck] while the link is up: a captive portal or a server outage /// ends without the link changing, and the last answer stands until the /// next one arrives. A probe that throws or takes longer than [timeout] /// counts as unreachable. /// /// A single-subscription stream: hand it to one provider's /// `OnlineStatus.stream`. Stream reachability( Stream link, Future Function() probe, { Duration recheck = const Duration(seconds: 20), Duration timeout = const Duration(seconds: 5), }) { StreamSubscription? linkChanges; Timer? timer; var linkUp = false; var cancelled = false; // Moves on every link change: an answer to a probe started before it is // stale. var epoch = 0; var probing = false; late final StreamController out; Future ask() async { try { return await probe().timeout(timeout, onTimeout: () => false); } on Object { return false; } } Future check() async { if (cancelled) return; if (!linkUp) { out.add(false); return; } if (probing) return; // one probe at a time; a recheck waits for it probing = true; final asked = epoch; final reachable = await ask(); probing = false; if (asked == epoch) { if (!cancelled) out.add(reachable); } else { await check(); // the link changed meanwhile: ask again } } out = StreamController( onListen: () { linkChanges = link.listen((up) { linkUp = up; epoch++; check().ignore(); }); timer = Timer.periodic(recheck, (_) => check().ignore()); }, onCancel: () { cancelled = true; timer?.cancel(); return linkChanges?.cancel(); }, ); return out.stream.distinct(); } ``` The re-probe is the part that is easy to forget. Without it, a probe that failed behind a captive portal leaves the app offline until the link changes — and logging in to the portal does not change the link. The probe itself is one cheap request with a short timeout. With `package:http`: ```dart // lib/data/reachability.dart Future probeBackend() async { try { final response = await http .head(Uri.parse('https://api.example.com/health')) .timeout(const Duration(seconds: 3)); // Over HTTPS a captive portal cannot answer for our host, so a 2xx // is our server. return response.statusCode >= 200 && response.statusCode < 300; } on Exception { return false; } } ``` and the two joined in `main`, the link from `connectivity_plus` as above: ```dart final initial = online && await probeBackend(); runApp( QueryClientProvider.create( create: QueryClient.new, onlineStatus: OnlineStatus.stream( reachability(changes, probeBackend), initial: initial, ), child: const MyApp(), ), ); ``` `reachability` listens to the link stream again whenever its own listener comes back, so give it a broadcast link stream. Keep the probe's traffic in proportion: one `HEAD` every twenty seconds while the link is up is small, but it is not nothing on a metered connection, and it runs for as long as the provider listens, whether or not a screen needs the network. ## Without Flutter A pure-Dart client has no provider to hand a status to. Its `onlineManager` takes the same thing directly — an adapter over any source, or a value set by hand: ```dart client.onlineManager.setEventListener((setOnline) { final subscription = reachable.listen(setOnline); return subscription.cancel; }); // …or by hand: client.onlineManager.setOnline(false); ``` Resuming paused work needs a mounted client (`client.mount()`); see [pure Dart](https://dualmeta-gmbh.github.io/query_kit/docs/guides/pure-dart.md). ## Testing offline `OnlineStatus.fixed(false)` in a widget test's provider puts the client offline from the first frame, and pumping a new provider with `OnlineStatus.fixed(true)` brings it back. In the live demo on the [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md) page, the *Online* switch does the same thing by hand: it calls `client.onlineManager.setOnline`. > **Note: In React Query** > > TanStack Query listens to the browser's `online` and `offline` events by > default — which report a link, like `connectivity_plus` — and > `onlineManager.setEventListener` is how a React Native app installs > `NetInfo`. Here nothing is installed by default; the provider's > `onlineStatus` or `onlineManager.setEventListener` is where a source goes. > See [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Polling > refetchInterval refetches a query on a timer while a reader is on screen — fixed or computed intervals, polling in the background, stopping when the server confirms, and giving up after failures in a row. Some data changes where the app cannot see it happen. A thermostat reports a new temperature, a shutter finishes moving, a firmware update installs on a device. Nothing in the app invalidates those queries, because nothing in the app caused the change — so the screen that shows them asks again on a timer. `refetchInterval` takes a `RefetchInterval`: | | | |---|---| | `RefetchInterval.off` | the default: no polling | | `RefetchInterval.every(d)` | refetch every `d` | | `RefetchInterval.dynamic((query) => …)` | computed from the query; return a `Duration`, or `null` to stop | ```dart // A thermostat's reading changes on its own; nothing in the app invalidates // it, so the screen that shows it asks every five seconds. QueryObserverOptions thermostatQuery(String id) => QueryObserverOptions( queryKey: DeviceKeys.detail(id), queryFn: (context) => deviceRepository.byId(id, signal: context.signal), refetchInterval: const RefetchInterval.every(Duration(seconds: 5)), ); ``` Polling belongs to the **reader**, not to the cache entry. Each reader's observer has its own timer: it starts when the reader subscribes and stops when it goes, so leaving the thermostat screen stops its poll with nothing to clean up. Two widgets reading the same key with different intervals each poll at their own; both refetch the one shared entry. The interval is independent of `staleTime`. A query polls on schedule whether its data is fresh or not — a `StaleTime.static` query included — and each poll is an ordinary refetch: the data stays on screen, `isRefetching` is true while it runs, and [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md) keeps the rows that did not change. Try it: in the screen below, pick `500 ms` and watch the strip's `fetches=` grow; *Add tick* shows up by the next poll at the latest. Pick `dynamic` and the poll stops by itself once the list has three ticks — *Clear ticks* starts it again. Live demo: [Auto refetching](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/auto-refetching), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/auto_refetching)). Polling on an interval, in the foreground or not. ## An interval computed from the data `RefetchInterval.dynamic` is asked for the next interval whenever the query changes — each fetch starting and ending — and when the reader's options change. Returning `null` stops the timer; returning a duration again later starts it. That makes "poll until something is true" a few lines. ### Poll until the server confirms A firmware update is started with a write that the server accepts with `202 Accepted` and a job id; the device installs it over the next minute. The screen polls the job until the server says it is done — or until the device has not answered five times in a row: ```dart // lib/data/firmware_queries.dart QueryKey firmwareJobKey(String jobId) => QueryKey(['firmware-job', jobId]); QueryObserverOptions firmwareJobQuery(String jobId) => QueryObserverOptions( queryKey: firmwareJobKey(jobId), queryFn: (context) => deviceRepository.firmwareJob(jobId, signal: context.signal), refetchInterval: const RefetchInterval.dynamic(_untilTheJobSettles), ); Duration? _untilTheJobSettles(Query query) => switch (query.state) { QueryState(data: FirmwareJob(done: true)) => null, // confirmed: stop QueryState(consecutiveErrorCount: >= 5) => null, // gone quiet: stop _ => const Duration(seconds: 2), }; ``` The callback receives the query untyped, because one `RefetchInterval` value can be shared by queries of any type; a pattern on `query.state` reads the data back. The widget shows the three outcomes. Nothing in it owns a timer or a counter: ```dart class FirmwareProgress extends StatelessWidget { const FirmwareProgress({super.key, required this.jobId}); final String jobId; @override Widget build(BuildContext context) { final job = context.query(firmwareJobQuery(jobId)); return switch (job) { QuerySuccess(data: FirmwareJob(done: true)) => const Text('Update installed'), _ when job.consecutiveErrorCount >= 5 => const Text('The device stopped answering. Check it and try again.'), _ => const LinearProgressIndicator(), }; } } ``` When the job reports done, the device itself has a new firmware version: the write's `onSuccess` is the wrong moment to invalidate it, and a [listener](https://dualmeta-gmbh.github.io/query_kit/docs/guides/side-effects.md) on the job's result is the right one. ### Giving up after failures `consecutiveErrorCount` is on the query's state and on the result: one more with every fetch that ends in an error (its retries exhausted), back to zero with the next data that is **fetched**. A manual write — an optimistic patch, `setQueryData` — leaves it alone, and so does a cancelled fetch: neither says whether the source answers. That is what makes it the right thing to count; the result's variant is not, since a manual write turns a `QueryError` into a `QuerySuccess` while the device is still silent. The stopping rule on its own, as a value to reuse: ```dart const RefetchInterval giveUpAfterFive = RefetchInterval.dynamic(_untilFiveFail); Duration? _untilFiveFail(Query query) => query.state.consecutiveErrorCount >= 5 ? null : const Duration(seconds: 1); ``` Each of those failures has already been [retried](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md) — three times with backoff by default — so five failed polls are twenty requests. For a poll, fewer retries are often the better trade: the next tick is a retry of its own. A top-level function, as here, is one value on every build, so the options compare as unchanged; see [describing a query once](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md#options-built-in-build-are-fine). ## In the background By default a poll skips its turns while the app is unfocused and picks up again when the user comes back — see [app focus refetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md) for what counts as focused on each platform. `refetchIntervalInBackground: true` keeps it running: ```dart // A wall-mounted dashboard on a desktop: keep it current while another // window is in front. QueryObserverOptions> dashboardQuery() => QueryObserverOptions( queryKey: DeviceKeys.list, queryFn: (context) => deviceRepository.list(signal: context.signal), refetchInterval: const RefetchInterval.every(Duration(minutes: 1)), refetchIntervalInBackground: true, ); ``` > **Warning: A phone suspends a backgrounded app** > > On iOS and Android the operating system stops a backgrounded app's Dart code > soon after it leaves the screen, timers included. The flag keeps a poll > going on the desktop, on the web, and for the moments before the phone > suspends the app — it cannot make a phone poll in its pocket. Work that must > happen in the background is the platform's (push notifications, background > fetch), not a query's. ## Pausing a poll A poll that must stop while something else happens — a write in flight, a dialog open — is a value in the options, chosen in `build`: ```dart QueryObserverOptions> polledTasks({required bool writing}) => QueryObserverOptions( queryKey: tasksKey, queryFn: (context) => api.listTasks(signal: context.signal), // A value, not a callback: the widget rebuilds when `writing` flips, // hands over new options, and the observer sees that they changed. refetchInterval: writing ? RefetchInterval.off : const RefetchInterval.every(Duration(seconds: 1)), ); ``` A `RefetchInterval.dynamic` that reads outside state instead is asked only when the query or the options change, so it notices the flag at the next poll at the earliest — one tick late. ## Testing a poll A poll is a timer. In a widget test, `pumpAndSettle` does not step it — nothing schedules a frame while the timer waits — so advance time with `tester.pump(interval)`. See [testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md). > **Note: In React Query** > > `refetchInterval` takes milliseconds, `false` or a function returning > either; here `RefetchInterval.every`, `off` and `dynamic`, with `null` for > "stop". `refetchIntervalInBackground` is the same flag, with the app > lifecycle standing in for the browser tab's visibility. > `consecutiveErrorCount` has no counterpart in TanStack Query. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Query retries > A failed query is retried before the error reaches the screen — RetryPolicy and RetryDelay, not retrying a 4xx, honouring Retry-After, and showing the attempts in the UI. Mobile networks drop requests. A lift, a tunnel, a handover between cells — the first attempt times out and the second one works. So a query whose function throws is not an error on screen straight away: it is retried, **three more times** by default, waiting one second, then two, then four. Only when those run out does the result become a `QueryError`. That default suits a flaky connection and is wrong for a request the server refused on purpose. A 404 will be a 404 on the fourth attempt too, and the user waits seven seconds to learn it. This page is about telling the two apart. ## `RetryPolicy` `retry` takes a `RetryPolicy`: | | | |---|---| | `RetryPolicy.times(n)` | retry up to `n` times after the first failure, so at most `n + 1` attempts — `times(3)` is the query default | | `RetryPolicy.never` | the first failure is the error — the mutation default | | `RetryPolicy.always` | retry until an attempt succeeds | | `RetryPolicy.when((failureCount, error, stackTrace) => …)` | decide per failure; `failureCount` is how many attempts had failed **before** this one, so `0` on the first decision | Set it on a query, or for every query in the client's defaults: ```dart QueryClient deviceAppClient() => QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults( retry: RetryPolicy.times(2), retryDelay: RetryDelay.exponential( base: Duration(milliseconds: 500), maximum: Duration(seconds: 8), ), ), ), ); ``` ### Not retrying what will not change `RetryPolicy.when` is where a policy looks at the error. The app's HTTP layer throws its own exception with the status code (see [query functions](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-functions.md#throwing-is-how-a-query-fails)), and the policy retries server errors and transport failures but not a client error: ```dart const RetryPolicy retryServerErrors = RetryPolicy.when(_retryServerErrors); bool _retryServerErrors(int failureCount, Object error, StackTrace _) => failureCount < 3 && switch (error) { // 4xx: the request is wrong, and asking again will not change that. ApiException(:final statusCode) => statusCode >= 500, // A timeout or a dropped connection may well work the second time. _ => true, }; ``` With dio, the same rule reads the `DioException` the client throws: ```dart bool retryServerErrors(int failureCount, Object error, StackTrace _) { if (failureCount >= 3) return false; if (error is! DioException) return true; return switch (error.type) { DioExceptionType.badResponse => (error.response?.statusCode ?? 0) >= 500, DioExceptionType.badCertificate => false, // Timeouts, connection errors: worth another try. _ => true, }; } ``` The narrower version, for the one status you know is final: ```dart const RetryPolicy retryUnlessNotFound = RetryPolicy.when(_retryUnlessNotFound); bool _retryUnlessNotFound(int failureCount, Object error, StackTrace _) => failureCount < 3 && !(error is HttpError && error.statusCode == 404); ``` A top-level function keeps the policy `const` — one value on every build, so the options compare as unchanged. An inline closure is a new value each time. A policy that throws does not leave the fetch hanging: the throw becomes the fetch's error. ## `RetryDelay` `retryDelay` takes a `RetryDelay`: | | | |---|---| | `RetryDelay.exponential()` | the default: one second, doubling, capped at thirty; `base:` and `maximum:` change both | | `RetryDelay.fixed(d)` | the same wait before every retry | | `RetryDelay.dynamic((failureCount, error) => …)` | computed per failure; `failureCount` is `0` before the first retry | `RetryDelay.dynamic` sees the error, so a server that says how long to wait can be taken at its word — and everything else falls back to the default backoff: ```dart const RetryDelay honourRetryAfter = RetryDelay.dynamic(_retryAfter); Duration _retryAfter(int failureCount, Object error) => switch (error) { ApiException(:final retryAfter?) => retryAfter, _ => RetryDelay.defaultValue.resolve(failureCount, error), }; ``` The delay is only asked when the policy has decided to retry. ## While it retries The fetch is still running while it retries, so the result stays what it was — `QueryPending` on a first load, `QuerySuccess` with its data on a refresh — with `isFetching` true. Two fields report the attempts: - **`failureCount`** — attempts that have failed in the current fetch. - **`failureReason`** — what the latest one threw. It stays set through the retries and after the fetch finally fails, and is cleared when the next fetch starts or an attempt succeeds. So a loading state can say it is struggling without owning a counter: ```dart Widget devicesStatus(QueryResult> devices) => switch (devices) { QueryPending(failureCount: 0) => const Text('Loading devices…'), QueryPending(:final failureCount, :final failureReason) => Text('Still trying (attempt ${failureCount + 1}): $failureReason'), QueryError(:final error) => Text('Could not load devices: $error'), QuerySuccess(:final data) => Text('${data.length} devices'), }; ``` When the retries run out, the result is a `QueryError`. On a first load its `isLoadingError` is true; on a refresh, `isRefetchError` is, and `staleData` still holds what the screen was showing. Try it: in the screen below, pick *Retry* `2 times`, set *Fail the next* to `2`, press *Arm* and then refetch. `failureCount=` climbs to 1 and 2 while `failureReason=` names the refusal, and the third attempt succeeds. With `10` failures armed, the same policy ends in an error after three requests. Live demo: [Retry](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/retry), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/retry)). Retry policies and delays, and what the result shows meanwhile. ## In the background A retry waits for the app to be in front and, under the default [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md), for the network. If the app goes to the background or the client learns it is offline between attempts, the fetch **pauses** — `fetchStatus` `paused`, `isPaused` true — and continues with its next attempt when both are back, rather than spending its retries where nobody is looking. ## Mounting on an error When a query with no data runs out of retries and a new reader mounts later — the user navigates back to the screen — the reader starts a fresh fetch with a fresh set of retries. `retryOnMount: false` leaves the error standing instead, until something else asks. A query that failed a *refresh* still has data, and the ordinary `refetchOnMount` rule decides for it. ## Never retried - A `MissingQueryFunctionError` — there is no function to retry. - A cancelled fetch — see [query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md). Retry policy, retry delay and network mode are read when a fetch starts; see [when options are read](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md#when-options-are-read). An imperative `client.query` with no retry policy of its own or in the defaults makes **one** attempt — there is no widget to show the error and try again — and leaves the cache entry's existing policy in place for later refetches; see [prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md#what-clientquery-joins). ## Tests Three retries with backoff make a failing test wait seven seconds. Turn them off in the client a test builds; see [testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md). > **Note: In React Query** > > `retry` takes `false`, a number, `true` or a function; here > `RetryPolicy.never`, `times`, `always` and `when`. `retryDelay` takes > milliseconds or a function; here `RetryDelay.fixed`, `exponential` and > `dynamic`, with the same default numbers. A `MissingQueryFunctionError` is > retried like any failure there and never here. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Query cancellation > Every query function receives a QueryCancelToken — bridging it to dio and package:http, stopping work done in steps, and what cancelQueries, a leaving screen and a refetch each do to a fetch in flight. The user opens a device's history, which takes three seconds to load over a slow link, and backs out after one. The request is still running. Its answer will be cached, which is fine — but if the transport could stop it, the phone would save the bandwidth and the gateway the work. Every query function receives `context.signal`, a `QueryCancelToken`. It is cancelled when the fetch is no longer wanted. What it cannot do by itself is stop your HTTP request, because Dart has no cancellation primitive that every HTTP client understands — so the token's `onCancel` is the bridge you build once, in the repository. | On `QueryCancelToken` | | |---|---| | `onCancel(callback)` | run `callback` when the fetch is cancelled — at once, if it already is. Callbacks run synchronously inside the cancel | | `isCancelled` | whether it has been cancelled; once true, it stays true | | `whenCancelled` | a future that completes on cancellation, and never otherwise | | `throwIfCancelled()` | throws a `CancelledError` if it has been cancelled | One token is created per fetch and shared by that fetch's retries. A query function never cancels it itself. ## With dio dio has its own `CancelToken`. Create one per request and cancel it from the query's token: ```dart // lib/data/device_repository.dart class DeviceRepository { DeviceRepository(this._dio); final Dio _dio; Future> list({QueryCancelToken? signal}) async { final cancelToken = CancelToken(); signal?.onCancel(cancelToken.cancel); final response = await _dio.get>( '/devices', cancelToken: cancelToken, ); return [ for (final json in response.data!) Device.fromJson(json! as Map), ]; } } ``` and the query hands the signal over: ```dart QueryObserverOptions( queryKey: DeviceKeys.list, queryFn: (context) => deviceRepository.list(signal: context.signal), // … ) ``` A cancelled dio request throws a `DioException` of type `cancel`. You do not need to catch it: by then the library has already settled the fetch as cancelled, and the function's late error is dropped. ## With `package:http` Since version 1.5, `package:http` can abort a request through a future — and `whenCancelled` is one: ```dart Future> list({QueryCancelToken? signal}) async { final request = http.AbortableRequest( 'GET', Uri.parse('$baseUrl/devices'), abortTrigger: signal?.whenCancelled, ); final response = await http.Response.fromStream(await _client.send(request)); return decodeDevices(response.body); } ``` With an older `package:http`, or any client that cannot abort, register nothing: the request runs to completion and its answer is thrown away. That is also what TanStack Query does with a `fetch` that ignores its signal. ## Work done in steps A function that does its work in several requests — reading a long log off a device in chunks — checks between steps, so a cancel stops it at the next boundary: ```dart // Reading a long log off a device, chunk by chunk, over a slow link. Future> readDeviceLog( QueryFunctionContext context, String deviceId, ) async { final signal = context.signal; final lines = []; for (var chunk = 0; chunk < 20; chunk++) { signal.throwIfCancelled(); // nobody wants the rest lines.addAll( await deviceRepository.readLog(deviceId, chunk, signal: signal), ); } return lines; } ``` ## When a fetch is cancelled **The last reader leaves** while the fetch runs — the screen was closed, the search term changed. What happens depends on whether the function *read* `context.signal`: - **It read the signal.** Reading it says "I can be stopped". The fetch is cancelled and the query goes back to the state it held before the fetch started: a list that had data keeps it; a first load goes back to `pending`, `idle`. - **It never read the signal.** The request cannot be stopped, so it is left to finish, and its answer is cached for the next reader. Only further retries are called off. (A first load paused while [offline](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md) is cancelled either way — no request is out.) **`client.cancelQueries(filters: …)`** cancels matching fetches on request — a *Stop* button, or the first step of an [optimistic update](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md), so that a refetch in flight cannot overwrite the optimistic data: ```dart class CancelLogButton extends StatelessWidget { const CancelLogButton({super.key, required this.deviceId}); final String deviceId; @override Widget build(BuildContext context) => TextButton( onPressed: () => QueryClientProvider.read(context) .cancelQueries( filters: QueryFilters(queryKey: deviceLogKey(deviceId)), ) .ignore(), child: const Text('Stop reading'), ); } ``` **A refetch with `cancelRefetch: true`** — the default for a result's `refetch()`, `invalidateQueries` and `refetchQueries` — cancels the fetch in flight and starts its own, when the query already has data. A query still loading its first data joins the running fetch instead. **Search as you type** gets cancellation for free when each term is its own key: a new term is a new query, the old one loses its last reader, and a function that read the signal is cancelled. Try it: the screen below starts its slow fetch as it opens, so wait the three seconds until *Start slow fetch* is enabled, then press it and *Cancel* within three seconds. `cancels=` goes up — the token reached the HTTP client — and `fetchStatus=` is back to `idle`. Turn on *Ignore the signal* and repeat: the query is cancelled all the same and `cancels=` stays put, because nothing told the client to stop; the backend answers in full and the answer is dropped. Below it, type into *Search posts*: each new term cancels the one still in flight. Live demo: [Cancellation](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/cancellation), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/cancellation)). A query cancelled is a request aborted. ## What `cancelQueries` does | Option | Default | Effect | |---|---|---| | `revert` | `true` | each query goes back to the state it held before the fetch, `fetchStatus` `idle` | | `silent` | `false` | `true` means "a new fetch is taking over": nothing is recorded as an error | With the defaults, a reader keeps the data it had. A `client.query` that was waiting for the fetch gets that data back, or a `CancelledError` when there was none; a `refetch()` never throws — it completes with the reader's result, which after the revert is the state from before the fetch. A silently cancelled fetch that nothing replaces is put back to `idle` rather than left `fetching`. The returned future completes when every matching cancel has settled, and never fails. A cancelled fetch is never retried. With `revert` (the default) it leaves no error on the result; `revert: false` without `silent` records the `CancelledError` as the query's error. ## Disconnecting a device To stop talking to something — a device the user removed — cancel its queries and remove them, so nothing refetches them: ```dart void disconnect(QueryClient client, QueryKey deviceKey) { final filters = QueryFilters(queryKey: deviceKey); client.cancelQueries(filters: filters).ignore(); client.removeQueries(filters: filters); } ``` Cancelling a *mutation* works differently — it fails the run; see [cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md). > **Note: In React Query** > > `context.signal` is an `AbortSignal` there, handed straight to `fetch` or > axios; here it is a `QueryCancelToken`, and `onCancel` or `whenCancelled` is > the bridge to your HTTP client. Reading the signal marks the fetch as > cancellable in both. A silent cancel with no successor stays `fetching` > there and goes back to `idle` here. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Paginated queries > One page at a time with the page number in the key — PlaceholderData.keepPrevious keeps the last page on screen while the next loads, and a prefetch makes Next instant. An installer's app lists the devices on a site, fifty to a page, with *Previous* and *Next* underneath. That is an ordinary query with the page number in its key — `['devices', 'page', 3]` — and every page is its own cache entry, so going back to page 2 shows it at once from the cache. What an ordinary query does badly is the moment between pages. The key changes, the new key has no data, and the list drops back to a spinner, the buttons jump, and the scroll position is gone. The fix is one option. ## Keeping the previous page `PlaceholderData.keepPrevious()` shows the previous key's data while the new key loads: ```dart QueryObserverOptions devicePageQuery(int page) => QueryObserverOptions( queryKey: DeviceKeys.page(page), queryFn: (context) => deviceRepository.page(page, signal: context.signal), // `const`: one value on every build, so the options compare unchanged. placeholderData: const PlaceholderData.keepPrevious(), staleTime: const StaleTime.duration(Duration(seconds: 30)), ); ``` While the next page loads, the result is a `QuerySuccess` carrying the **previous** page's data and `isPlaceholderData: true`. When the new page lands it replaces the placeholder, and `isPlaceholderData` goes back to `false`. Nothing is written to the cache: the placeholder is only what this reader shows in the meantime; see [placeholder data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md). That gives the screen two things to do with the flag — dim the rows, so the user sees they are about to change, and hold *Next* until the page it would skip from has actually arrived: ```dart /// The rows and the two buttons, from whichever read the screen uses. Widget devicePageView( QueryResult result, { required int page, required ValueChanged goTo, }) => switch (result) { QueryPending() => const Center(child: CircularProgressIndicator()), QueryError(:final error) => Center(child: Text('No devices: $error')), QuerySuccess(:final data, :final isPlaceholderData) => Column( children: [ Expanded( // The previous page, dimmed, while this one loads. child: Opacity( opacity: isPlaceholderData ? 0.5 : 1, child: ListView( children: [ for (final device in data.devices) DeviceTile(device), ], ), ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton( onPressed: page == 0 ? null : () => goTo(page - 1), child: const Text('Previous'), ), Text('Page ${page + 1}'), TextButton( // Not while a placeholder shows: `hasMore` is the old page's. onPressed: isPlaceholderData || !data.hasMore ? null : () => goTo(page + 1), child: const Text('Next'), ), ], ), ], ), }; ``` ## The reader has to survive the key change `keepPrevious` shows what *this reader* showed before. So the read must be the same observer on page 3 as it was on page 2, with a new key. How that is spelled depends on the call style; all four do it: **context.query** A read's `id:` names it, so a new key is the same read moved rather than a new one: ```dart class _DevicePagerState extends State { int _page = 0; @override Widget build(BuildContext context) { // The id keeps one observer across pages, so keepPrevious has a previous. final result = context.query(devicePageQuery(_page), id: 'devices'); return devicePageView( result, page: _page, goTo: (page) => setState(() => _page = page), ); } } ``` **QueryBuilder** A builder keeps its observer when its options change: ```dart class _DevicePagerBuilderState extends State { int _page = 0; @override Widget build(BuildContext context) => QueryBuilder( // The builder keeps its observer when the key changes. options: devicePageQuery(_page), builder: (context, result) => devicePageView( result, page: _page, goTo: (page) => setState(() => _page = page), ), ); } ``` **QueryMixin** As with `context.query`, the `id:` keeps one observer across pages: ```dart class _DevicePagerMixinState extends State with QueryMixin { int _page = 0; @override Widget build(BuildContext context) { // The id keeps one observer across pages, so keepPrevious has a previous. final result = watchQuery(devicePageQuery(_page), id: 'devices'); return devicePageView( result, page: _page, goTo: (page) => setState(() => _page = page), ); } } ``` **QueryController** A controller is one observer for its lifetime; `setOptions` moves it to the new page: ```dart class _DevicePagerControllerState extends State { int _page = 0; late final QueryController _devices = QueryController.create( QueryClientProvider.read(context), devicePageQuery(_page), ); void _goTo(int page) { setState(() => _page = page); // The same observer, a new key: keepPrevious has a previous. _devices.setOptions(devicePageQuery(page)); } @override void dispose() { _devices.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ValueListenableBuilder( valueListenable: _devices, builder: (context, result, _) => devicePageView(result, page: _page, goTo: _goTo), ); } ``` Without the `id:`, a new key is a new observer, and it has nothing previous to show: the page falls back to `QueryPending` as if there were no placeholder at all. See [reading queries in widgets](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) for how reads are identified. ## Knowing there is a next page A query knows nothing about pages; the server does. Return what the screen needs with the page — a `hasMore` flag, a total, a next cursor — and decide from the data you have. While a placeholder shows, that data is the **previous** page's, which is why *Next* above stays disabled until `isPlaceholderData` is `false`: otherwise a fast double tap skips a page on the strength of the page before it. ## Prefetching the next page With a page on screen, the user will most likely want the next one. Fetch it before they ask, and *Next* shows it instantly — no placeholder, no request: ```dart /// Call from `build` with the page on screen. Starts the next page's fetch /// after the frame — a fetch fires cache events, and the widgets listening /// to them are in the middle of building — and only once per page. void prefetchNextPage( BuildContext context, QueryResult result, { required int page, required Set prefetched, }) { if (result case QuerySuccess(:final data, isPlaceholderData: false) when data.hasMore && prefetched.add(page + 1)) { final client = QueryClientProvider.read(context); WidgetsBinding.instance.addPostFrameCallback((_) { client.query(devicePageQuery(page + 1)).ignore(); }); } } ``` Call it from the build that shows the page, with a `Set` kept in the widget's state. Three details make it work: - **After the frame.** Starting a fetch fires cache events, and the widgets listening to them may be building right now; the post-frame callback waits for the frame to finish. - **Once per page.** `prefetched.add` is `false` the second time, so a rebuild does not ask again. - **A `staleTime`.** The prefetched page is fresh for thirty seconds, so the reader that moves to it finds it fresh and does not fetch again. With the default `staleTime` of zero it would be shown at once — and refetched straight away. See [prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md). Try it: in the screen below, press *Next page* a few times. Each page is there at once, because the one after the page on screen was prefetched as soon as that page landed; press twice in quick succession, before the prefetch answers, and the rows stay on screen with `isPlaceholderData=true` until the new page replaces them. *Previous page* is instant too, from the cache. Live demo: [Pagination](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/pagination), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/pagination)). Page by page, keeping the previous page on screen while the next loads. ## Things to know - **Every page is a cache entry.** Pages nobody reads are collected after `gcTime` — five minutes by default — like any query. A user who pages through two hundred pages holds at most five minutes' worth. - **Invalidating the list invalidates every page.** With keys under one prefix, `invalidateQueries(filters: QueryFilters(queryKey: DeviceKeys.all))` marks every page stale; only the one on screen is refetched, the rest when they are next shown. See [query invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md). - **Rows moving between pages.** Offset paging over data that changes can show a row twice or skip one when something is added or deleted between two page loads. That is the server's paging scheme, not the cache's; a cursor avoids it. - **A list that grows as you scroll** rather than a page at a time is an [infinite query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md). > **Note: In React Query** > > `placeholderData: keepPreviousData` (the successor to v4's > `keepPreviousData: true`) is `PlaceholderData.keepPrevious()` here, with the > same `isPlaceholderData` flag. A hook keeps its observer across renders by > position; here a `context.query` or `watchQuery` read needs an `id:` to be > the same read on a new key. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Infinite queries > A list of pages behind one key — pageFn, getNextPageParam and maxPages, why paging lives on the controller, and a scrolling list with load-on-scroll and pull-to-refresh. A query whose data is a *list of pages*. Same four call styles; the difference is that the function is `pageFn` rather than `queryFn`, and it receives a typed page context. ```dart InfiniteQueryObserverOptions, int> feedQuery() => InfiniteQueryObserverOptions, int>( queryKey: QueryKey(['feed']), pageFn: (context) => api.feed(cursor: context.pageParam), initialPageParam: 0, getNextPageParam: (page, pages, pageParam, pageParams) => page.isEmpty ? null : pageParam + page.length, ); ``` > **Note: Two shapes, as with plain queries** > > A *reader* takes one of two observer shapes, mirroring > [`QueryObserverOptions` and `QuerySelectOptions`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md#two-shapes): > `InfiniteQueryObserverOptions`, above, has no > `select` and its data is the whole `InfiniteData`; > `InfiniteQuerySelectOptions` has a required > `select` over it, the place to flatten pages into one list. The infinite entry > points — `InfiniteQueryBuilder`, `context.infiniteQuery`, > `watchInfiniteQuery`, `InfiniteQueryController` — take either and read every > type argument off the options, so `InfiniteQueryBuilder(options: feedQuery(), > …)` names none. `InfiniteQueryOptions` is what > `QueryClient.query` takes, where there is no observer and so no `select`. - **`initialPageParam`** is where the first page starts. - **`getNextPageParam`** returns where the next page starts, or `null` when there is none — that `null` is what `hasNextPage` is derived from. - **`getPreviousPageParam`** is the same for the other direction; give it only if you page backwards. - **`maxPages`** caps how many pages are kept. Pages are dropped from the far end, and a refetch then touches exactly `maxPages` pages rather than every page ever loaded. - **`context.direction`** on the page context says which way this call is going, for a backend whose two directions differ. ## Paging lives on the controller Every style hands back a controller for an infinite query, because the paging surface is not part of the sealed result — the result keeps one shape whether a query pages or not. ```dart final feed = context.infiniteQuery(feedQuery()); // or watchInfiniteQuery(...), InfiniteQueryBuilder(...), InfiniteQueryController final posts = feed.value.dataOrNull?.flatten() ?? const []; if (feed.hasNextPage && !feed.isFetchingNextPage) { feed.fetchNextPage().ignore(); } ``` On the controller: `hasNextPage`, `hasPreviousPage`, `fetchNextPage()`, `fetchPreviousPage()`, `isFetchingNextPage`, `isFetchingPreviousPage`, `isFetchNextPageError`, `isFetchPreviousPageError`, `isRefetching`, `isRefetchError`. Those flags **notify**: a direction starting or finishing is a change a widget can see, and it rebuilds. ## The data `InfiniteData` holds `pages` and `pageParams`, the same length and in the same order: `pages[i]` was fetched with `pageParams[i]`. For the common case where a page is itself a list: ```dart final pages = feed.value.dataOrNull?.pages ?? const >[]; ``` Pages are structurally shared **page by page**, so a refetch that returns an unchanged page keeps the same instances and the rows built from it do not rebuild. From the client, the typed read is `getInfiniteQueryData(key)` rather than `getQueryData` with an `InfiniteData` type argument. ## Scroll-triggered loading A scroll listener is **level-triggered**. A view resting near the bottom keeps receiving notifications — a page landing changes the content dimensions and sends one — so "am I near the end?" alone asks for the next page again and again, and how many pages you get depends on how fast the machine is. Remember how long the list was when the last page was asked for, and ask again only once it has grown. The scroll position is the wrong thing to remember: one gesture keeps moving, so its next notification looks like a new arrival at the end. The data is wrong too: a page can land and a notification arrive before its rows are laid out, still reading as the end. `maxScrollExtent` changes only when the new rows are laid out — the moment the list really became longer: ```dart double? _askedAtExtent; void onScroll() { final position = scrollController.position; if (position.extentAfter < 400 && position.maxScrollExtent != _askedAtExtent && feed.hasNextPage && !feed.isFetchingNextPage) { _askedAtExtent = position.maxScrollExtent; feed.fetchNextPage().ignore(); } } ``` ## A device's activity log, start to end A device's detail screen ends with its activity log: switched on, switched off, firmware updated — thousands of entries over its life, fetched twenty at a time, newest first. The server answers each page with the offset of the next one, or none at the end: ```dart InfiniteQueryObserverOptions activityQuery(String id) => InfiniteQueryObserverOptions( queryKey: DeviceKeys.activity(id), pageFn: (context) => deviceRepository.activity( id, offset: context.pageParam, signal: context.signal, ), initialPageParam: 0, getNextPageParam: (page, pages, offset, offsets) => page.nextOffset, ); ``` The screen puts the pieces together — the scroll trigger from above, a footer that says what the end of the list is doing, and pull-to-refresh. Here it is with a controller; `context.infiniteQuery`, `watchInfiniteQuery` and `InfiniteQueryBuilder` hand back the same controller surface, so the list, the footer and the scroll trigger read the same with any of them: ```dart class _ActivityLogState extends State { late final InfiniteQueryController> _log = InfiniteQueryController( QueryClientProvider.read(context), activityQuery(widget.deviceId), ); final ScrollController _scroll = ScrollController(); double? _askedAtExtent; @override void initState() { super.initState(); _scroll.addListener(_onScroll); } void _onScroll() { final position = _scroll.position; if (position.extentAfter < 400 && position.maxScrollExtent != _askedAtExtent && _log.hasNextPage && !_log.isFetchingNextPage) { _askedAtExtent = position.maxScrollExtent; _log.fetchNextPage().ignore(); } } @override void dispose() { _scroll.dispose(); _log.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ListenableBuilder( listenable: _log, builder: (context, _) { final result = _log.value; if (result is QueryPending) { return const Center(child: CircularProgressIndicator()); } if (result case QueryError(hasStaleData: false, :final error)) { return Center(child: Text('No activity: $error')); } final entries = [ for (final page in result.dataOrNull?.pages ?? []) ...page.entries, ]; return RefreshIndicator( // Refetches every page held, first to last. onRefresh: _log.refetch, child: ListView.builder( controller: _scroll, // Pull-to-refresh needs a list that scrolls when it is short. physics: const AlwaysScrollableScrollPhysics(), itemCount: entries.length + 1, // A widget per row: the row reads nothing through this context. itemBuilder: (context, index) => index < entries.length ? ActivityTile(entries[index]) : _footer(), ), ); }, ); Widget _footer() { if (_log.isFetchingNextPage) { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } if (_log.isFetchNextPageError) { return TextButton( onPressed: _log.fetchNextPage, child: const Text('Could not load older entries. Try again'), ); } return _log.hasNextPage ? const SizedBox(height: 64) : const ListTile(title: Text('No older activity')); } } ``` What each part is for: - **The footer row** is one more item than there are entries. It shows a spinner while the next page loads, a retry button when that load failed (`isFetchNextPageError` — the entries already shown stay), and the end of the log when `hasNextPage` is `false`. - **`RefreshIndicator`** calls `refetch`, which refetches every page held, first to last, each from the page parameter the previous one returns — so a new entry at the top shifts the pages correctly instead of leaving a gap or a duplicate. The indicator spins until the returned future completes. - **`AlwaysScrollableScrollPhysics`** lets a log shorter than the screen be pulled down at all. - **A widget per row.** `ActivityTile` receives its entry; it reads nothing. A row that needs a query of its own — the user who triggered the entry, say — is its own widget that reads it in its own `build`. A read through the `itemBuilder`'s `context` is refused in debug builds, because that context belongs to the list, and every row ever built would pile onto it. Pulling to refresh a log of fifty pages makes fifty requests. To start over with one page instead, reset the query: `client.resetQueries(filters: QueryFilters(queryKey: DeviceKeys.activity(id)))` drops the pages it held and fetches the first one again. `maxPages` bounds the same cost ahead of time. Try it: scroll to the bottom of the list below, or press *Load more*, and `pages=` grows by one each time until *Nothing more to load* shows. Press *Go to about* and then *Back to list*: the rows are back at once, from the cache. Live demo: [Load more and infinite scroll](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/load-more), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/load_more)). An infinite query that appends pages as you scroll. And with `maxPages: 3`, starting in the middle of the data: press *Load next* twice and `pageParams=` reads `30,40,50`; a third time slides the window to `40,50,60` — still three pages, the first one dropped. *Load previous* slides it back, fetching `30` again, and *Refetch* requests exactly the three pages held. Live demo: [Infinite query with max pages](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/max-pages), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/max_pages)). Pages in both directions, with a window of three. ## Related - [Scroll restoration](https://dualmeta-gmbh.github.io/query_kit/docs/guides/scroll-restoration.md) — why a list comes back where it was. - [Paginated queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/paginated-queries.md) — the page-numbered shape, one page at a time. > **Note: In React Query** > > `useInfiniteQuery` returns `fetchNextPage`, `hasNextPage` and the > direction flags on its result; here they live on the controller every call > style hands back, and the result stays the same sealed type a plain query > has. `queryFn` for pages is `pageFn`, with a typed page context. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Initial query data > InitialData seeds the cache with data the app already has — a bundled catalogue, a row from the list screen — and initialDataUpdatedAt says how old it is, so staleTime decides whether to fetch. The user taps a light in the device list. The detail screen is about to fetch `/devices/42` — but the list screen fetched that device a second ago, name, room and state included. Showing a spinner for data the app is already holding is a waste of the user's time. When the app already has the data a query will fetch, hand it over as **initial data**. The query starts as a `QuerySuccess` with it, and no spinner is shown. **`InitialData` is written to the cache.** It is indistinguishable from a fetch result: every reader of the key sees it, it is shared structurally with what the fetch later returns, and it ages under `staleTime` like fetched data. If it should not be believed — a skeleton, a partial object — you want [placeholder data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md) instead. | | | |---|---| | `InitialData.value(v)` | this value | | `InitialData.compute(() => …)` | computed; returning `null` means "none". `InitialData.value(null)` is a value *of* `null` | | `initialDataUpdatedAt: DateTime?` | how old it is; `null` means now | | `initialDataUpdatedAtCompute: () => DateTime?` | the lazy form, evaluated only when the data is actually seeded. Give one form or the other, never both | Initial data is only a seed. It is used when the cache entry has no data — an entry that already holds data, fetched or seeded, keeps it. ## Data that ships with the app The "add a device" picker lists every device type the gateway supports. The list changes a few times a year, so the app ships with a copy and asks the server for the current one in the background: ```dart // The catalogue ships with the app, so the "add a device" picker never // shows a spinner. Dated when it was bundled, it is older than a day on // most phones, and the server's copy replaces it in the background. QueryObserverOptions> deviceTypesQuery() => QueryObserverOptions( queryKey: QueryKey(['device-types']), queryFn: (context) => deviceRepository.types(signal: context.signal), staleTime: const StaleTime.duration(Duration(days: 1)), initialData: const InitialData.value(bundledDeviceTypes), initialDataUpdatedAt: bundledDeviceTypesDate, ); ``` The date is what makes this right. Without it, the bundled copy would count as fetched *now*, stay fresh for a day, and the picker would show last release's catalogue until tomorrow. Dated when it was bundled, it is older than the `staleTime` on any phone that installed the app more than a day after the build — so the picker shows it at once and refetches behind it. ## Seeding a detail from a list `InitialData.compute` can look in another cache entry. The task list's row becomes the task detail's first state: ```dart QueryObserverOptions taskSeededFromList(QueryClient client, String id) => QueryObserverOptions( queryKey: taskKey(id), queryFn: (context) => api.getTask(id, signal: context.signal), initialData: InitialData.compute( () => client .getQueryData>(tasksKey) ?.where((task) => task.id == id) .firstOrNull, ), // As old as the list it came from. initialDataUpdatedAtCompute: () => client.queryCache .find(filters: QueryFilters(queryKey: tasksKey)) ?.state .dataUpdatedAt, ); ``` Giving the list's `dataUpdatedAt` as the seed's age means the detail is as old as the list it came from, and goes stale at the same moment. The lazy form is asked only when a seed is actually written — not on every rebuild. Only seed a detail from a list when the row holds everything the detail screen shows. A list endpoint that returns a summary — a name and a room, but not the device's settings — seeds a detail with gaps that the screen then renders as real. That is a placeholder, not initial data. ### Only when the list is recent A device's state changes on its own — a shutter moves, a light is switched at the wall. A list fetched ten minutes ago is not the device's current state, and showing it as such, even for the length of a refetch, flickers between the wrong state and the right one. Seed only from a recent list: ```dart QueryObserverOptions deviceSeededIfRecent( QueryClient client, String id, ) => QueryObserverOptions( queryKey: DeviceKeys.detail(id), queryFn: (context) => deviceRepository.byId(id, signal: context.signal), initialData: InitialData.compute(() { final list = client.getQueryState>(DeviceKeys.list); final updatedAt = list?.dataUpdatedAt; // An old list is not worth showing as the device's current state. if (updatedAt == null || DateTime.now().difference(updatedAt) > const Duration(seconds: 10)) { return null; } return list?.data?.where((device) => device.id == id).firstOrNull; }), ); ``` Returning `null` means "no seed": the detail loads like any query. `InitialData.compute` is asked again on every options update and every fetch until the entry holds data, so a seed that appears later — the list lands while the detail is still loading — still lands. Keep it cheap. ## With `staleTime` With the default `staleTime` of zero, initial data is stale at once, so it is shown *and* refetched behind. With a `staleTime`, initial data younger than it is not refetched — which is why its age matters: | Seed dated | `staleTime` | On mount | |---|---|---| | now (no date given) | zero | shown, refetched at once | | now (no date given) | one minute | shown, no request; stale a minute later | | the list's `dataUpdatedAt`, 40 s ago | one minute | shown, no request; stale 20 s later, with the list | | bundled, weeks ago | one day | shown, refetched at once | Try it: in the screen below, open a post from card **A** within thirty seconds of the list loading. Its title shows at once from the list, and the strip's `fetches=` stays `0` — the seed is as old as the list, younger than the thirty-second `staleTime`. Turn on *Treat initial data as old* and open another: the title still shows at once, and one fetch follows. Card **D** does the same with the lazy date: `fresh` fetches nothing, `backdated` refetches straight away, and `computeCalls=` stays at `1` through every rebuild. Live demo: [Initial and placeholder data](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/initial-and-placeholder), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/initial_and_placeholder)). Data before the first fetch: written to the cache, or shown only. ## Other ways to have data before the screen opens Initial data lives in the options, so the query that needs it says where it comes from. The alternatives write to the cache from outside: [prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md) fetches ahead of a navigation, and [updates from mutation responses](https://dualmeta-gmbh.github.io/query_kit/docs/guides/updates-from-mutation-responses.md) write what a write returned. Both are better when the data is not at hand at the moment the query is created. > **Note: In React Query** > > `initialData` takes a value or a function, and `initialDataUpdatedAt` a > number of milliseconds or a function; here `InitialData.value` or > `InitialData.compute`, with `initialDataUpdatedAt` a `DateTime` and > `initialDataUpdatedAtCompute` its lazy form, as separate fields. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Placeholder query data > PlaceholderData is shown while the real fetch runs and never written to the cache — a fixed stand-in, a row borrowed from the list, or the previous key's data — flagged isPlaceholderData so the screen can render it as provisional. A device's detail screen opens. The full device — settings, firmware, schedules — takes a moment to load, but the list the user tapped already knows its name and room. Showing those straight away, dimmed, reads as "loading" without a blank screen; showing them as if they were the whole device would be a lie. That is placeholder data: what a reader sees while the real fetch runs. Unlike [initial data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md), **it is never written to the cache**. Other readers of the key do not see it, it does not count as fetched, and the first real result replaces it. | | | |---|---| | `PlaceholderData.value(v)` | this value | | `PlaceholderData.compute((previousData, previousQuery) => …)` | computed; given the data (before `select`) of the query this reader last showed, and that query. Returning `null` means "none" | | `const PlaceholderData.keepPrevious()` | the data this reader showed for the previous key | While it shows, the result is a `QuerySuccess` with `isPlaceholderData: true`, and the fetch runs as it would with no placeholder. A placeholder goes through `select` like real data, so a selecting reader receives the selected placeholder. It shows only while the query has **no data and no error** — `pending`. Once real data has landed, or the fetch has failed, there is nothing to stand in for. ## A fixed stand-in The simplest placeholder is a value of the right type that the screen can lay out — a skeleton device: ```dart QueryObserverOptions deviceQuery(String id) => QueryObserverOptions( queryKey: DeviceKeys.detail(id), queryFn: (context) => deviceRepository.byId(id, signal: context.signal), placeholderData: const PlaceholderData.value(Device.loading), ); ``` The screen then renders one layout for both, so nothing jumps when the real device lands, and distinguishes the two by the flag: ```dart Widget deviceTitle(QueryResult device) => switch (device) { QuerySuccess(:final data, isPlaceholderData: true) => Opacity(opacity: 0.5, child: Text(data.name)), QuerySuccess(:final data) => Text(data.name), QueryPending() => const Text('…'), QueryError(:final error) => Text('Could not load: $error'), }; ``` ## A row from the list `PlaceholderData.compute` can borrow from another cache entry. The row the user tapped is shown while the detail loads — and, because it is a placeholder, it is not cached as the detail, so no other screen takes the summary for the whole device: ```dart QueryObserverOptions devicePreviewedFromList( QueryClient client, String id, ) => QueryObserverOptions( queryKey: DeviceKeys.detail(id), queryFn: (context) => deviceRepository.byId(id, signal: context.signal), // The list row, shown while the detail loads — and not cached as it. placeholderData: PlaceholderData.compute( (_, __) => client .getQueryData>(DeviceKeys.list) ?.where((device) => device.id == id) .firstOrNull, ), ); ``` When the list row holds everything the detail shows, seed it as initial data instead; see [seeding a detail from a list](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md#seeding-a-detail-from-a-list). ### How often it is computed A reader keeps its placeholder while the `placeholderData` it is handed is the **identical** instance, and the `select` is the same. A `const` placeholder — `const PlaceholderData.value(…)`, `const PlaceholderData.keepPrevious()` — is one instance forever, so it is provided once. A `PlaceholderData.compute(…)` built in a function, as above, is a new instance each time the options are built, so the callback runs again on every rebuild while the detail loads. Keep it cheap — a lookup, as here. A callback that needs nothing from outside can be a top-level function in a `const PlaceholderData.compute(…)`, which is computed once like the others. ## Keeping the previous key's data `keepPrevious` is for a key that changes — a page number, a search term, a device picked in a side panel. The screen keeps showing the old key's data until the new key's lands, instead of dropping back to a spinner. It shows what *this reader* showed before, so the reader must survive the key change: a builder and a controller keep their observer across a key change; in the `context.query` and `QueryMixin` styles, give the read an `id:`. The [paginated queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/paginated-queries.md) guide shows all four. `keepPrevious` is `PlaceholderData.compute((previousData, _) => previousData)` with a name — and `const`. Try it: in the screen below, card **B** shows the stand-in title with `isPlaceholderData=true` and `cache=empty` while post 4 loads, then the real title with `false` — the cache never held the placeholder. In card **C**, switch between the posts: the previous post stays on screen, flagged, until the next one arrives. Live demo: [Initial and placeholder data](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/initial-and-placeholder), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/initial_and_placeholder)). Data before the first fetch: written to the cache, or shown only. ## Placeholder or initial? | | Initial data | Placeholder data | |---|---|---| | Written to the cache | yes | no | | Seen by other readers | yes | no | | Subject to `staleTime` | yes — fresh initial data is not refetched | no — the fetch runs as if there were no data | | `isPlaceholderData` | `false` | `true` | | Shown after a failed fetch | yes, as `staleData` on the error | no | Use initial data when it is the real data — a copy from another cache entry that holds everything. Use placeholder data when it is only something to show — a skeleton, a summary, the previous page. > **Note: In React Query** > > `placeholderData` takes a value or a function `(previousData, > previousQuery) => …`, and `keepPreviousData` is the helper for the previous > key; here `PlaceholderData.value`, `PlaceholderData.compute` and `const > PlaceholderData.keepPrevious()`. A hook keeps its observer across renders by > position; a `context.query` or `watchQuery` read needs an `id:` for > `keepPrevious` to have a previous. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Scroll restoration > A list comes back where it was when its data is still cached, because the first build already has the rows — and what Flutter needs from you, a PageStorageKey or a restorationId, to put the position back. The user scrolls halfway down a list of sixty devices, opens one, comes back — and lands at the top of an empty list with a spinner, then at the top of the full list. Two things went wrong there, and only one of them is about data. **The data half is solved by the cache.** Coming back to a list whose query is still cached is instant: the first build after navigating back already has the rows — a `QuerySuccess` on the first frame, no spinner and no empty frame. That is what makes restoring a scroll position possible at all: a saved offset into a list that is still loading has nothing to point at, and Flutter clamps it to zero. If the data is stale, it is refetched **behind** the rows, and [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md) keeps the instances of the rows that did not change, so the list does not jump or rebuild what is the same. **The position half is Flutter's.** What you need to do depends on how the list went away. ## Pushed routes: nothing to do `Navigator.push` keeps the route below alive, its widgets and its scroll position included. Popping back to the list needs nothing from you or from the cache. (The list's query still has a reader the whole time, so it is not even collected.) ## Tabs, page views, switched bodies: `PageStorageKey` A `TabBarView`, a `PageView`, or a screen that swaps its body builds the list again from scratch when it comes back. Give the scrollable a `PageStorageKey`, and Flutter's `PageStorage` keeps its offset while it is gone and puts it back when it is built again — with its rows, since they come from the cache: ```dart class DeviceList extends StatelessWidget { const DeviceList({super.key, required this.kind}); final String kind; @override Widget build(BuildContext context) { final devices = context.query(devicesOfKind(kind)); final rows = devices.dataOrNull; if (rows == null) { return const Center(child: CircularProgressIndicator()); } return ListView.builder( // Where PageStorage files this list's offset. key: PageStorageKey('devices-$kind'), itemCount: rows.length, itemBuilder: (context, index) => DeviceTile(rows[index]), ); } } class DeviceTabs extends StatelessWidget { const DeviceTabs({super.key}); @override Widget build(BuildContext context) => DefaultTabController( length: 2, child: Scaffold( appBar: AppBar( bottom: const TabBar( tabs: [Tab(text: 'Lights'), Tab(text: 'Shutters')], ), ), body: const TabBarView( children: [ DeviceList(kind: 'light'), DeviceList(kind: 'shutter'), ], ), ), ); } ``` Two details matter: - **The spinner has no key.** While the rows are loading there is no list to restore; the `ListView` with the key is built only once there is data. The first time, that is after the fetch; every time after, it is the first frame. - **One key per list.** Each tab's list files its offset under its own key (`devices-light`, `devices-shutter`), so switching tabs does not hand one list the other's position. `AutomaticKeepAliveClientMixin` is the other way to keep a tab's position: it keeps the whole tab alive while it is off screen. That costs the widgets' memory, and the tab's queries keep a reader — so they keep polling and refetching on focus. With a `PageStorageKey`, the tab is gone, its queries have no reader, and it is rebuilt from the cache when it returns. ## How long the rows are there The cache keeps a query without readers for its `gcTime` — five minutes by default — before collecting it; see [garbage collection](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md#garbage-collection). Come back within that and the rows are there on the first frame. Come back later and the list loads from scratch: the spinner shows first, and the saved offset is applied when the keyed list is built with the new rows — a jump the user sees. An infinite query is back to its first page then, so an offset further down is clamped to what that page holds. For a list the user returns to after longer breaks, raise the `gcTime` on that query. An [infinite query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md) keeps every page it had loaded — up to `maxPages` — so a long scrolled list comes back to its full length, not to its first page. ## Across app restarts: state restoration When the operating system ends a backgrounded app and the user returns to it, Flutter's state restoration can bring the scroll position back: a `restorationScopeId` on the app, a `restorationId` on the scrollable. The data does not come back with it — the cache lives in memory — so the list loads, and the scrollable takes its restored offset when it is built — so, as with a `PageStorageKey`, build it only once the rows are there. An infinite query starts again from its first page. There is no persistence of the cache in 1.0. Try it: in the screen below, press *Load more* a couple of times, then press *Go to about*. The list is unmounted — its query has no reader — and *Back to list* rebuilds it with every page on the first frame and no new request. This demo's list has no `PageStorageKey`, so it comes back at the top: the rows are the cache's half, the position is the part this page adds. Live demo: [Load more and infinite scroll](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/load-more), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/load_more)). An infinite query that appends pages as you scroll. > **Note: In React Query** > > The web has the browser's own scroll restoration, which works when the data > is cached and the first render has it; TanStack's guide says as much. In > Flutter the position is `PageStorage`'s or state restoration's, and the > cache supplies the same thing — the rows on the first build. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Mutations > The four ways to read a mutation, MutationResult, mutate and mutateAsync, per-call callbacks, identity, and mutation defaults. A mutation is a write. It has the same four call styles as a query — `context.mutation(...)`, `watchMutation(...)`, `MutationBuilder`, `MutationController` — and every one of them hands back a **controller**, because you need `mutate` as well as the state. ```dart final add = context.mutation( MutationOptions.simple( mutationFn: api.addTask, onSuccess: (_, __, ___) => client.invalidateQueries( filters: QueryFilters(queryKey: tasksKey), ), ), ); // … add.value is the MutationResult; add.mutate(vars) starts it. ``` `MutationResult` is sealed: `MutationIdle`, `MutationPending`, `MutationSuccess`, `MutationError`, with `isIdle` / `isPending` / `isSuccess` / `isError` for the cases where a `switch` is more than you need. ## Three type arguments, and why `simple` exists `MutationOptions`. The third is what `onMutate` returns — the **rollback handle** of an optimistic update. A mutation without an optimistic step has nothing to roll back, so `MutationOptions.simple` fixes that third argument to `void` and lets the other two infer from `mutationFn`. That is the only reason it exists: a typedef cannot fix one type argument of a constructor and leave the rest to inference. ## `mutate` versus `mutateAsync` - `mutate(vars)` starts it and returns nothing. Errors go to `onError` and to the result; nothing is thrown at the call site. - `mutateAsync(vars)` returns a `Future` that **rejects** on failure. Use it when the caller genuinely wants to `await` the outcome — and then handle the rejection, or an unhandled async error will find you. Per-call callbacks ride along, and run *after* the options' own: ```dart add.mutate( 'New task', callbacks: MutateCallbacks( onSuccess: (data, vars, _) => Navigator.of(context).pop(), ), ); ``` A mutation does not retry unless you ask: its `retry` defaults to `RetryPolicy.never`, because repeating a write is rarely safe. Pass a policy when the endpoint is idempotent. The showcase's `mutations` screen walks through all of this on one counter. Press *Increment (mutate)* and *Increment (mutateAsync)* and watch the status go `pending`, then `success`; tick *Fail next* for an error after exactly one request; *Run with callbacks* logs the callbacks in the order they run. Live demo: [Mutations](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/mutations), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutations)). mutate, mutateAsync, reset, callbacks, and scopes. ## In an app A mutation's options belong next to the queries it affects — a `lib/data/device_mutations.dart` beside `device_queries.dart` — as a function of what it needs. Here, the power switch of one device in a smart-home app: the server answers with the device as it now is, which goes straight into the cache, and the room lists refresh behind it. ```dart // lib/data/device_mutations.dart MutationOptions setPowerMutation( QueryClient client, String id, ) => MutationOptions.simple( mutationFn: (bool on) => devices.setPower(id, on: on), onSuccess: (device, _, __) { client.setQueryData(DeviceKeys.detail(id), device); client .invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.lists), ) .ignore(); }, ); ``` The switch reads the mutation in any of the four call styles. They hand you the same controller — `value` for the state, `mutate` to start a run — and behave the same; pick the one your widget already uses. **context.mutation** ```dart class PowerSwitch extends StatelessWidget { const PowerSwitch({super.key, required this.device}); final Device device; @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); final power = context.mutation(setPowerMutation(client, device.id)); final result = power.value; return Switch( // While the write is out, show what was asked for. value: result.isPending ? result.variables! : device.isOn, onChanged: result.isPending ? null : power.mutate, ); } } ``` **MutationBuilder** ```dart Widget powerSwitch(QueryClient client, Device device) => MutationBuilder( options: setPowerMutation(client, device.id), builder: (context, power) { final result = power.value; return Switch( value: result.isPending ? result.variables! : device.isOn, onChanged: result.isPending ? null : power.mutate, ); }, ); ``` **QueryMixin** ```dart class _MixinPowerSwitchState extends State with QueryMixin { @override Widget build(BuildContext context) { final device = widget.device; final power = watchMutation(setPowerMutation(queryClient, device.id)); final result = power.value; return Switch( value: result.isPending ? result.variables! : device.isOn, onChanged: result.isPending ? null : power.mutate, ); } } ``` **MutationController** ```dart class _ControllerPowerSwitchState extends State { late final QueryClient _client = QueryClientProvider.read(context); late final MutationController _power = MutationController(_client, setPowerMutation(_client, widget.device.id)); @override void dispose() { _power.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ValueListenableBuilder>( valueListenable: _power, builder: (context, result, _) => Switch( value: result.isPending ? result.variables! : widget.device.isOn, onChanged: result.isPending ? null : _power.mutate, ), ); } ``` While the write is out, the switch shows the value it was asked for — the mutation's `variables` — and is disabled; if the write fails, it falls back to what the cache says. That is an [optimistic update](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md) in its simplest form. In a list, give each row's widget a `ValueKey` of the device's id, so a row's mutation stays with its device when the list reorders. ## A mutation outlives its widget Disposing a controller does not cancel the mutation — a write the user started should normally finish; call `cancel()` first when it should not. That has one practical consequence, and it bites everyone once: ```dart @override Widget build(BuildContext context) { // Take the client HERE, not inside onSuccess. The callback 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(/* … */), )); // … } ``` The cache work has to happen either way. The client is the right thing to close over; the `BuildContext` is not. Per-call callbacks — the `callbacks:` passed to `mutate` — are the exception: once the reader has stopped listening, they are skipped, and only the options' own callbacks run. Put what must happen in the options. ## Narrowing rebuilds `context.mutation`, `watchMutation` and `MutationBuilder` take [`buildWhen`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md#buildwhen); a `MutationController` has none, on purpose — it is the notifier. It is the only narrowing a mutation reader has: there is no `select` on a mutation. ## Identity In the context and mixin styles a mutation is identified by `id:` if you give one, else by its `mutationKey`, each together with its three type arguments; without either, by the types alone. A `mutationKey` is a category, as TanStack Query's is, not a name: two mutations of the same shape under one key read in one `build` without an `id:` would share one controller, and whichever was read last would run for both — its function and its callbacks alike. When their mutation functions or callbacks (`onMutate`, `onSuccess`, `onError`, `onSettled`) differ, a debug build catches it with an assertion: "delete, then pop" and "delete, then show a snackbar" are two mutations. So does a different `scope`, `retry`, `retryDelay`, `networkMode` or `gcTime`: rows reading `MutationScope('task-$id')` inline under one key would otherwise share one queue. Give each an `id:`. Those five compare by value, except the two that carry a closure — `RetryPolicy.when` and `RetryDelay.dynamic` — which compare by variant only, so a helper building one inline and read twice is still one mutation. `meta` is not compared — it is most often a map literal, new on every build — and the last read's wins. The same functions read twice are one mutation and do not assert: a getter over one stored options object, options built around tear-offs or top-level functions, or a nested builder re-reading what `build` read. A function literal is a new function every time it is evaluated, so a getter that builds one per read looks exactly like two mutations and still asserts — keep the options (or the functions) in a field, or read the mutation once and share the controller. Only a `StatelessWidget`'s or `State`'s own `build` is checked; reads through a `LayoutBuilder`'s context never are. Like a query, a mutation is released after the frame once a build stops reading it. ## Defaults `client.setMutationDefaults(key, MutationDefaults(...))` registers `mutationFn`, `retry`, `retryDelay`, `networkMode`, `gcTime`, `scope` and `meta` per key. **Not callbacks** — see [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). A mutation with neither a `mutationFn` nor a `mutationFnWithContext` anywhere fails with `MissingMutationFunctionError`, and is never retried. > **Note: In React Query** > > This page is `useMutation`. `mutate` and `mutateAsync` behave as they do > there, and so does the order of the callbacks. Two things differ: a mutation > has four call styles here rather than one hook, and `setMutationDefaults` > takes no callbacks. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). ## Where next - [Invalidations from mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md) and [updates from mutation responses](https://dualmeta-gmbh.github.io/query_kit/docs/guides/updates-from-mutation-responses.md) — making the cache agree with the write. - [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md) — showing the write before the server confirms it. - [Mutation scopes](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-scopes.md), [cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md) and [mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md). - Offline, a mutation is paused rather than failed; see [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md). --- # Query invalidation > invalidateQueries marks matching queries stale and refetches the ones on screen — prefix matching, exact, predicates, refetchType and cancelRefetch. Waiting for data to go stale by itself is fine for data that changes elsewhere. It is not good enough when *your app* just changed it: the user renamed a task, and the list on the previous screen still shows the old name for as long as its `staleTime` says it is fresh. You know the cached data is wrong. `invalidateQueries` is how you say so. ```dart // Every key that starts with ['tasks']: the lists and every detail. await client.invalidateQueries( filters: QueryFilters(queryKey: TaskKeys.all), ); // This one detail only. await client.invalidateQueries( filters: QueryFilters(queryKey: TaskKeys.detail(id), exact: true), ); // Mark everything under ['tasks'] stale, and refetch nothing now. await client.invalidateQueries( filters: QueryFilters(queryKey: TaskKeys.all), refetchType: RefetchType.none, ); ``` An invalidation does two things to every query it matches: 1. **It marks the query stale**, whatever its `staleTime` says — except `StaleTime.static`, which an invalidation leaves fresh. So the next mount, focus or reconnect refetches it. 2. **It refetches the query now if it is active** — if a widget or an observer is reading it. A query nobody reads waits until something reads it again, and then refetches because it is stale. The future completes when those refetches have settled. A refetch that fails does not fail it — the error lands in the query's state, where its readers see it — and a refetch paused because the device is offline is not waited for. ## Keys decide what an invalidation reaches A filter's `queryKey` matches as a **prefix**: `['tasks']` matches `['tasks']`, `['tasks', 'list', 'open']` and `['tasks', 'detail', '7']`. That is why keys are built from the most general part down, and why an app usually keeps them in one place. In a smart-home app, a key factory makes every level of the device hierarchy something you can name: ```dart // lib/data/device_keys.dart abstract final class DeviceKeys { static final QueryKey all = QueryKey(['devices']); static final QueryKey lists = all.append(['list']); static QueryKey list({required String room}) => lists.append([room]); static final QueryKey details = all.append(['detail']); static QueryKey detail(String id) => details.append([id]); } ``` With that, every invalidation in the app is a sentence about the domain rather than a list of string arrays: ```dart // Something changed about this device: its detail, and every room list. await client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.detail(device.id)), ); await client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.lists), ); // A bulk import from the hub: everything about devices is suspect. await client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.all), ); ``` `DeviceKeys.lists` matches every room's list; `DeviceKeys.all` matches the lists, every detail and anything filed under a detail. See [query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md) for how keys compare, maps inside keys included. ## Narrowing the match **`exact: true`** matches the key itself and nothing below it — `QueryFilters(queryKey: DeviceKeys.lists, exact: true)` matches a query whose key is exactly `['devices', 'list']`, and no room's list. **`predicate`** takes the `Query` and says yes or no, for anything a key prefix cannot express. It runs after the other fields have matched, so give it a prefix to keep the set it is asked about small: ```dart // The kitchen and the hallway were merged on the hub: invalidate the lists // of both rooms, and nothing else. const merged = {'kitchen', 'hallway'}; await client.invalidateQueries( filters: QueryFilters( queryKey: DeviceKeys.lists, predicate: (query) => merged.contains(query.queryKey.parts.last), ), ); ``` The other [filters](https://dualmeta-gmbh.github.io/query_kit/docs/guides/filters.md) work too: `type`, `stale`, `status` and `fetchStatus`. ## Which queries refetch `refetchType` decides the second step: | `RefetchType` | Refetches | |---|---| | `active` | matching queries a reader is observing — the default, unless the filters' own `type` says otherwise | | `inactive` | matching queries nobody is observing | | `all` | both | | `none` | nothing — the queries are only marked stale | `RefetchType.all` is for data you know will be needed again soon, such as a list behind the screen the user will return to. `RefetchType.none` is for a change that should show up only when the data is next read. A disabled query is marked but never refetched by an invalidation, and neither is an observed query whose `staleTime` is `StaleTime.static`. ## A fetch already in flight `cancelRefetch` (default `true`) decides what happens to a matching query that is already fetching. If the query holds data, its fetch is cancelled and a new one started, so what lands was fetched after your call — the fetch that was running may have left before the server saw your write. If it has no data yet, the running fetch is joined instead: that first load is the answer anyway. Pass `false` to join a running fetch in every case. ## Awaiting it, or not `await` the invalidation when the caller has to know the screen is consistent — a pull-to-refresh whose indicator should stay until the new data is there. Otherwise call `.ignore()` on the future and let the refetch run behind whatever comes next. After a mutation the choice decides how long the mutation stays `pending`; see [invalidations from mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md). The `invalidation-and-filters` screen runs every one of these calls against a small cache: the posts list, two observed posts, one post nobody reads, and the todos. Press *Invalidate posts prefix* and watch the three observed posts entries refetch while post 3 is only marked stale; *Invalidate posts exactly* refetches the list alone, and *Invalidate inactive too* fetches post 3 as well. Live demo: [Invalidation and filters](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/invalidation-and-filters), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/invalidation_and_filters)). Invalidate, refetch, reset and remove, by prefix, type or predicate. ## Related operations | Call | What it does | |---|---| | `refetchQueries` | refetches matching queries now, without marking anything stale; skips disabled and static ones | | `resetQueries` | puts matching queries back to their initial state (their `initialData`, or nothing), then refetches the active ones | | `removeQueries` | drops matching queries from the cache — for keys nobody is watching; a reader still attached keeps showing what it had | | `cancelQueries` | cancels matching fetches in flight and, by default, puts each query back to the state it had before the fetch | > **Note: In React Query** > > The same `queryClient.invalidateQueries`, with the same prefix matching, > `exact`, `predicate`, `refetchType` and `cancelRefetch`. The filters are a > named `filters:` argument here rather than the first argument, and > `StaleTime.static` is TanStack Query's `staleTime: 'static'`. --- # Invalidations from mutations > Invalidate the queries a write affected from its onSuccess or onSettled — which keys, which callback, and what returning the future does to the mutation's state. A write makes some cached reads wrong. A device was removed, so every list that showed it is out of date; a task was added, so the task list is short by one. The simplest way to make them right is to invalidate them once the write has landed, and let the ones on screen refetch: ```dart final add = context.mutation( MutationOptions.simple( mutationFn: api.addTask, onSuccess: (_, __, ___) => client.invalidateQueries( filters: QueryFilters(queryKey: tasksKey), ), ), ); // … add.value is the MutationResult; add.mutate(vars) starts it. ``` That is the whole pattern. The rest of this page is about the three choices inside it: which keys, which callback, and whether the mutation waits. ## Which keys Invalidate the most general key the write could have affected. Adding a task changes every task list, so `['tasks']`; renaming one changes its detail and every list it appears in, so `['tasks']` again. A key factory — see [query invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md#keys-decide-what-an-invalidation-reaches) — turns this into a sentence about the domain. Sometimes a write makes one entry *meaningless* rather than stale. After a delete, refetching the device's detail would only fetch a 404; remove it instead, and invalidate the lists: ```dart // lib/data/device_mutations.dart MutationOptions removeDeviceMutation(QueryClient client) => MutationOptions.simple( mutationFn: devices.remove, onSuccess: (_, id, __) { // The device is gone: drop its detail rather than refetch a 404 … client.removeQueries( filters: QueryFilters(queryKey: DeviceKeys.detail(id)), ); // … and refetch every room list that may have shown it. return client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.lists), ); }, ); ``` `removeQueries` is for keys nobody is watching: a reader still attached keeps showing what it had. Pop the detail screen before the delete starts, or, if it has to stay up, invalidate its key instead and let it show the error. When a write touches two unrelated keys, invalidate both — in parallel: ```dart MutationOptions moveDeviceMutation( QueryClient client, Device device, ) => MutationOptions.simple( mutationFn: (String toRoom) => devices.move(device.id, toRoom), // Two lists changed. Both refetches run at once, and the mutation // settles when both have landed. onSuccess: (moved, toRoom, _) => Future.wait(>[ client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.list(room: device.room)), ), client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.list(room: toRoom)), ), ]), ); ``` ## `onSuccess` or `onSettled` - In **`onSuccess`**, only a write that went through refetches. That is right when a failed write provably changed nothing. - In **`onSettled`**, a failed write refetches too. Use it after an [optimistic update](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md), which has to replace its guess with the server's answer either way, and whenever a failure may have half-happened on the server — a timeout after the request arrived, a cancelled upload. ## Returning the future, or not A callback that **returns** the `invalidateQueries` future keeps the mutation `pending` until the refetch has landed. The button that shows a spinner while the mutation runs keeps it until the list on screen is actually up to date, and the new row never flickers in a moment after the spinner has gone. Most of the time that is what you want; when the refetch is slow, say so on screen, or it looks as if the write were slow. A callback that returns nothing — a block body, with `.ignore()` on the future — lets the mutation succeed as soon as the server has answered, and the refetch runs behind it: ```dart MutationOptions renameDeviceQuickly( QueryClient client, String id, ) => MutationOptions.simple( mutationFn: (String name) => devices.rename(id, name), // A block body that returns nothing: the mutation succeeds as soon as // the server has answered, and the lists refresh behind it. onSuccess: (_, __, ___) { client .invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.all), ) .ignore(); }, ); ``` Either way the invalidation happens. What you choose is which moment the UI calls "done". Press *Increment (mutate)* on the `mutations` screen: the mutation goes `pending`, then `success`, and the counter query its `onSuccess` invalidates refetches to the new value. Live demo: [Mutations](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/mutations), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutations)). mutate, mutateAsync, reset, callbacks, and scopes. ## Take the client in `build` The callbacks may run after the widget that started the mutation is gone — the user saved and navigated back. Close over the `QueryClient`, taken with `QueryClientProvider.of(context)` in `build` and passed into the options function, never over the `BuildContext`; see [mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md#a-mutation-outlives-its-widget). ## Every mutation, one rule When every write in an app follows the same rule — "invalidate what the mutation names" — it can live in one place, the mutation cache's `onSuccess`, with each mutation naming its keys in `meta`. See [global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md#invalidating-after-every-mutation). When the server answers the write with the new data, you can put that into the cache instead of refetching; see [updates from mutation responses](https://dualmeta-gmbh.github.io/query_kit/docs/guides/updates-from-mutation-responses.md). > **Note: In React Query** > > The same pattern: `queryClient.invalidateQueries` in `useMutation`'s > `onSuccess` or `onSettled`, and returning the promise to keep the mutation > pending. A Dart callback returns the `Future` the same way. --- # Updates from mutation responses > Write what a mutation returned straight into the cache — setQueryData, updateQueryData and updateQueriesData, immutable updates, and the one-key-one-type rule. Many endpoints answer a write with the object as it now is: a `PUT` returns the saved settings, a `PATCH` the renamed device. That answer is already the data the cache should hold — invalidating and fetching it again would be a second request for something you have in your hand. Write it into the cache in `onSuccess`, and every reader of that key shows it at once. ## A settings screen The notification settings of a user, read by a query and saved by a mutation. The server may clamp or fill in values, so what it answers is the truth, not what the form sent: ```dart // lib/data/settings_queries.dart final QueryKey settingsKey = QueryKey(['settings', 'notifications']); QueryObserverOptions settingsQuery() => QueryObserverOptions( queryKey: settingsKey, queryFn: (context) => settingsRepository.load(signal: context.signal), ); MutationOptions saveSettingsMutation(QueryClient client) => MutationOptions.simple( mutationFn: settingsRepository.save, // The server answers with what it stored — defaults applied, // values clamped. That is the new truth; there is nothing to // fetch. onSuccess: (saved, _, __) { client.setQueryData(settingsKey, saved); }, ); ``` The screen reads both and knows nothing about the cache: ```dart class NotificationSettingsTile extends StatelessWidget { const NotificationSettingsTile({super.key}); @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); final settings = context.query(settingsQuery()).dataOrNull; final save = context.mutation(saveSettingsMutation(client)); if (settings == null) return const LinearProgressIndicator(); return SwitchListTile( title: const Text('Push notifications'), subtitle: save.value.isError ? const Text('Could not save') : null, value: settings.pushEnabled, onChanged: save.value.isPending ? null : (on) => save.mutate(settings.copyWith(pushEnabled: on)), ); } } ``` A cache write marks the data fresh as of now, so it triggers no refetch of its own, and every other widget reading `settingsKey` — a badge in the app bar, a banner on the home screen — rebuilds with the saved value. ## The calls ```dart client.getQueryData>(tasksKey); client.setQueryData(taskKey(id), task); client.updateQueryData( taskKey(id), (previous) => previous?.copyWith(name: 'Renamed'), ); client.updateQueriesData( (previous) => previous?.copyWith(name: 'Renamed'), filters: QueryFilters(queryKey: tasksKey), ); ``` - `getQueryData` reads what is cached, or `null`. - `setQueryData` takes a **value** and returns what the cache stored, after [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md). - `updateQueryData` takes an updater from the previous value — `null` when nothing is cached — and returning `null` from it leaves the cache untouched. - `updateQueriesData` runs one updater over every query the filters match. It runs every updater and checks every result before writing any. For an infinite query, the typed read is `getInfiniteQueryData(key)`. A bare `setQueryData(key, null)` infers `Null` and writes nothing, as `undefined` does in TanStack Query; write `setQueryData(key, null)` to store a null in a query whose type allows one. ## One answer, several places A renamed device is in its detail entry and in its room's list. The answer updates both, the list by building a new one: ```dart MutationOptions renameDeviceMutation( QueryClient client, String id, ) => MutationOptions.simple( mutationFn: (String name) => devices.rename(id, name), onSuccess: (renamed, _, __) { client.setQueryData(DeviceKeys.detail(id), renamed); // Every room list that holds it gets a new list with the new device; // `null` leaves the others untouched. client.updateQueriesData>( (list) => list == null || !list.any((device) => device.id == id) ? null : [ for (final device in list) device.id == id ? renamed : device, ], filters: QueryFilters(queryKey: DeviceKeys.lists), ); }, ); ``` `updateQueriesData` over `DeviceKeys.lists` reaches every room's list, including rooms the device is not in. For those the updater returns `null`, which leaves the entry alone: writing back an equal list would still date the entry now and rebuild its readers. When the answer is only part of the picture — the device moved rooms, a count on another screen changed — [invalidate](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md) the rest rather than computing it on the client. ## Immutability The cache compares what you write with what it held, and tells readers only when something changed. So a cache write must be a **new value**, never the cached one edited in place: ```dart // Wrong: the cached list changes under every reader, and none is told. client.getQueryData>(key)?.add(device); // Right: a new list. The cache compares it, stores it and notifies. client.updateQueryData>( key, (list) => [...?list, device], ); ``` The first line changes the list every reader holds without telling any of them, so nothing rebuilds until some unrelated change comes along — and if the repository handed back a `const` or unmodifiable list, it throws. Build a new list, a `copyWith` of the model, a new map. The same goes for the value a query function returns: hand the cache something nobody else will mutate afterwards. The `playground` screen does this for real. Open a todo in its editor and rename it: the `PATCH`'s answer is written into the todo's own entry with `setQueryData`, so its strip shows no new fetch, and only the list is invalidated and refetched. Live demo: [Playground](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/playground), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/playground)). Todos with live knobs for stale time, gc time, latency and errors. > **Danger: One key, one exact type** > > A key is bound to the data type it was first used with, and reading it as any > other type throws `QueryDataTypeError` — **related types included**. `int` and > `int?` are two types. So are `List` and `List`. > `getQueryData`, `getQueriesData` and an observer's `TQueryData` all have > to agree with the key's first use. > > A **write** is the one place a related type is welcome. `setQueryData` infers > its type from the value (and so do `updateQueryData` and `updateQueriesData` > from the updater), so an entry that already exists takes any value its own > type can hold — a `String` into a `String?` query, a sealed type's variant > into a query of the sealed type — and keeps its type. Name the type when the > write *creates* the entry, as when seeding a key before its query exists: > `setQueryData>(key, [])`. > **Note: In React Query** > > `queryClient.setQueryData` in `onSuccess`, as there. The updater form is a > separate method here, `updateQueryData`, and TanStack Query's > `setQueriesData` is `updateQueriesData`. The immutability rule is the same. --- # Optimistic updates > Show a write before the server confirms it — drawn from the pending mutation's variables, or patched into the cache in onMutate and rolled back on error. A user adds a device and waits half a second for the list to show it; flips a switch and watches it spring back until the server agrees. Most writes succeed, so the app can show the result *before* the server has confirmed it, and put things right in the rare case it fails. That is an optimistic update, and there are two ways to do one: - **Via the UI** — draw the pending write from the mutation's `variables`, next to the cached data. The cache is never touched, so there is nothing to roll back. - **Via the cache** — patch the cached data in `onMutate`, before the request goes out, and restore it if the request fails. Every reader of the key sees the write. ## Via the UI While a mutation is pending, its result carries the variables it was started with. A widget that reads both the list and the mutation can draw the pending row itself: ```dart // lib/data/device_mutations.dart QueryKey addDeviceKey(String room) => QueryKey(['add-device', room]); MutationOptions addDeviceMutation( QueryClient client, String room, ) => MutationOptions.simple( mutationKey: addDeviceKey(room), mutationFn: (String name) => devices.add(name: name, room: room), // Returned, so the mutation stays pending — and its greyed row on // screen — until the list has refetched with the real row in it. onSettled: (_, __, ___, ____, _____) => client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.list(room: room)), ), ); ``` ```dart class RoomDeviceList extends StatelessWidget { const RoomDeviceList({super.key, required this.room}); final String room; @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); final list = context.query(roomDevicesQuery(room)); final add = context.mutation(addDeviceMutation(client, room)); return ListView( children: [ for (final device in list.dataOrNull ?? const []) DeviceTile(device: device), // The write in flight, drawn from what it was called with. if (add.value case MutationPending(:final variables?)) Opacity(opacity: 0.5, child: ListTile(title: Text(variables))), // A failed write keeps its variables: offer to send them again. if (add.value case MutationError(:final variables?)) ListTile( title: Text(variables), subtitle: const Text('Not saved'), trailing: TextButton( onPressed: () => add.mutate(variables), child: const Text('Retry'), ), ), AddDeviceField(onSubmit: add.mutate), ], ); } } ``` The greyed row is the mutation, not the data. When the write fails, the result turns into `MutationError` with the variables still on it, so the row becomes an error with a *Retry* that sends the same name again — no retyping. When it succeeds, `onSettled` invalidates the list, and because it **returns** that future, the mutation stays pending until the refetched list contains the real row: the greyed row and the real one never show together, and there is no gap between them. Every call style hands you the same `MutationResult`, so this works the same with `MutationBuilder`, `watchMutation` or a `MutationController`'s `value`; see the [four call styles for a mutation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md#in-an-app). ### When the list and the form are different widgets The pending row has to be drawn where the list is, and the mutation is often started somewhere else — a dialog, a bottom sheet. Give the mutation a `mutationKey`, and read every pending mutation under it from the cache with a [`MutationStateController`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md): ```dart class _PendingDevicesState extends State { // Every pending add for this room, wherever in the app it was started. late final MutationStateController _adding = MutationStateController.typed( QueryClientProvider.read(context), filters: MutationFilters( mutationKey: addDeviceKey(widget.room), status: MutationStatus.pending, ), select: (Mutation mutation) => mutation.state.variables!, ); @override void dispose() { _adding.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ValueListenableBuilder>( valueListenable: _adding, builder: (context, names, _) => Column( children: [ for (final name in names) Opacity(opacity: 0.5, child: ListTile(title: Text(name))), ], ), ); } ``` `typed` hands the selection the mutation with its variables typed, so no cast is needed; the filter's key picks this room's adds and nothing else. ## Via the cache When several widgets show the data — a list, a count in the app bar, a room overview — patching the cache shows the write everywhere at once. The work moves into the mutation's callbacks: 1. **`onMutate`** runs before the request. It cancels any fetch of the key in flight, patches the cache, and returns what the rollback will need. 2. **`onError`** receives that value as its last argument and puts the cache back. 3. **`onSettled`** invalidates the key, so the server has the last word whether the write succeeded or not. ### Adding to a list ```dart int _temporaryIds = 0; MutationOptions addDeviceOptimistically( QueryClient client, String room, ) { final key = DeviceKeys.list(room: room); return MutationOptions( mutationFn: (name) => devices.add(name: name, room: room), onMutate: (name) async { // A refetch already in flight would land after the patch and undo it. await client.cancelQueries(filters: QueryFilters(queryKey: key)); // Until the server names the device, a temporary id marks the row. final temporaryId = 'pending-${_temporaryIds++}'; client.updateQueryData>( key, (list) => [ ...?list, Device(id: temporaryId, name: name, room: room), ], ); return temporaryId; // what onSuccess and onError need to find the row }, onSuccess: (device, _, temporaryId) { // Swap in the server's device at once; the refetch below confirms it. client.updateQueryData>( key, (list) => list == null ? null : [ for (final row in list) row.id == temporaryId ? device : row, ], ); }, onError: (error, stackTrace, name, temporaryId) { // Take out this row only: another add may be in flight beside it. client.updateQueryData>( key, (list) => list?.where((row) => row.id != temporaryId).toList(), ); }, onSettled: (_, __, ___, ____, _____) => client.invalidateQueries(filters: QueryFilters(queryKey: key)), ); } ``` The **`cancelQueries` first is not optional.** A refetch already in flight left before the write; if it lands after the patch, it writes the old list over it and the new row vanishes until the next fetch. Cancelling it (with its default `revert: true`) puts the query back as it was before that fetch, so the patch is the last word until `onSettled`'s invalidation fetches again. The rollback here removes the one row this mutation added rather than restoring a snapshot of the whole list. That matters as soon as two adds can be in flight: restoring the first one's snapshot when it fails would also erase the second one's row. ### Updating one item For a change to one entry, the classic shape — snapshot, patch, restore — is exactly right, because nothing else writes that entry in the meantime: ```dart MutationOptions renameOptimistically( QueryClient client, String id, ) => MutationOptions( mutationFn: (name) => api.rename(id, name), onMutate: (name) async { await client.cancelQueries( filters: QueryFilters(queryKey: taskKey(id)), ); final previous = client.getQueryData(taskKey(id)); client.updateQueryData( taskKey(id), (task) => task?.copyWith(name: name), ); return previous; // the rollback handle }, onError: (error, stack, name, previous) { if (previous != null) client.setQueryData(taskKey(id), previous); }, onSettled: (_, __, ___, ____, _____) => client.invalidateQueries( filters: QueryFilters(queryKey: taskKey(id)), ), ); ``` Whatever `onMutate` returns reaches `onSuccess`, `onError` and `onSettled` as their last argument, typed by the options' third type argument. If `onMutate` itself throws, the mutation fails without running its function, and that argument is `null`. The `optimistic-updates` screen shows both shapes on one todo list. Pick *Via variables* or *Via cache*, type a todo and press *Add*; then tick *Refuse next write* and add another. Via variables, the refused row turns into an error with a *Retry*; via cache, it appears, disappears again and the card says *Rolled back*. Live demo: [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/optimistic-updates), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/optimistic_updates)). Show the write before the server answers — two ways. ## When to use which | | Via the UI | Via the cache | |---|---|---| | Where the write shows | where the mutation is read (or a `MutationStateController` looks) | in every reader of the key | | On failure | the row turns into an error; nothing to undo | `onError` has to undo the patch | | Code | a few lines in one widget | three callbacks, and a way to find what you wrote | | Good for | one list, one form | data shown in several places, toggles that must not flicker | Start with the UI shape; move to the cache when a second widget needs to see the write before the server confirms it. ## Settling, retries and scopes Whichever shape you use, invalidate in `onSettled`: success or failure, the server has the final word, and the refetch replaces the guess with it. See [invalidations from mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md). A mutation does not retry by default. If you turn retries on, the optimistic state stays on screen through them — `onMutate` runs once per mutation, not per attempt. Mutations in a [scope](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-scopes.md) run their `onMutate` when they are submitted, not when their turn comes — so, again, roll back the row a mutation changed rather than restoring a whole-list snapshot. The `playground` screen is the other half of the argument: its adds and renames are not optimistic. Set *Latency* to *2 s* and add a todo — the wait between pressing and seeing is what an optimistic update removes. Live demo: [Playground](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/playground), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/playground)). Todos with live knobs for stale time, gc time, latency and errors. > **Note: In React Query** > > The same two shapes: the UI one reads `variables` from `useMutation` (or > `useMutationState` from another component), the cache one uses `onMutate`, > `onError` and `onSettled`. What `onMutate` returns is called the *context* in > TanStack Query and `onMutateResult` here. --- # Mutation scopes > MutationScope runs the mutations that share it one at a time, in the order they were started — for writes to the same thing that must not race. Mutations run in parallel by default. Two writes to *different* things racing each other is harmless. Two writes to the *same* thing are not: a user taps a light switch on, then off, and the two requests leave a few milliseconds apart. Nothing guarantees they arrive in that order. If *off* lands first, the light ends up on while the switch on screen says off. A scope fixes that. Mutations in the same scope run **one at a time**, in the order they were started: ```dart MutationOptions serialisedWrite(String id) => MutationOptions.simple( mutationFn: (String name) => api.rename(id, name), scope: const MutationScope('task-writes'), ); ``` ## One scope per thing A constant id serialises every write of that kind across the whole app. That is sometimes what you want — one sync queue, one upload at a time — but it also makes a write to one row wait for a write to another. For "writes to the same row must not race", build the id from the row: ```dart MutationOptions setPowerInOrder( QueryClient client, String id, ) => MutationOptions.simple( mutationFn: (bool on) => devices.setPower(id, on: on), // Every write to this device waits for the one before it; writes to // other devices do not wait for it. scope: MutationScope('device-$id'), onSuccess: (device, _, __) { client.setQueryData(DeviceKeys.detail(id), device); }, ); ``` Now *on* then *off* on the hallway light always reach the hub in that order, and switching the kitchen light meanwhile does not wait for either of them. A scope's id is compared with `==`, so any value with value equality works; a string is the usual choice. Mutations without a scope never wait for anything. ## What waits, and what does not Only the mutation **function** waits for its turn. Everything else happens when the mutation is started: - **`onMutate` runs at once**, before the writes ahead of it have landed. A snapshot it takes, a patch it applies or a `cancelQueries` it calls happens at that moment. For an [optimistic update](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md) in a scope, roll back the row this mutation changed rather than restoring a whole-list snapshot — the snapshot may already contain the patch of a mutation queued ahead of it. - **A queued mutation is `pending` and reports `isPaused`**, as one waiting for the network does. A spinner that shows for `isPending` shows for it too; show "queued" for `isPaused` if the difference matters to the user. - **The scope is held until the running mutation has settled** — its `onSettled` future included. An invalidation returned from `onSettled` therefore finishes before the next write in the scope starts. Until then the mutation is still `pending`, and `client.isMutating()` counts it, inside its own `onSettled` too. - **The per-call callbacks** passed to `mutate` run after the state has moved on, and do not hold the scope. A failed mutation hands the scope on like a successful one; so does a [cancelled one](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md). The writes behind it run anyway — if they depended on it, check in their own function or cancel them. The `mutations` screen has two buttons for this. *Run two unscoped* starts two slow writes at once and both are pending together; *Run two scoped* starts the same two in one scope, and the second shows paused until the first has finished. Live demo: [Mutations](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/mutations), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutations)). mutate, mutateAsync, reset, callbacks, and scopes. ## When not to use a scope A scope makes the user wait for the network one write at a time. When the last write is the only one that matters — a text field saved as the user types, a slider — sending every intermediate value in order is slow and pointless. Debounce the input and send the latest value instead, or cancel the write in flight before starting the next. > **Note: In React Query** > > `scope: { id: 'device-42' }` on `useMutation`. The scope here is a value > class, `MutationScope('device-42')`, and the rules are the same: only the > function waits, a queued mutation is paused, and mutations without a scope > run in parallel. --- # Cancelling mutations > mutationFnWithContext, the signal cancel() cancels, and why cancelling a write fails it rather than reverting it. A firmware upload takes a minute; the user picks the wrong file and wants to stop it. A long export should stop when the user closes its dialog. A write can be cancelled — but what cancelling a write *means* is different from what it means for a query, and this page is about that difference. `mutationFn` takes the variables and nothing else. When the function needs to know about its run — to abort its request, or to read what `onMutate` kept — give `mutationFnWithContext` instead: the same function with a second argument. ```dart MutationOptions renameWithContext( QueryClient client, String id) => MutationOptions( onMutate: (name) { final before = client.getQueryData(taskKey(id))!; client.setQueryData(taskKey(id), before.copyWith(name: name)); return before; }, // The cache already says `name`. What it said before is in the context, // and so is the signal `cancel()` cancels. mutationFnWithContext: (name, context) => api.rename( id, name, from: context.onMutateResult?.name, signal: context.signal, ), onError: (_, __, ___, before) { if (before != null) client.setQueryData(taskKey(id), before); }, onSettled: (_, __, ___, ____, _____) => client.invalidateQueries( filters: QueryFilters(queryKey: taskKey(id)), ), ); ``` The context holds `client`, `meta` and `mutationKey`, as in TanStack Query, and two more: - **`onMutateResult`**, typed. `onMutate` runs *before* the function, so after an optimistic patch the cache no longer says what was there. A function that compares "before" with "wanted" and reads the cache will find no difference and send nothing. What `onMutate` kept is the answer. - **`signal`**, cancelled by `cancel()` — on the controller, the observer or the `Mutation`. ## Cancelling is failing `cancel()` fails the run with a `CancelledError`: no further retry, `onError` and `onSettled` run, and the scope moves on. So the rollback you already wrote rolls it back, and the invalidation you already wrote finds out what the server really did — which nobody can know otherwise, because the request may have arrived. That is why it is not the quiet return to the previous state that cancelling a *query* is: a write has no previous state to return to. - A function that honours the signal aborts its transport; one that does not runs on unobserved, and its result is discarded. - `mutateAsync` throws that `CancelledError` at its call site like any other failure, so a `mutateAsync` nobody awaits needs a handler (or use `mutate`, which has the controller hold the error instead). - Only `cancel()` cancels the signal: removing a mutation from the cache or disposing its controller leaves an attempt in flight to settle. - A mutation that is paused, queued behind its scope or still in `onMutate` fails the same way without its function ever running. So does one restored `pending` from persistence that has not been resumed yet. - Once the function has returned, `cancel()` does nothing: the write went through. One function per mutation — both at once fails an assertion at the options literal in a debug build, and is an `ArgumentError` when the client resolves them in a release build — and a function registered with `setMutationDefaults` has no context form. ## A firmware update, cancellable The button starts the upload, turns into *Cancel update* while it runs, and after a cancel offers to try again. The mutation passes its signal on to the repository: ```dart MutationOptions firmwareUploadMutation( QueryClient client, String id, ) => MutationOptions.simple( mutationFnWithContext: (image, context) => devices.uploadFirmware(id, image, signal: context.signal), // Cancelled or not, ask the device what it is running now. onSettled: (_, __, ___, ____, _____) => client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.detail(id)), ), ); class FirmwareUpdateButton extends StatelessWidget { const FirmwareUpdateButton({ super.key, required this.deviceId, required this.image, }); final String deviceId; final Uint8List image; @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); final upload = context.mutation(firmwareUploadMutation(client, deviceId)); return switch (upload.value) { MutationPending() => OutlinedButton( onPressed: upload.cancel, child: const Text('Cancel update'), ), MutationError(error: CancelledError()) => FilledButton( onPressed: () => upload.mutate(image), child: const Text('Update cancelled — try again'), ), _ => FilledButton( onPressed: () => upload.mutate(image), child: const Text('Install update'), ), }; } } ``` Cancelling fails the run, so `onSettled` still invalidates the device's detail — the device may have received half an image and rebooted, or all of it, and only asking it tells you which. The `CancelledError` in the result is what lets the button tell "the user stopped it" from "it failed". The repository hands the signal to its HTTP client. With dio, a `CancelToken` bridges the two: ```dart // lib/data/device_repository.dart Future uploadFirmware( String id, Uint8List image, { required QueryCancelToken signal, }) async { final token = CancelToken(); signal.onCancel(token.cancel); final response = await dio.put>( '/devices/$id/firmware', data: Stream.fromIterable([image]), options: Options(headers: {Headers.contentLengthHeader: image.length}), cancelToken: token, ); return Device.fromJson(response.data!); } ``` With the `http` package, send an `AbortableRequest` whose `abortTrigger` completes from `signal.onCancel`. A repository that ignores the signal still works: its request runs on unobserved, and whatever it returns is discarded. The `mutation-cancel` screen holds each rename on the server for three seconds. Type a new title, press *Rename*, then *Cancel* before the three seconds are up: the result reads `error=cancelled`, the optimistic title rolls back to the old one, and the refetch shows what the server kept. Live demo: [Mutation context and cancel](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/mutation-cancel), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutation_cancel)). A write that reads what onMutate kept, and can be called off. > **Note: In React Query** > > TanStack Query cannot cancel a mutation: `useMutation` has no `cancel`, and > its `mutationFn` receives no signal. `mutationFnWithContext` and `cancel()` > are additions of this library, built so that a cancel runs the error path you > already wrote. --- # Mutation state > MutationStateController and MutationStateObserver — every mutation matching a filter, for a "saving…" badge no widget owns, and its typed form. `MutationStateController` (and the core's `MutationStateObserver`) reads *every* mutation matching a filter through a `select` — the counterpart of `useMutationState`. It is how a "saving…" badge in an app bar works without any widget owning the mutation. Concurrent runs under one key are kept apart. ```dart final saving = MutationStateController( client, filters: const MutationFilters(status: MutationStatus.pending), select: (mutation) => 1, ); // saving.value.length is "how many writes are in flight" ``` `client.isMutating()` is the count alone, without a subscription. ## A "saving…" indicator in the app bar A mutation belongs to the widget that asked for it, so the app bar cannot read it. It can read the mutation *cache*, which holds every mutation wherever it was started. The indicator below counts the writes in flight — the power switches, the renames, a firmware upload — and knows none of them: ```dart // lib/widgets/saving_indicator.dart — in the app bar, owning no mutation. class _SavingIndicatorState extends State { late final MutationStateController _saving = MutationStateController( QueryClientProvider.read(context), filters: const MutationFilters(status: MutationStatus.pending), select: (mutation) => mutation.mutationId, ); @override void dispose() { _saving.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ValueListenableBuilder>( valueListenable: _saving, builder: (context, running, _) => running.isEmpty ? const SizedBox.shrink() : Text('Saving ${running.length}…'), ); } ``` Selecting the `mutationId` rather than a constant keeps each run apart in the list, and the list is compared with the previous one before the controller notifies: a cache event that leaves the same mutations pending does not rebuild the indicator. A widget that wants to know *what* is being saved selects the variables instead, as below. Finished mutations stay in the mutation cache until their `gcTime` runs out after their last observer is gone, so a filter without a `status` sees recent successes and failures too — useful for a "2 changes could not be saved" banner, which filters on `MutationStatus.error`. The `mutation-state` screen puts such a badge above a todo list. Press *Add todo* twice quickly: the badge counts `saving=2` while both run and drops to zero when they settle. *Add, failing* ends in `failed=1` instead, and `badge-builds` only moves when the selection actually changed. Live demo: [Mutation state](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/mutation-state), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutation_state)). Every running mutation in the cache, read by a widget that owns none of them. ## One type of mutation A filter spans mutations of every type, so `select` receives them erased. When you want the mutations of *one* type, `typed` filters by it and hands them over typed — the pending variables as an optimistic display, without a cast: ```dart MutationStateController pendingRenames(QueryClient client) => MutationStateController.typed( client, filters: const MutationFilters(status: MutationStatus.pending), // The parameter's type is the filter: every mutation whose variables // are a String, and `variables` needs no cast. select: (Mutation mutation) => mutation.state.variables!, ); ``` Three things to know, because an empty list after a filter looks harmless: - The filter is the mutation's **declared** type arguments, not the runtime type of its variables. A mutation built from options whose types were never written or inferred is a `Mutation` and is not a `Mutation`, whatever it was called with — it drops out silently. Options with a typed `mutationFn` infer correctly; check the ones assembled from pieces. - The type is the controller's for its life: a later `setOptions` may replace the filters or the select, and the selection still sees only mutations of that type (a new select receives them erased, as the untyped one does). A filter's `predicate` runs after the type test, so it too sees only mutations of that type and may read the declared type. - A typed selection does not replace an untyped one where the mutations are mixed on purpose: "is *any* write in flight?" over a scope that holds two variable types is still one untyped controller, next to the typed one. > **Note: In React Query** > > `MutationStateController` is `useMutationState({ filters, select })`, and > `client.isMutating()` is `useIsMutating` without the subscription. The typed > form has no counterpart there: in TypeScript `select` receives the mutation > untyped and you cast. --- # Filters > QueryFilters and MutationFilters — the one named filters argument every bulk operation takes, what each field matches, and how to match a query yourself. Every operation that acts on many queries at once — invalidating, refetching, resetting, removing, cancelling, counting — takes its target as a named `filters:` argument. One value type describes "which queries", so the call sites read the same everywhere: ```dart client.invalidateQueries(filters: QueryFilters(queryKey: tasksKey)).ignore(); client.removeQueries( filters: QueryFilters(queryKey: tasksKey, exact: true), ); client .refetchQueries(filters: QueryFilters(type: QueryTypeFilter.active)) .ignore(); client .resetQueries( filters: QueryFilters(predicate: (query) => query.isStale())) .ignore(); ``` Leaving the filters out matches everything. ## `QueryFilters` | Field | Matches | |---|---| | `queryKey` | keys that start with this one — a **prefix** | | `exact` | with `true`, only the key itself | | `type` | `QueryTypeFilter.active` (read by at least one enabled observer), `inactive` or `all` (the default) | | `stale` | stale (`true`) or fresh (`false`) queries | | `fetchStatus` | `FetchStatus.fetching`, `paused` or `idle` | | `status` | `QueryStatus.pending`, `error` or `success` | | `predicate` | anything else: a function from the `Query` to `bool` | Every field given must match; a field left out matches anything. The operations that take them: `invalidateQueries`, `refetchQueries`, `resetQueries`, `removeQueries`, `cancelQueries`, `isFetching`, `getQueriesData`, `updateQueriesData`, and the query cache's `findAll`. `isFetching` is the one exception to "every field given must match": it always counts queries that are fetching right now, so a `fetchStatus` passed to it is ignored rather than combined. In an app, the key factory does most of the work, and the other fields cut the set down: ```dart // Every device query, lists and details alike. await client.invalidateQueries( filters: QueryFilters(queryKey: DeviceKeys.all), ); // The kitchen's list only — `exact` stops the prefix match. await client.refetchQueries( filters: QueryFilters( queryKey: DeviceKeys.list(room: 'kitchen'), exact: true, ), ); // Device details nobody is showing: dropped, not refetched. client.removeQueries( filters: QueryFilters( queryKey: DeviceKeys.details, type: QueryTypeFilter.inactive, ), ); // Every query whose last fetch failed, whatever its key. await client.refetchQueries( filters: const QueryFilters(status: QueryStatus.error), ); // Only what has gone stale, for a pull-to-refresh that skips fresh data. await client.refetchQueries( filters: QueryFilters(queryKey: DeviceKeys.all, stale: true), ); // How many device requests are out right now — for a spinner. final loading = client.isFetching( filters: QueryFilters(queryKey: DeviceKeys.all), ); ``` ### The predicate `predicate` runs last, after every other field has matched, and receives the whole `Query` — its key, its state, its options' `meta`. That makes it the place for rules that cut across keys. A query can carry a tag in `meta`, and sign-out drops everything so tagged, whatever it is called: ```dart // At sign-out, drop every query tagged as the user's own, whatever its key. client.removeQueries( filters: QueryFilters( predicate: (query) => switch (query.meta) { {'personal': true} => true, _ => false, }, ), ); ``` Give the predicate a `queryKey` alongside it when you can, so it is asked about fewer queries. ### One asymmetry `queryCache.find` — one query, by key — defaults to an **exact** match, as you would expect from a lookup. The bulk operations default to a **prefix**, as you would expect from "everything about devices". Pass `exact: true` to a bulk operation when you mean one key. ## `MutationFilters` The same idea over the mutation cache: `mutationKey` (a prefix unless `exact`), `exact`, `status` and `predicate`. `mutationCache.findAll(filters: …)` returns the mutations they match, and [mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md) reads them as a listenable. `client.isMutating(filters: …)` counts only the matching mutations that are pending: a `status` passed to it is ignored, as `isFetching` ignores `fetchStatus`. ```dart // Adds still out in any room — `['add-device']` is a prefix. final adding = client.isMutating( filters: MutationFilters(mutationKey: QueryKey(['add-device'])), ); // Every write that failed, for a "retry all" banner. final failed = client.mutationCache.findAll( filters: const MutationFilters(status: MutationStatus.error), ); ``` A mutation without a `mutationKey` is matched only by filters that name no key. ## Matching a query yourself A filter is a value with a public `matches(query)`. That is useful outside the bulk operations — in a cache listener that should only log what happens to one part of the cache, for instance: ```dart final QueryFilters kitchenLists = QueryFilters( queryKey: DeviceKeys.list(room: 'kitchen'), ); void Function() logKitchen(QueryClient client) => client.queryCache.subscribe((event) { if (kitchenLists.matches(event.query)) debugPrint('kitchen: $event'); }); ``` `MutationFilters` has the same `matches(mutation)`. The `invalidation-and-filters` screen runs these fields against a small cache. Tick *Fail post 2 next*, press *Refetch post 2*, then *Predicate: errored*: only the failed post is invalidated. *Refetch stale only* refetches the posts and leaves the todos alone, which are fresh for thirty seconds. Live demo: [Invalidation and filters](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/invalidation-and-filters), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/invalidation_and_filters)). Invalidate, refetch, reset and remove, by prefix, type or predicate. > **Note: In React Query** > > The same fields, with two spellings changed: `type: 'active'` is > `QueryTypeFilter.active`, and the filters are always a named `filters:` > argument rather than the first positional one. `matches` is TanStack Query's > exported `matchQuery` and `matchMutation`. --- # Request waterfalls > When one request cannot start before another has answered — where waterfalls come from in a widget tree, and how hoisting, prefetching and flatter reads avoid them. A waterfall is a request that could have started earlier but waited for another one to finish. Each step adds a full round trip, and on a phone network a round trip is what the user waits for. Three requests of 300 ms each take 300 ms side by side and 900 ms in a row. The library does not create waterfalls, and it cannot remove them either: they come from where the widgets that read the queries sit in the tree. This page is about spotting them and flattening them. ## Where they come from - **Nested widgets.** A parent reads its query and shows a spinner; only when its data arrives does it build the child, and only then does the child's query start. Neither request needed the other, but the tree made them wait. - **Dependent queries.** The second query really does need the first one's answer — the readings of a device whose id comes from a scan. That one is in the data, not in the tree; see [dependent queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/dependent-queries.md). - **Navigation.** The detail screen's query starts when its route is built, which is after the tap, after the transition has begun. - **Code loaded on demand.** A deferred library on the web loads first, then the screen in it builds, then its query starts. ## Nested widgets A device screen shows the device, and under it a chart of its energy use. The chart is its own widget with its own query — which is good design — and it is only built once the device has arrived: ```dart class DeviceScreen extends StatelessWidget { const DeviceScreen({super.key, required this.id}); final String id; @override Widget build(BuildContext context) { final device = context.query(deviceQuery(id)); return switch (device) { QueryPending() => const Center(child: CircularProgressIndicator()), QueryError(:final error) => Center(child: Text('$error')), QuerySuccess(:final data) => Column( children: [ Text(data.name), // Built only once the device has arrived — and only then does // the chart's own query start. EnergyChart(deviceId: id), ], ), }; } } ``` The requests go out one after the other: ```text device |--------> energy |--------> ``` The energy query did not need the device. It only waited because the widget that reads it is built inside the success branch. There are two fixes. ### Read both where the parent reads Queries read in the same `build` start together. Read the chart's query in the screen, and hand the chart its result: ```dart class HoistedDeviceScreen extends StatelessWidget { const HoistedDeviceScreen({super.key, required this.id}); final String id; @override Widget build(BuildContext context) { // Both read in the same build: both requests start now, side by side. final device = context.query(deviceQuery(id)); final energy = context.query(energyQuery(id)); return switch (device) { QueryPending() => const Center(child: CircularProgressIndicator()), QueryError(:final error) => Center(child: Text('$error')), QuerySuccess(:final data) => Column( children: [ Text(data.name), EnergyChartView(energy), ], ), }; } } ``` ```text device |--------> energy |--------> ``` The chart has become a plain widget that takes a `QueryResult`, which also makes it easier to test. The cost is that the screen now knows what the chart reads. ### Prefetch in the parent When the child should keep its own query, the parent can start the same request early without reading it, and the child joins it when it is built: ```dart class _PrefetchingDeviceScreenState extends State { @override void initState() { super.initState(); // The chart below will read this. Start it now, beside the device's own // request; the chart joins it, or finds the answer cached. QueryClientProvider.read(context).query(energyQuery(widget.id)).ignore(); } @override Widget build(BuildContext context) { final device = context.query(deviceQuery(widget.id)); return switch (device) { QueryPending() => const Center(child: CircularProgressIndicator()), QueryError(:final error) => Center(child: Text('$error')), QuerySuccess(:final data) => Column( children: [ Text(data.name), EnergyChart(deviceId: widget.id), ], ), }; } } ``` `client.query(...).ignore()` starts the fetch and forgets about it. When the chart is built, its `context.query` finds the fetch in flight and joins it, or finds the answer cached. Both widgets name the query through the same options function, `energyQuery(id)`, which is what keeps the two in step: the same key, the same function, the same `staleTime`. ## Dependent queries When the second request needs the first one's answer, some waiting is unavoidable — but it is often less than it looks. Ask whether the server can answer the second question from what you *already* have: a device's readings by the device id you navigated with, rather than by a sensor id that only the device's detail contains. If it can, the dependency disappears and both requests run side by side. If it cannot, [dependent queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/dependent-queries.md) shows how to chain them with `enabled`. ## Navigation The detail screen's queries cannot start before the screen is built — unless someone else starts them. The row the user tapped knows exactly what the next screen will read, so it can prefetch on tap, before the push, or earlier still, on hover on the web and desktop. A route guard or a router's `redirect` can do the same for deep links. [Prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md) has the samples. ## Code loaded on demand A `deferred as` import on the web downloads the library the first time `loadLibrary()` is called, and the screen in it builds only afterwards. Start the screen's query next to `loadLibrary()` rather than inside the screen: the query's options live in your data layer, which is not deferred, so the two downloads run side by side. ## Seeing it The `prefetching` screen shows the difference a head start makes. Open a post from the list without prefetching it: the detail waits for its own request. Press a row's prefetch button first (tooltip *Prefetch post N*), wait for its *prefetched* pill, then open it: the title is there at once and no request is made. Live demo: [Prefetching](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/prefetching), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/prefetching)). Warm the cache before the screen that needs it opens. ## Summary | Waterfall | Fix | |---|---| | a child's query starts when the parent's data arrives | read both in the parent, or prefetch in the parent | | the second query needs the first's answer | ask the server differently, or chain with `enabled` | | the screen's query starts after navigation | prefetch on tap, hover or in the route guard | | a deferred screen loads, then fetches | start the query beside `loadLibrary()` | Two queries the server could answer in one response are the last kind: if they always go together, one endpoint and one query beat two. > **Note: In React Query** > > The same guide exists there, with Suspense and lazy components as the usual > culprits. Flutter has neither, so the cases here are nested widgets, > navigation and deferred imports, and the fixes are the same: hoist the read, > or prefetch with `queryClient.prefetchQuery` — `client.query(...).ignore()` > here. --- # Prefetching > client.query fetches imperatively — await it, ignore it to prefetch on tap, hover or in a route redirect, fetch only when nothing is cached, or serve the cache and revalidate behind it. If you know a screen is about to need data, fetch it before the screen asks. By the time a widget reads the key, the data is in the cache — or its fetch is already running, and the widget joins it. The user sees the screen with its content instead of a spinner. Everything imperative is one method on the client, `client.query`: ```dart final tasks = await client.query>( QueryOptions>( queryKey: tasksKey, queryFn: (context) => api.listTasks(signal: context.signal), ), ); ``` | You want | Write | |---|---| | fetch and await | `await client.query(options)` | | prefetch, don't wait | `client.query(options).ignore()` | | only if nothing is cached | `staleTime: StaleTime.static` | | cached data now, refresh behind it | `client.query(options, revalidateIfStale: true)` | `client.query` fetches only when the entry is missing or stale by the options' `staleTime` — zero unless you set one. Fresh data comes back without a request, so prefetching the same key twice within its `staleTime` costs nothing. A call makes one attempt unless the options configure `retry`, and an error is thrown to the caller; `.ignore()` drops it, which is what a prefetch wants — the screen will fetch and show the error itself. `client.query` also accepts `InfiniteQueryOptions` — with nothing cached, it fetches the first page — and `client.infiniteQuery` is the typed form; see [infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md). ## Prefetch on intent The best moment is the moment the user shows what they are about to do. In a device list, that is a pointer resting on a row on the web and desktop, and the tap on a phone: ```dart class DeviceTile extends StatelessWidget { const DeviceTile({super.key, required this.device}); final Device device; @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); void prefetch() { client.query(deviceQuery(device.id)).ignore(); client.query(energyQuery(device.id)).ignore(); } return MouseRegion( // Web and desktop: a pointer resting on the row is intent enough. onEnter: (_) => prefetch(), child: ListTile( title: Text(device.name), onTap: () { // Touch has no hover. Start both requests before the push, so the // chart's does not wait for the device to arrive first. prefetch(); Navigator.of(context).push( MaterialPageRoute( builder: (context) => DeviceScreen(id: device.id), ), ); }, ), ); } } ``` The tile prefetches with the **same options function** the detail screen reads with — `deviceQuery(id)` and `energyQuery(id)` — so the key, the function and the `staleTime` cannot drift apart. On the tap, both requests leave before the push, and the transition animation covers part of the wait. A pointer that sweeps across twenty rows asks for twenty devices, but only once each within the `staleTime`. > **Tip: Give the reader a `staleTime`** > > Prefetched data is only useful if the screen that reads it accepts it. With > the default `staleTime` of zero, the cached device is stale the moment it > lands, so the detail screen shows it at once *and* refetches it on mount. > That is still better than a spinner, but it is two requests where one would > do. The device queries here are fresh for ten seconds. ## Prefetch in a route A deep link or a push notification opens the detail screen directly, with no row to tap. The router is the one place that knows where the user is going. With go_router, start the fetch in the route's `redirect`, which runs before the screen is built: ```dart GoRouter buildRouter(QueryClient client) => GoRouter( routes: [ GoRoute( path: '/devices/:id', redirect: (context, state) { final id = state.pathParameters['id']!; client.query(deviceQuery(id)).ignore(); client.query(energyQuery(id)).ignore(); return null; // no redirect — only a head start }, builder: (context, state) => DeviceScreen(id: state.pathParameters['id']!), ), ], ); ``` Build the router with the client it prefetches into, as you would pass it to a `QueryClientProvider`. An `async` redirect that *awaits* `client.query` holds the navigation until the data is there — use that when a screen without its data makes no sense, and remember that an error then has to be handled in the redirect. With `Navigator` alone, prefetch next to the `push`, as the tile above does. ## Prefetch in a parent A parent that knows its child will read a query can start it while it loads its own, so the two requests do not wait for each other. See [request waterfalls](https://dualmeta-gmbh.github.io/query_kit/docs/guides/request-waterfalls.md#prefetch-in-the-parent). ## Seed from what you have Sometimes the data is already in the cache under another key. A room's list holds every field the detail screen shows, so the list can seed each detail when it arrives: ```dart // The room list already holds every device's fields: seed each detail, so // opening one shows it at once. for (final device in list) { client.setQueryData(DeviceKeys.detail(device.id), device); } ``` Opening a device then shows it at once, and its own `staleTime` decides whether the detail refetches. When the list holds only part of the detail, use it as the detail's [`placeholderData`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md) instead, which is shown but never cached as the detail's data. ## Cached data now, fresh data soon `revalidateIfStale: true` is stale-while-revalidate as an imperative call: whatever the cache holds comes back at once, even if it is stale, and a stale entry refreshes behind the call. Only with nothing cached is the fetch awaited: ```dart // The kitchen's devices at once, even a stale list; a stale entry // refreshes behind this call. Nothing cached: the fetch is awaited. final kitchen = await client.query( roomDevicesQuery('kitchen'), revalidateIfStale: true, ); ``` It is the right call for a background task or a service that needs *some* answer now and wants the cache to catch up — it fails only when nothing is cached and the fetch fails. The `prefetching` screen prefetches from a button on each row rather than on hover, so it works the same with a mouse and a finger. Press a row's prefetch button (tooltip *Prefetch post N*): the row gets a *prefetched* pill; open it, and the title is there without a request. The last card contrasts the imperative reads of a counter: press *Read (await)* once so a value is cached, then *Increment on the server* and *Read (revalidateIfStale)* — it shows the old value at once and the new one lands behind it. *Read (await)* waits for a fresh value instead, and *Read (static)* returns whatever is cached without a request. Live demo: [Prefetching](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/prefetching), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/prefetching)). Warm the cache before the screen that needs it opens. Prefetched data is cached like any other: it is garbage-collected after `gcTime` if no one reads it, and it is stale after `staleTime`. ## What `client.query` joins `client.query` **joins** a fetch already in flight for its key rather than starting another — so a call right after your write may hand back what the running fetch brings, and a cancelled fetch that reverts resolves it with the reverted data. Use `refetchQueries` for a fetch that starts after your write. The options it is given become the query's, as an observer's do: an explicit `retry` in them is the policy a later invalidation or focus refetch of that query uses too, until an observer or another call hands in its own. Only the no-retry default, for a call that configured none, is limited to that one fetch. > **Note: In React Query** > > TanStack Query has `fetchQuery`, `prefetchQuery`, `ensureQueryData` and > `usePrefetchQuery` for these; here they are the rows of one table on one > method. `prefetchQuery` is `client.query(options).ignore()`, > `ensureQueryData` is `staleTime: StaleTime.static` (or `revalidateIfStale: > true` for its `revalidateIfStale` option), and `prefetchInfiniteQuery` is > `client.infiniteQuery(options).ignore()`. --- # Caching > How long data is fresh (StaleTime), how long an unobserved entry is kept (GcTime), and what happens to a query from its first read to garbage collection. Two options decide the life of a cache entry: how long its data counts as **fresh**, and how long the entry is **kept** once nothing reads it. Most of what the library does without being asked — refetching on mount, on focus, on reconnect, dropping old data — follows from those two numbers. ## A query's life, with the defaults Take a room screen that reads the kitchen's devices, with the defaults — `StaleTime.zero` and a five-minute `GcTime`: 1. **The first read.** `RoomScreen('kitchen')` builds and reads `roomDevicesQuery('kitchen')`. Nothing is cached under `['devices', 'list', 'kitchen']`: a new entry is created, the result is `QueryPending`, and the query function runs. 2. **The data arrives** and is cached under that key. The screen rebuilds with `QuerySuccess`. With `StaleTime.zero`, the data is **stale at once** — the cache keeps it, but no longer vouches for it. 3. **A second reader.** A kitchen badge on the home tab reads the same options. It gets the cached list on its first frame — no spinner — and, because the data is stale, a refetch runs behind it. It is one request for both readers, and both get its answer when it lands — the same list instance as before if nothing in it changed (see [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md)). 4. **The readers go.** The user leaves the room screen and the home tab. The entry has no observers any more, so it becomes **inactive** and its **garbage-collection timer** starts: five minutes. 5. **Back within five minutes.** The user opens the kitchen again. The screen shows the cached list on its first frame and refetches behind it, because it is stale. The collection timer is cancelled — the entry has a reader. 6. **Gone for five minutes.** Nobody reads the kitchen for five minutes: the entry is dropped. The next read starts again at step 1, with a spinner. Two things follow. A stale entry is not a problem to be avoided: it is shown at once and refreshed behind the reader, which is the whole point of the cache. And "refetch behind it" only happens on the triggers — a new reader, [app focus](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md), reconnect, an [invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md) — never on a timer unless you ask for [polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md). ## Choosing the numbers The defaults assume that data may have changed the moment it arrived. For most apps that is too cautious: it refetches every time a tab is switched. Give each query the freshness its data really has: ```dart // lib/data/device_queries.dart QueryObserverOptions> roomDevicesQuery(String room) => QueryObserverOptions( queryKey: DeviceKeys.list(room: room), queryFn: (context) => devices.list(room: room, signal: context.signal), // Switching between room tabs within ten seconds costs no request. staleTime: const StaleTime.duration(Duration(seconds: 10)), ); QueryObserverOptions deviceQuery(String id) => QueryObserverOptions( queryKey: DeviceKeys.detail(id), queryFn: (context) => devices.get(id, signal: context.signal), staleTime: const StaleTime.duration(Duration(seconds: 10)), ); QueryObserverOptions> energyQuery(String id) => QueryObserverOptions( queryKey: DeviceKeys.detail(id).append(['energy']), queryFn: (context) => devices.energy(id, signal: context.signal), // Hourly readings: a minute old is new enough. staleTime: const StaleTime.duration(Duration(minutes: 1)), ); QueryObserverOptions> firmwareChannelsQuery() => QueryObserverOptions( queryKey: QueryKey(['firmware-channels']), queryFn: (context) => devices.firmwareChannels(signal: context.signal), // Changes with a server release, not while the app runs: fetch it once // and keep it for the session. staleTime: StaleTime.static, gcTime: GcTime.never, ); ``` The question to ask per query is "how old can this be before the user would notice or care?" A light's state that someone else may switch: seconds. Hourly energy readings: a minute. A list of firmware channels that changes with a server release: never, for the life of the app. When most of the app agrees, set the numbers once on the client and override them per query: ```dart // lib/main.dart final QueryClient appClient = QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults( staleTime: StaleTime.duration(Duration(seconds: 20)), gcTime: GcTime.duration(Duration(minutes: 10)), ), ), ); ``` ## Staleness `StaleTime` decides whether cached data counts as fresh. Fresh data is returned without a fetch; stale data is returned *and* refetched behind it — on mount, on [app focus](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md) and on reconnect. | | | |---|---| | `StaleTime.zero` | stale immediately — the default | | `StaleTime.duration(d)` | fresh for `d` after it was fetched | | `StaleTime.infinite` | never stale by time, still refetched when explicitly asked | | `StaleTime.static` | never stale **and**, while an observer holds the query, skipped by every refetch trigger — mount, focus, reconnect, `invalidateQueries`, `refetchQueries` — but not by an observer's own `refetch()`, and not by an explicit `refetchInterval`. An entry nobody observes is refetched by `invalidateQueries` and `refetchQueries` like any other | | `StaleTime.dynamic((query) => …)` | computed per query, from its current state | `StaleTime.static` is the "fetch this once, ever" option. A dynamic stale time is asked several times per operation; keep its function cheap and free of side effects. An [invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md) marks data stale whatever its `staleTime` says — except `StaleTime.static`, which an invalidation does not make stale. So `StaleTime.infinite` is the choice for data that only your own writes change: it never refetches by itself, and invalidating it after a write still works. ## Garbage collection `GcTime` is how long an entry with no observers is kept before it is dropped. | | | |---|---| | `GcTime.duration(d)` | drop `d` after the last observer leaves | | `GcTime.defaultValue` | five minutes | | `GcTime.never` | keep for the life of the client | An entry keeps the **longest** `gcTime` any reader has given it. A screen that reads a key with ten minutes and a badge that reads it with one minute leave an entry that is kept for ten, whichever went last. Garbage collection frees memory; it has nothing to do with freshness. A long `gcTime` with a short `staleTime` is common and useful: the data comes back instantly when the user returns, and is refreshed as it does. The opposite — a `gcTime` shorter than the time a user spends away — means a spinner on every return. A client owns its garbage-collection timers, which is why a widget test has to [clear it](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md) before the test ends. ## Seeing it The `stale-and-gc` screen has one entry and a knob for every `StaleTime` and `GcTime` value. Pick *GC time* 5 s and press *Detach reader* (the icon with that tooltip): the entry has no observers, and five seconds later it is gone; *Attach reader* starts again from nothing. With *Stale time* set to `static`, *Invalidate* fetches nothing, while *Refetch* — the reader's own request — still does. Live demo: [Stale time and garbage collection](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/stale-and-gc), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/stale_and_gc)). When data goes stale, and when an unused entry is dropped. The `cache-inspector` screen lists every entry of the cache, with its status and its observer count. Press *Load posts* and *Load todos*, then untick *Keep readers*: the observer counts drop to zero, and five seconds later the rows disappear as their entries are collected. Live demo: [Cache inspector](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/cache-inspector), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/cache_inspector)). Every entry and every event, live. For the same view of your own app, see [debugging](https://dualmeta-gmbh.github.io/query_kit/docs/guides/debugging.md). > **Note: In React Query** > > `staleTime` and `gcTime`, with the same defaults and the same lifecycle. The > values are sealed types here instead of numbers: `staleTime: Infinity` is > `StaleTime.infinite`, `'static'` is `StaleTime.static`, a function is > `StaleTime.dynamic`, and `gcTime: Infinity` is `GcTime.never`. --- # What rebuilds, and when > select narrows what a widget reads; buildWhen narrows when it rebuilds. They are not the same tool. The rule is TanStack Query's: **a widget rebuilds whenever its result changes** — and a background refetch that brings back *equal* data is still a change, because `dataUpdatedAt` moved. Two tools narrow that down, and they do different jobs. Reaching for the wrong one is the most common way to be surprised here. ## `select` narrows what a widget reads A `select` runs at the observer. A fetch that brings back data whose *selection* is equal keeps the previous selected value — same instance, so `data` is unchanged and anything compared on it (a `buildWhen` over `dataOrNull`, a child keyed on the data) sees no change. ```dart QuerySelectOptions, int> doneCountQuery() => QuerySelectOptions( queryKey: tasksKey, queryFn: (context) => api.listTasks(signal: context.signal), select: (tasks) => tasks.where((s) => s.done).length, ); ``` What `select` does **not** narrow is the rest of the result. A widget is handed a `QueryResult`, and `fetchStatus`, `failureCount` and `dataUpdatedAt` are part of it — and of its `==`. A background refetch that returns identical data still moves `dataUpdatedAt` (and `fetchStatus` through `fetching` and back), and that is a changed result — so the widget **does** rebuild, with an unchanged `data`. If you want no rebuild at all, `buildWhen` is the tool. `select` is the tool for *what a widget reads*. `buildWhen` is the tool for *when it rebuilds*, and only the second one can ignore a metadata change. ### Equal by value For the part `select` does control, equality is `==`: - a `select` returning a **fresh list** every call is fine — lists are shared element by element; - a fresh instance of a class **with** `==`/`hashCode` is fine; - a fresh instance of a class **without** value equality is a different value every time, so every fetch reaches the widget as a change. Give such a model `==`, or select a list or a scalar. Dart **records** already have value equality, which makes them the easy pick: ```dart select: (data) => ( done: data.where((s) => s.done).length, total: data.length, ), ``` ## `buildWhen` On every builder — `QueryBuilder`, `QuerySelectBuilder`, `InfiniteQueryBuilder`, `MutationBuilder` — and on every keyless read: `watchQuery`, `watchSelectQuery`, `watchInfiniteQuery`, `watchMutation`, `context.query`, `context.selectQuery`, `context.infiniteQuery`, `context.mutation`. ```dart QueryBuilder( options: taskQuery(id), buildWhen: (previous, current) => previous.dataOrNull != current.dataOrNull, builder: (context, result) => /* … */, ) ``` ```dart final task = context.query( taskQuery(id), buildWhen: (previous, current) => previous.dataOrNull != current.dataOrNull, ); ``` It is the counterpart of `notifyOnChangeProps`, expressed as a function of two results. The same predicate and the same semantics in all twelve places — the difference is only *whose* rebuild it decides. A builder's is its own subtree, because a builder reads exactly one query or one mutation. A keyless read's is per read, and its reader is the whole widget or the whole `State`: several reads each filter their own query, and a change any one of them lets through rebuilds the reader. > **Note: `previous` is what was built, not what was seen** > > `previous` is the result the reader last **built**, not the last one it saw. A > result `buildWhen` skipped is not remembered, so the next comparison is against > what is actually on screen. > > It is the **opposite** of > `bloc`'s `buildWhen`, where `previous` is the last state emitted whether or not > it was built. An equal result is skipped before the predicate is even asked, so `buildWhen` only ever sees a real change. The one exception is an infinite query, whose paging flags live beside the result: a fetch that moves only those rebuilds regardless, because there is nothing there for a predicate over results to compare and the reader is showing the stale half. ### On a mutation The same predicate over a `MutationResult`, and it is the *only* narrowing a mutation reader has: there is no `select` on a mutation. ```dart final rename = context.mutation( renameTask(id), // A retrying run moves `failureCount` while it stays pending; a spinner // does not care which attempt it is on. buildWhen: (previous, current) => previous.status != current.status, ); ``` It is also asked more often than a query's. A `MutationObserver` drops a notification whose result is equal before it sends one at all, so every notification a mutation reader gets reaches its predicate. ### A controller has none, on purpose No controller takes a `buildWhen` — not `QueryController`, `InfiniteQueryController` or `MutationController`. A controller **is** the notifier: a predicate on it would impose one listener's filter on every listener of it. What it gives instead is the guarantee underneath — it notifies only when something a reader can see has actually moved, so a `ValueListenableBuilder` over one never rebuilds for a notification carrying what it is already showing, not even for the fetch its own subscription started. Past that, a controller is a `ValueListenable`, so filtering is composition: hold the last value your listener acted on and compare, or wrap it in whatever your state-management package offers for a listenable. ### `QueriesBuilder` has none either A collection has no one result to filter on: a predicate over a whole `List` would fire for any query in the list and say nothing about which. A reader who wants per-query filtering has it already, by reading each query with its own `QueryBuilder` or `context.query`, each with its own `buildWhen`. It makes the other half of the guarantee: the collection notifies only when a result in it actually moved, compared element by element. ## In an app A lamp badge on each room tab shows how many lights are on. It reads the room's device list — the same options the room screen reads, so one request serves both — selects a count, and rebuilds only when the count moves: ```dart class RoomBadge extends StatelessWidget { const RoomBadge({super.key, required this.room}); final String room; @override Widget build(BuildContext context) { // Reads the room's list, keeps a count. A device renamed, or a refetch // that changes nothing, leaves the count — and this badge — alone. final on = context.selectQuery( roomDevicesQuery(room).withSelect(_countOn), buildWhen: (previous, current) => previous.dataOrNull != current.dataOrNull, ); return Badge( label: Text('${on.dataOrNull ?? 0}'), child: const Icon(Icons.lightbulb_outline), ); } } // Top-level, so the options compare equal from one build to the next. int _countOn(List list) => list.where((device) => device.isOn).length; ``` `withSelect` turns the room's plain options into select options without restating the key and the function. The `select` alone keeps `data` the same `int` across a rename or a refetch that changes nothing; the `buildWhen` also ignores the `fetchStatus` and `dataUpdatedAt` that every refetch moves. The room screen next to it, which shows those names, rebuilds on a rename; the badge does not. Pass a top-level function or a static method as `select`, not a closure written inline: a new closure each build is a new option, and the observer runs the new `select` again over data it has already selected. ## Why not track which fields were read React Query tracks which fields of the result a component touched during a render (`trackedProps`) and re-renders only when one of those changed. That needs a proxy around the result and a render pass it can observe; a Flutter widget reads its result in `build` with no such seam. So whole results are compared instead, and a widget rebuilds where React Query sometimes would not. `buildWhen` is the explicit form of the same thing. See [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). ## Build-aware delivery Results are delivered right away outside a build — a tap handler or a resolved future is where Flutter expects a `setState`, and one `pump` in a test shows the new result — and **after** the build when they arrive inside one, so a query resolving during a build can never call `setState` into it. That covers the frame's build phase and the app's very first build, which `runApp` runs outside any frame. ## Seeing it The `select-and-sharing` screen reads one entry five times, with a different `select` each, and counts every reader's builds and its *data builds* — the builds whose selected value changed. Press *Refetch*: the data comes back equal, every reader's *data builds* stays put, and only the builder with a `buildWhen` does not rebuild at all. *Toggle todo 1* changes what some selections see and not others. Tick *Structural sharing off* and refetch: the reader without a `select` and the one whose selection is a new list each time now count a data change for equal data. Live demo: [Select and structural sharing](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/select-and-sharing), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/select_and_sharing)). What a reader rebuilds on, and what it does not. The `build-when` screen is this page's other half: each of the eight keyless reads is made twice over one entry — once with a predicate, once without — so what the predicate costs and saves is the difference between two counters. Press *Refetch the posts* and watch the unfiltered counters move while the filtered ones stay; set the *buildWhen* knob to *never* to freeze the filtered half, and to *always* to make it its twin again. Live demo: [Filtering rebuilds](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/build-when), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/build_when)). buildWhen on the eight keyless reads, each beside its unfiltered twin. What `select` keeps depends on [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md). > **Note: In React Query** > > `select` is the same option. `notifyOnChangeProps` and tracked properties > decide there which result fields re-render a component; `buildWhen` is that > decision written as a function of the previous and the current result, on > every builder and every keyless read. --- # Structural sharing > A refetch that brings back equal data keeps the cached instances — what is shared, why a model needs ==, StructurallyShareable, and turning it off. On by default, and it is what makes `select` and `buildWhen` worth having: if a refetch brings back data equal to what is cached, the *same instances* are kept, so `==` downstream stays true and nothing rebuilds unnecessarily. Lists are shared element by element; maps and sets are kept whole when deeply equal; everything else is compared with `==`. **A typed model therefore needs `==` and `hashCode`** — without them every fetch produces a new value. ## A class of your own **A class of your own is a leaf, including what is inside it.** A wrapper around a list — `TaskList(items)`, the shape freezed suggests — is compared with `==` and kept or replaced *whole*: when one task changes, all of them get new instances. Nothing looks wrong, because `==` still holds; `identical` and everything built on it is what goes. Hold the list itself in the cache, or let the class take part by implementing `StructurallyShareable`: ```dart @immutable class TaskList implements StructurallyShareable { const TaskList(this.items); final List items; // Asked only when the two are not equal: keep every Task instance the // cache already holds, and replace the ones that changed. @override TaskList shareWith(TaskList previous) => TaskList(replaceEqualDeep(previous.items, items)); @override bool operator ==(Object other) => other is TaskList && listEquals(other.items, items); @override int get hashCode => Object.hashAll(items); } ``` Returning `previous` itself is right exactly when nothing changed. A class without value equality is never `==` to its predecessor, even with the same content, so the walk asks on every refetch, and handing `previous` back is the only way it keeps its instance: ```dart // No value equality: two TaskFeeds are never ==, so the walk always asks. class TaskFeed implements StructurallyShareable { TaskFeed(this.items); final List items; @override TaskFeed shareWith(TaskFeed previous) { final shared = replaceEqualDeep(previous.items, items); return identical(shared, previous.items) ? previous : TaskFeed(shared); } } ``` Nothing checks the contract, in debug builds or release: for a class whose `==` is not deep, the walk cannot tell a correct `previous` from a mistaken one. So there are two ways to get `shareWith` wrong, both silent. Returning `previous` — or anything not equal in content to `this` — when something did change puts stale data in the cache. Returning an equal value that shares nothing — a plain copy — is correct and useless: the saving is gone without a sound. Measure it once: after a refetch that changed one element, the others should be `identical` to what was there before, and after one that changed nothing, the whole value should be. (A hook that throws is ignored, and the incoming value kept.) It is found wherever the walk goes — at the top, in a list, in an `InfiniteData` page — so one implementation replaces a `structuralSharing` hook on every query that holds the type. ## Generated models Most apps do not write `==` by hand. What the generators give you decides what sharing can do: - **json_serializable alone** generates `fromJson` and `toJson` and nothing else. A model with only those has identity equality, so every fetch is a change to every reader. Add `==` and `hashCode`, or generate them. - **freezed** generates a deep `==` and `hashCode`, so a freezed model is an equal leaf: kept when a refetch brings back the same content, replaced when it does not. That is all a model of scalar fields needs. - **A freezed class wrapping a list** — a page of results with a cursor — is still a leaf, and one changed item costs every item its instance. Implement `StructurallyShareable` on it; freezed allows it with a private constructor: ```dart @freezed abstract class Device with _$Device { const factory Device({ required String id, required String name, required String room, required bool isOn, }) = _Device; factory Device.fromJson(Map json) => _$DeviceFromJson(json); } @freezed abstract class DevicePage with _$DevicePage implements StructurallyShareable { const DevicePage._(); const factory DevicePage({ required List items, String? nextCursor, }) = _DevicePage; factory DevicePage.fromJson(Map json) => _$DevicePageFromJson(json); // Asked only when the pages are not equal: keep every Device the cache // already holds, and take the new ones. @override DevicePage shareWith(DevicePage previous) => copyWith(items: replaceEqualDeep(previous.items, items)); } ``` Because freezed's `==` is deep, `shareWith` is asked only when something did change, so building a new page from the shared items is always right. The list freezed hands out is unmodifiable, so what `replaceEqualDeep` rebuilds from it is fixed-length (see below), and the `copyWith` wraps it unmodifiable again. ## Maps, sealed lists and sets "Kept whole" cuts both ways: a `Map` in which one entry changed is replaced whole, and every entry's instance with it — Dart cannot rebuild a map of your key and value types from inside the walk, as it can a list. If you cache normalised by id and rely on instance identity, cache a list, or share the map yourself in a `structuralSharing` hook. A rebuilt list is growable only if the one that came in was. A list that cannot grow — fixed-length or unmodifiable — is rebuilt as a **fixed-length** list when a cached instance is swapped into it: keeping those instances is the point of sharing, and Dart cannot build an unmodifiable list of your element type from inside the walk. `add` and `remove` throw on it; `list[i] = x` does not. When nothing is swapped in, your sealed list is stored as it came. If the cache must hold a sealed list in every case, seal it in your own `structuralSharing` hook, where the element type is known. A set is compared by its members' `==` and `hashCode`, never by the comparator or `equals:` it was built with, so a case-insensitive set still reports `'Alpha'` becoming `'alpha'`. That costs about a millisecond and a half a write for 10 000 members and roughly a frame for 100 000; for a set that large, `noStructuralSharing()` skips the comparison. ## Turning it off To turn it off — `structuralSharing: false` in TanStack Query: ```dart structuralSharing: noStructuralSharing(), // `false` in TanStack Query ``` That turns sharing off everywhere: for the cache write, for placeholder data and for what `select` produces, so a selector that returns a fresh list every time then counts as a change on every fetch. A function of your own, `(previous, next) => …`, replaces the default for the cache write and placeholder data only. It cannot be handed a selection — it is typed for the query's data, and a selection can be another type — so what `select` produces is still shared by the default comparison. That is also true of `(_, next) => next`: it keeps every write, but it is not the opt-out, and a selection over it stays shared. Use `noStructuralSharing()` when you mean off. ## Seeing it The `select-and-sharing` screen reads one todo list five times and counts each reader's builds. Press *Refetch*: the list comes back equal, the cached instances are kept, and no reader counts a data change. Tick *Structural sharing off* and press *Refetch* again: the list in the cache is a new instance each time, so the reader without a `select` and the one whose `select` builds a list count a change for the same content, while the ones selecting a number, a string or a record do not. Live demo: [Select and structural sharing](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/select-and-sharing), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/select_and_sharing)). What a reader rebuilds on, and what it does not. See [what rebuilds, and when](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md) for how `select` and `buildWhen` build on this. > **Note: In React Query** > > The same algorithm, `replaceEqualDeep`, and the same `structuralSharing` > option: `false` there is `noStructuralSharing()` here, and a function is a > function. JavaScript compares plain objects by their fields; Dart has no plain > objects, so a model takes part through its `==` or `StructurallyShareable`. --- # Default query function > Register a queryFn per key prefix with setQueryDefaults, so options without one derive the request from the key — its types, its mutation twin, and client-wide defaults. When every request follows one pattern — a REST path built from the key, say — there is no need to write a function per query. Register one for a key prefix, and every query under that prefix without a `queryFn` of its own uses it: ```dart final client = QueryClient( defaultOptions: DefaultOptions( queries: QueryDefaults( staleTime: const StaleTime.duration(Duration(seconds: 30)), retry: const RetryPolicy.times(2), ), ), ); client.setQueryDefaults( QueryKey(['tasks']), QueryDefaults(queryFn: (context) => api.listTasks()), ); ``` The function reads the key it is fetching from `context.queryKey`, so one function can serve a whole family of keys. ## The key is the request Take it one step further, and let the key *be* the request: its second part the path, its third the query parameters. One function, registered once when the app starts, serves every GET of the API: ```dart // In main(), once: every key under ['api'] is a GET of the path it names. // ['api', '/devices', {'room': 'kitchen'}] is GET /devices?room=kitchen. client.setQueryDefaults( QueryKey(['api']), QueryDefaults( queryFn: (context) { final parts = context.queryKey.parts; return api.getJson( parts[1]! as String, query: parts.length > 2 ? parts[2]! as Map : const {}, signal: context.signal, ); }, ), ); ``` The `api` client behind it is whatever the app already uses — with dio, a thin wrapper: ```dart class ApiClient { ApiClient(this._dio); final Dio _dio; Future getJson( String path, { Map query = const {}, QueryCancelToken? signal, }) async { final token = CancelToken(); signal?.onCancel(token.cancel); final response = await _dio.get( path, queryParameters: query, cancelToken: token, ); return response.data; } } ``` A query is then nothing but its key — and, because the function hands back raw JSON, a `select` that parses it: ```dart // lib/data/device_queries.dart — no queryFn: the key is the request. QuerySelectOptions> apiRoomDevices(String room) => QuerySelectOptions( queryKey: QueryKey([ 'api', '/devices', {'room': room}, ]), select: parseDevices, ); // A top-level function, so a rebuild hands in an equal select and the // parsed list is kept rather than parsed again. List parseDevices(Object? json) => [ for (final item in json! as List) Device.fromJson(item! as Map), ]; ``` Because the parameters are part of the key, two rooms are two cache entries, and invalidating `['api', '/devices']` reaches every room's list. ## Name the types A default query function is registered for many keys, so its type is erased to `Object?`. The query that uses it states what it expects, and the client checks the function's answer against it: an answer that is not the query's data type fails the fetch with a `QueryDataTypeError`. - When the default returns a **typed value** — `api.listTasks()` returns a `List` — write the type on the options: `QueryObserverOptions>(queryKey: …)`. Without a type argument and without a `queryFn` to infer it from, the query's type is `dynamic`; see [type safety in Dart](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md). - When the default returns **raw JSON**, as above, the cached type is `Object?` and the parsing is a `select`: `QuerySelectOptions>`. The cache holds the JSON, every reader gets the typed list, and a top-level `select` function parses once per fetch rather than once per build. ## Mutations too `setMutationDefaults` is the same for mutations: a function registered for a mutation key prefix, used by every mutation under it without a function of its own. ```dart client.setMutationDefaults( QueryKey(['api', 'add-device']), MutationDefaults( mutationFn: (body) => api.postJson('/devices', body), ), ); ``` ```dart MutationOptions, void> addDeviceByKey() => MutationOptions.simple( mutationKey: QueryKey(['api', 'add-device'])); ``` The mutation's function is erased the same way, and its answer is checked against the mutation's data type — `Object?` here. A default mutation function has no context form, and mutation defaults carry no callbacks: `onSuccess` and the rest stay on the mutation's own options, or go on the [mutation cache](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md). ## Two levels of defaults - `QueryClient(defaultOptions: DefaultOptions(queries: …, mutations: …))` applies to every query or mutation of the client. - `client.setQueryDefaults(key, QueryDefaults(...))` applies to every query whose key starts with `key`, and sits above the client-wide default. An option set on the query itself wins over both. When several registered prefixes match one key — `['api']` and `['api', '/devices']` — their defaults merge in the order they were registered, the later one winning field by field. Registering the same key again replaces its defaults. `setMutationDefaults` is the matching call for mutations. The `default-query-function` screen registers such a function for its `['api', …]` keys and has no `queryFn` anywhere. Its queries each cost one request; press *Fetch a missing post* and the key's path answers with the backend's 404, shown as the query's error; *Create a todo* runs a mutation with only a key, which posts once. Live demo: [Default query function](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/default-query-function), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/default_query_function)). A query function derived from the key, set once as a default. > **Note: In React Query** > > TanStack Query's example sets `queryFn` in the client's `defaultOptions`; the > same works here, and `setQueryDefaults` narrows it to a key prefix. The type > check has no counterpart there: a TypeScript default function's answer is > trusted as whatever the query declared. --- # Global callbacks > QueryCache and MutationCache callbacks run for every query or mutation of a client — one error SnackBar for the whole app, a per-query opt-out through meta, and one invalidation rule for every write. Some reactions belong to every query or every mutation: a SnackBar when a background refresh fails, a log line for every failed write, an error report to your crash service. Writing them into each widget repeats them and, worse, runs them once per *reader* — ten widgets watching a failed query would show ten SnackBars. Put them on the caches instead, once, when the client is built: ```dart final GlobalKey messengerKey = GlobalKey(); QueryClient clientWithErrorToasts() => QueryClient( queryCache: QueryCache( onError: (error, stackTrace, query) { // A first load shows its own error; only a failed background // refresh of data already on screen deserves a toast. if (!query.state.hasData) return; // A query can opt out through its meta. if (query.meta case {'silent': true}) return; messengerKey.currentState?.showSnackBar( SnackBar(content: Text('Could not refresh: $error')), ); }, ), mutationCache: MutationCache( onError: (error, stackTrace, variables, onMutateResult, mutation) { messengerKey.currentState?.showSnackBar( SnackBar(content: Text('Could not save: $error')), ); }, ), ); ``` ## Reaching the UI from the cache The client outlives every screen, and its callbacks run when a fetch finishes, not while a widget builds — there is no `BuildContext` to hand them. A `GlobalKey` is the bridge: the app hands it to `MaterialApp`, and the callback shows its SnackBar through it, over whatever screen is up at that moment: ```dart // lib/main.dart class DevicesApp extends StatelessWidget { const DevicesApp({super.key, required this.client, required this.home}); final QueryClient client; final Widget home; @override Widget build(BuildContext context) => QueryClientProvider( client: client, child: MaterialApp( // The key the cache's onError shows its SnackBar through. scaffoldMessengerKey: messengerKey, home: home, ), ); } ``` The same goes for a navigator key (to send the user to the sign-in screen on a 401) or for your own notification service. ## What each cache offers | | Callbacks | |---|---| | `QueryCache` | `onSuccess(data, query)`, `onError(error, stackTrace, query)`, `onSettled(data, error, stackTrace, query)` | | `MutationCache` | `onMutate(variables, mutation)`, `onSuccess(data, variables, onMutateResult, mutation)`, `onError(error, stackTrace, variables, onMutateResult, mutation)`, `onSettled(data, error, stackTrace, variables, onMutateResult, mutation)` | When they run: - **Once per fetch**, not once per reader and not once per retry: ten widgets reading a query whose fetch failed after three attempts produce one `onError`. That is the difference from reacting in a widget — see [side effects](https://dualmeta-gmbh.github.io/query_kit/docs/guides/side-effects.md) for the per-reader form. - **For fetches only.** A `setQueryData` is not a fetch and runs nothing, and neither does a cancelled fetch that reverts the query to where it was. - **The cache's first.** A mutation's cache callback runs before the mutation's own callback of the same name, and the per-call callbacks passed to `mutate` run after both; see [mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md). A mutation cache callback may return a future, which is awaited: the mutation stays `pending` until it completes. A query cache callback that throws does not change the fetch: the error is reported to the zone, where your crash reporting sees it. On the mutation side, a throw from `onMutate`, `onSuccess` or `onSettled` on the way to success fails the mutation, as a throw from its own callbacks would; a throw on the error path is reported to the zone and does not replace the error the caller is waiting for. ## Opting out with `meta` `meta` is any value — usually a map — on a query's or a mutation's options that the library never reads. The global callbacks can, through `query.meta` and `mutation.meta`, which makes it the way for one query to say "not me" to a rule that holds for all the others. Above, a query with `meta: {'silent': true}` fails without a SnackBar — a background poll of a status light, say, whose failures the user does not need to hear about. It works the other way round too: a rule that applies only to queries that ask for it, such as `meta: {'toast': 'Could not load the energy chart'}` carrying its own message. ## Invalidating after every mutation When every write in an app follows the same rule — "invalidate the keys the mutation names" — the rule can live in one place, the mutation cache's `onSuccess`, and each mutation names its keys in `meta`: ```dart QueryClient clientInvalidatingByMeta() { late final QueryClient client; client = QueryClient( mutationCache: MutationCache( onSuccess: (data, variables, onMutateResult, mutation) async { // A mutation names the keys it makes stale; the cache does the rest. if (mutation.meta case {'invalidates': final List keys}) { await Future.wait(>[ for (final key in keys) client.invalidateQueries(filters: QueryFilters(queryKey: key)), ]); } }, ), ); return client; } MutationOptions renameDevice(String id) => MutationOptions.simple( mutationFn: (String name) => devices.rename(id, name), meta: { 'invalidates': [DeviceKeys.all], }, ); ``` Because the callback awaits the invalidation, each mutation stays `pending` until the refetch has landed, as it would with the invalidation in its own `onSuccess`. A mutation with no `invalidates` entry is left alone. The client is `late` because the cache is built before the client it belongs to, and the callback only runs after both exist. ## Seeing it The `global-callbacks` screen runs on a client of its own whose caches log every callback as one line. Press *Fetch a missing post*: the query fails, the log shows `query error post-999 (meta: toast)`, and a SnackBar says *Post not found* — the meta decided it. *Create todo* logs `mutation mutate`, `mutation success`, `option onSuccess`, `mutation settled` and `option onSettled`, in that order: the cache's callback before the option's, each time. Live demo: [Global callbacks](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/global-callbacks), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/global_callbacks)). Cache-level callbacks, and meta on its way through. > **Note: In React Query** > > `new QueryCache({ onError, onSuccess, onSettled })` and > `new MutationCache({ ... })`, as there — the callbacks take the stack trace > as well here. TanStack Query v5 removed `onSuccess` and `onError` from > `useQuery`; this library never had them on a query, for the same reason: they > ran once per reader. --- # Debugging > There are no devtools — what the cache can tell you instead, by subscribing to its events, reading its entries, or putting a small inspector on screen. "Why did this refetch?", "is this still cached?", "who is holding that query?" — in React, the devtools panel answers these. query_kit has no devtools package, but everything such a panel shows is public: every entry of the cache, its state, its readers, and an event for every change. This page turns that into a log line, a small on-screen inspector, and a list of what the library throws when something is wrong. ## Listening to the cache `client.queryCache.subscribe` calls you back with a `QueryCacheEvent` for every change and returns the function that unsubscribes. Put it next to the client, in `lib/main.dart`, under `kDebugMode`: ```dart void Function() logCacheEvents(QueryClient client) => client.queryCache.subscribe((event) { final key = event.query.queryKey.debugString; switch (event) { case QueryAdded(): debugPrint('added $key'); case QueryRemoved(): debugPrint('removed $key'); case QueryUpdated(): final state = event.query.state; debugPrint('$key: ${state.status} / ${state.fetchStatus}'); default: break; } }); ``` The events, each carrying the `query` it is about: | Event | When | |---|---| | `QueryAdded` | an entry is created — by a reader, `client.query` or `setQueryData` | | `QueryRemoved` | an entry leaves the cache — garbage collection, `removeQueries`, `clear` | | `QueryUpdated` | the entry's state changed; its `action` says how (a fetch started, succeeded, failed, paused, was invalidated, …) | | `QueryObserverAdded` / `QueryObserverRemoved` | a reader subscribed or left | | `QueryObserverOptionsUpdated` | a reader was handed options that are not `==` to its last ones — with a `queryFn` closure written in the options, that is **every build** of that reader | | `QueryObserverResultsUpdated` | a reader delivered a new result to its listeners | The last two are about readers, not about the cache, and the first of them usually fires on every rebuild: leave both out of a log, or it grows with nobody touching the screen. `client.mutationCache.subscribe` is the same for mutations, with `MutationAdded`, `MutationRemoved`, `MutationUpdated`, `MutationObserverAdded`, `MutationObserverRemoved` and `MutationObserverOptionsUpdated` — the last one only once the reader has run a mutation; there is no results event on the mutation side. The full lists, with the actions, are on [caches and observers](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md). Only a log? The cache-wide callbacks are shorter: `QueryCache(onError: …)` runs once per fetch that fails for good, whichever widget asked. See [global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md). ## Reading the cache What an inspector reads, all public: - `client.queryCache.queries` — every entry right now. - `query.state` — `status`, `fetchStatus`, `dataUpdatedAt`, `dataUpdateCount`, `error`, `fetchFailureCount`, `consecutiveErrorCount`, `isInvalidated`. - `query.observersCount` — how many readers hold it. An entry with none is waiting for garbage collection, `gcTime` after its last reader left. - `query.isStale()` — whether the next trigger would refetch it. - `query.options` — the options it runs with, every default filled in. - `key.debugString` — the key, readable. - `client.isFetching()` and `client.isMutating()` — how much is in flight. ## A small inspector A widget that lists the cache and rebuilds on its events, to drop into a debug drawer or behind a long-press. It lives in your app, for instance in `lib/debug/cache_inspector.dart`: ```dart /// Every entry of the query cache, one line each. For debug builds. class CacheInspector extends StatefulWidget { const CacheInspector({super.key}); @override State createState() => _CacheInspectorState(); } class _CacheInspectorState extends State { QueryClient? _client; void Function()? _unsubscribe; bool _rebuildPending = false; @override void didChangeDependencies() { super.didChangeDependencies(); final client = QueryClientProvider.of(context); if (client == _client) return; _unsubscribe?.call(); _client = client; _unsubscribe = client.queryCache.subscribe((event) { // About the readers, not the cache — and the first fires on every // reader's build. if (event is QueryObserverOptionsUpdated || event is QueryObserverResultsUpdated) { return; } _rebuildAfterFrame(); }); } /// An event can arrive while a frame is being built, when `setState` is /// not allowed; rebuild once that frame is done, however many came. void _rebuildAfterFrame() { if (_rebuildPending) return; _rebuildPending = true; SchedulerBinding.instance ..addPostFrameCallback((_) { _rebuildPending = false; if (mounted) setState(() {}); }) ..scheduleFrame(); } @override void dispose() { _unsubscribe?.call(); super.dispose(); } @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final query in _client!.queryCache.queries) Text( '${query.queryKey.debugString} ' '${query.state.status.name}/${query.state.fetchStatus.name} ' 'observers=${query.observersCount} ' 'stale=${query.isStale()}', ), ], ); } ``` It reads the cache directly and holds no observer, so it never keeps an entry alive or triggers a fetch — what it shows is what the cache holds. The *Cache inspector* example is a fuller version: a table of every entry with *Refetch*, *Invalidate* and *Remove* buttons, the mutations, and the event log. Press *Load posts*, then *Invalidate* on its row and watch the log; switch on *Keep readers* and see `observers` go to one, then off again and watch the entry disappear five seconds later. Live demo: [Cache inspector](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/cache-inspector), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/cache_inspector)). Every entry and every event, live. ## What the library throws Some mistakes fail loudly rather than showing up as wrong data: - **`QueryDataTypeError`** — a key holds exactly one type, and reading or writing it as another throws at the call. The message names the key, the type asked for and the type held. Usually two options functions share a key; see [type safety in Dart](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md#one-key-one-exact-type). - **`MissingQueryFunctionError`** and **`MissingMutationFunctionError`** — a query or mutation ran with no function and no default registered for its key. They become the error state, and are not retried. - **Assertions in debug builds** — for example, `context.query` read through a `ListView.builder`'s item context. Each message says what to do instead. In the *Diagnostics* example, press *Read as String* and *Write a String* against an `int` entry, then *Mutate without a function*, and *Register a default mutationFn* to see the same mutation succeed. Live demo: [Diagnostics](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/diagnostics), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/diagnostics)). What the library throws, and when: the wrong type, the missing function. Every error, with when it is thrown, is on the [errors reference](https://dualmeta-gmbh.github.io/query_kit/docs/reference/errors.md). > **Note: In React Query** > > The React devtools (`@tanstack/react-query-devtools`) have no counterpart > here. The events they are built on are the same: `queryCache.subscribe` and > `mutationCache.subscribe`, with the same event names in Dart class form. ## Common surprises [Troubleshooting](https://dualmeta-gmbh.github.io/query_kit/docs/reference/troubleshooting.md) lists symptoms — a query that refetches on every build, a list that never releases, a `QueryDataTypeError` for a type that looks right — each with its cause and fix. --- # Testing > The teardown every widget test needs, a harness that wraps it, a client without retries, and the pump rules that fake timers impose. ## The teardown every widget test needs A `QueryClient` outlives the widget tree by design — it owns the cache and its `gcTime` timers. Flutter's test binding asserts that **no timer is pending** when the tree comes down, and it checks that *before* any `tearDown` runs, so the cleanup has to happen inside the test body. Get it wrong and the test fails with a pending-timer error that says nothing about queries. The end of a query widget test is therefore always the same steps: ```dart 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(); }); ``` Tear the tree down first and let the frame after it run: the binding's `context.query` scope releases observers in a post-frame sweep, and clearing the client before that runs would leave the sweep to re-create what it is about to drop. Then `clear()` cancels the `gcTime` timers. The last two steps matter when a test leaves a mutation paused offline: `clear()` fails it, its `onError` runs a moment later, and an optimistic rollback's `setQueryData` re-creates the query it names — gc timer included. Nothing here is exported by the package. `flutter_test` is a dev dependency of `query_kit_flutter`, not a regular one, so nothing a test needs sits in your app's dependency graph. Copy the steps, or the harness below, into your own test folder. The Flutter samples on this page are test cases in [`examples/doc_snippets/test/`](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/doc_snippets/test) — the teardown and the harness in `teardown_snippet_test.dart` — which CI runs, so they cannot rot. ## A harness Written once per test suite, so no case repeats the steps: ```dart /// `testWidgets` plus the teardown a `QueryClient` needs. void queryWidgetTest( String description, Future Function(WidgetTester tester, QueryClient client) body, { QueryClient Function()? createClient, }) { testWidgets(description, (tester) async { final client = (createClient ?? QueryClient.new)(); try { await body(tester, client); } finally { await tester.pumpWidget(const SizedBox()); await tester.pumpAndSettle(); client.clear(); await tester.pump(); client.clear(); } }); } ``` The client is built for the case and taken down after it; pass `createClient` to give it `defaultOptions`. A case then reads as the first sample without its last five lines: ```dart queryWidgetTest('the list loads', (tester, client) async { await tester.pumpWidget(QueryClientProvider( client: client, child: const MaterialApp(home: TasksScreen()), )); await tester.pumpAndSettle(); expect(find.byType(ListView), findsOneWidget); }); ``` Both example apps wrap this shape with a fixture of their own, in the same teardown order: `showcaseTest` in [`examples/showcase/test/harness.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/main/examples/showcase/test/harness.dart), which also brings a fresh fake backend and opens the app on one route, and `demoTest` in [`examples/task_manager/test/acceptance_test.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/main/examples/task_manager/test/acceptance_test.dart). The binding's own suite has the fuller version in [`packages/query_kit_flutter/test/harness.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/main/packages/query_kit_flutter/test/harness.dart) — a second client adopted for the teardown, the provider wired with lifecycle observation off, the app lifecycle put back to `resumed` when a case faked it. None of them is importable; they are worth reading before you write your own. ## A client for tests The default retries — three, with backoff — make a failing query take seven seconds to fail. A test's client turns them off: ```dart QueryClient testClient() => QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults(retry: RetryPolicy.never), ), ); ``` Pass it as the harness's `createClient: testClient`. Mutations already default to no retries. Build a **new client per test**: a shared one carries cached data from one case into the next. The rest of this page tests a small shop screen. Its cases use a client like that one, and a helper that puts a screen under a provider: ```dart QueryClient productTestClient() => QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults(retry: RetryPolicy.never), ), ); Widget productApp(QueryClient client, Widget screen) => QueryClientProvider( client: client, child: MaterialApp(home: Scaffold(body: screen)), ); ``` Anything else the app sets on its own client — a `staleTime`, a `refetchInterval` — goes into the test client too, when a test is about it. The stale-time and polling cases below do that. ## Faking the backend Fake the transport, not the library. The query functions under test call your API client; hand them one that answers from memory. The cache then behaves exactly as it does in the app: retries, staleness, structural sharing and all. There are two usual places to cut. **An injected repository.** When the screen takes its data source as a parameter or from your dependency injection, the fake is a class with the same interface: ```dart /// Answers from memory, after [latency], or fails with [failWith]. class FakeProductRepository implements ProductRepository { FakeProductRepository({ List catalogue = const [], this.latency = const Duration(milliseconds: 300), this.failWith, }) : catalogue = [...catalogue]; final List catalogue; final Duration latency; Object? failWith; /// How many calls reached the "server". int requests = 0; Future _answer(T Function() body) async { requests++; await Future.delayed(latency); if (failWith case final error?) throw error; return body(); } @override Future> products({QueryCancelToken? signal}) => _answer(() => List.unmodifiable(catalogue)); @override Future product(String id, {QueryCancelToken? signal}) => _answer(() => catalogue.firstWhere((p) => p.id == id)); @override Future addProduct(String name) => _answer(() { final product = Product(id: 'p${catalogue.length + 1}', name: name, price: 0); catalogue.add(product); return product; }); } ``` `latency` is what makes a loading state observable, `failWith` switches a case to the error path, and `requests` counts what reached the "server" — the number a staleness or polling test asserts on. **Your HTTP client's adapter.** When the app talks to `dio` directly, keep the real repository and swap what is under it. `dio` takes an `HttpClientAdapter`; one that answers from a map is a dozen lines: ```dart class FakeAdapter implements HttpClientAdapter { FakeAdapter(this.routes); /// `'GET /products'` → the JSON body to answer with. final Map routes; @override Future fetch( RequestOptions options, Stream? requestStream, Future? cancelFuture, ) async { await Future.delayed(const Duration(milliseconds: 300)); final route = '${options.method} ${options.path}'; if (!routes.containsKey(route)) { return ResponseBody.fromString('not found', 404); } return ResponseBody.fromString( jsonEncode(routes[route]), 200, headers: { Headers.contentTypeHeader: [Headers.jsonContentType], }, ); } @override void close({bool force = false}) {} } final dio = Dio(BaseOptions(baseUrl: 'https://shop.test/api')) ..httpClientAdapter = FakeAdapter({ 'GET /products': [ {'id': 'p1', 'name': 'Desk lamp', 'price': 4900}, ], }); ``` This is how the *Showcase* example's widget tests run: an in-memory backend behind `dio`, in [`examples/showcase/lib/demo/in_memory_backend.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/main/examples/showcase/lib/demo/in_memory_backend.dart), with a contract test that runs the same cases against it and the real server, so the fake cannot drift from what it stands in for. Either way, a mock of `QueryClient` itself is the wrong cut: it tests your mock, not what the screen will do. ## Loading, error and empty states The screen under test lists products, with a spinner, an error line and an empty state: ```dart QueryObserverOptions> productsQuery(ProductRepository repo) => QueryObserverOptions( queryKey: QueryKey(['products']), queryFn: (context) => repo.products(signal: context.signal), ); class ProductListScreen extends StatelessWidget { const ProductListScreen({super.key, required this.repo}); final ProductRepository repo; @override Widget build(BuildContext context) => QueryBuilder>( options: productsQuery(repo), builder: (context, result) => switch (result) { QueryPending() => const Center(child: CircularProgressIndicator()), QueryError() => const Center(child: Text('Could not load products')), QuerySuccess(:final data) when data.isEmpty => const Center(child: Text('No products yet')), QuerySuccess(:final data) => ListView( children: [ for (final product in data) ListTile(title: Text(product.name)), ], ), }, ); } class AddProductButton extends StatelessWidget { const AddProductButton({super.key, required this.repo}); final ProductRepository repo; @override Widget build(BuildContext context) => MutationBuilder( options: MutationOptions.simple( mutationFn: repo.addProduct, ), builder: (context, mutation) => switch (mutation.value) { MutationPending() => const Text('Saving…'), MutationSuccess() => const Text('Saved'), MutationError() => const Text('Could not save'), MutationIdle() => TextButton( onPressed: () => mutation.mutate('Desk lamp'), child: const Text('Add a desk lamp'), ), }, ); } ``` One case per state. Each steps the fake's latency with `pump` rather than `pumpAndSettle` — the spinner animates forever, so the tree never settles while it is on screen: ```dart queryWidgetTest('shows a spinner, then the products', (tester, client) async { final repo = FakeProductRepository(catalogue: [lamp]); await tester.pumpWidget(productApp(client, ProductListScreen(repo: repo))); expect(find.byType(CircularProgressIndicator), findsOneWidget); await tester.pump(repo.latency); expect(find.text('Desk lamp'), findsOneWidget); }, createClient: productTestClient); queryWidgetTest('shows the error', (tester, client) async { final repo = FakeProductRepository(failWith: Exception('offline')); await tester.pumpWidget(productApp(client, ProductListScreen(repo: repo))); await tester.pump(repo.latency); expect(find.text('Could not load products'), findsOneWidget); expect(repo.requests, 1); // no retries in this client }, createClient: productTestClient); queryWidgetTest('shows the empty state', (tester, client) async { final repo = FakeProductRepository(); await tester.pumpWidget(productApp(client, ProductListScreen(repo: repo))); await tester.pump(repo.latency); expect(find.text('No products yet'), findsOneWidget); }, createClient: productTestClient); ``` The error case asserts one request: with the default retries it would still be waiting a second before the second attempt, and the error text would not be there yet. `find.byType(CircularProgressIndicator)` straight after `pumpWidget` works because a query with no data starts pending in the very first frame; no pump is needed to see it. ## Mutations: success and failure The button under test is the `AddProductButton` above. A mutation's result moves from idle to pending to success or error, and each step is one pump: ```dart queryWidgetTest('saves a product', (tester, client) async { final repo = FakeProductRepository(); await tester.pumpWidget(productApp(client, AddProductButton(repo: repo))); await tester.tap(find.text('Add a desk lamp')); await tester.pump(); expect(find.text('Saving…'), findsOneWidget); await tester.pump(repo.latency); expect(find.text('Saved'), findsOneWidget); expect(repo.catalogue.single.name, 'Desk lamp'); }); queryWidgetTest('says so when saving fails', (tester, client) async { final repo = FakeProductRepository(failWith: Exception('409')); await tester.pumpWidget(productApp(client, AddProductButton(repo: repo))); await tester.tap(find.text('Add a desk lamp')); await tester.pump(repo.latency); expect(find.text('Could not save'), findsOneWidget); expect(repo.catalogue, isEmpty); }); ``` `tester.pump()` after the tap builds the pending frame; `pump(repo.latency)` lets the fake answer. `mutation.mutate` swallows the error for you — it lands in the result, not in the test zone — so the failure case needs no `expectLater` or `runZonedGuarded`. A test that calls `mutateAsync` itself gets the error from the returned future instead, and has to catch it. When a mutation updates the cache — an optimistic `setQueryData`, an `invalidateQueries` in `onSettled` — assert on the screen that shows the query, not on the cache: that is what the user sees, and it catches a wrong key as well as a wrong value. ## The pump rules Two things surprise people, and both come from `testWidgets` running under `FakeAsync`. **`pumpAndSettle` only pumps while a frame is scheduled.** A fake backend's latency is a *timer*, not a frame, and so is a `refetchInterval`. Step them explicitly: ```dart await tester.pump(const Duration(milliseconds: 300)); // the fake's latency await tester.pumpAndSettle(); ``` So `pumpAndSettle` never reaches the next poll or retry: it returns as soon as no frame is scheduled, long before the timer is due — or, with a spinner on screen while a query retries, it times out. Drive those with `pump(duration)` only. **`tester.pump()` with no duration does not let a `dio` response resolve.** `dio` hangs its pipeline off zero-duration timers, and `FakeAsync` runs those only when the clock moves. Step with a real duration. ## Time: stale time and polling The whole library reads time through `package:clock`, and `testWidgets` binds `clock` to the fake one. So `pump(const Duration(minutes: 5))` genuinely ages data past its `staleTime` and fires `gcTime` timers — no `withClock`, no sleeping, no flake. A stale-time case leaves the screen and comes back, once inside the stale time and once after it, and counts requests: ```dart queryWidgetTest('a fresh list is not fetched again', (tester, client) async { final repo = FakeProductRepository(catalogue: [lamp]); final screen = productApp(client, ProductListScreen(repo: repo)); await tester.pumpWidget(screen); await tester.pump(repo.latency); expect(repo.requests, 1); // Leave and come back within the stale time: served from the cache. await tester.pumpWidget(const SizedBox()); await tester.pump(const Duration(seconds: 30)); await tester.pumpWidget(screen); expect(find.text('Desk lamp'), findsOneWidget); expect(repo.requests, 1); // Come back after it: shown from the cache, and fetched again. await tester.pumpWidget(const SizedBox()); await tester.pump(const Duration(minutes: 1)); await tester.pumpWidget(screen); expect(find.text('Desk lamp'), findsOneWidget); await tester.pump(repo.latency); expect(repo.requests, 2); }, createClient: () => QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults( retry: RetryPolicy.never, staleTime: StaleTime.duration(Duration(minutes: 1)), ), ), )); ``` The second visit shows the list without a request; the third shows the cached list at once *and* fetches, because stale data is still shown while it is refreshed. Keep the gap under `gcTime` (five minutes by default), or the entry is gone and the third visit starts from a spinner. A polling case steps the interval: ```dart queryWidgetTest('polls every ten seconds', (tester, client) async { final repo = FakeProductRepository(catalogue: [lamp]); await tester.pumpWidget(productApp(client, ProductListScreen(repo: repo))); await tester.pump(repo.latency); expect(repo.requests, 1); // Each poll: the interval, then the fake's latency. for (final expected in [2, 3]) { await tester.pump(const Duration(seconds: 10)); expect(repo.requests, expected); await tester.pump(repo.latency); } }, createClient: () => QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults( retry: RetryPolicy.never, refetchInterval: RefetchInterval.every(Duration(seconds: 10)), ), ), )); ``` The interval counts from the query's last update, not from the first fetch, so each poll is the interval plus the fake's latency. Polling stops when the screen goes, so the harness's teardown settles as usual. ## Testing without widgets A `QueryController` is a plain `ValueListenable`, testable with `test()` rather than `testWidgets`. It fetches only while something listens — the same rule a widget follows — so a case adds a listener and waits for the result it wants: ```dart test('a controller loads without a widget', () async { final client = productTestClient(); final repo = FakeProductRepository( catalogue: [lamp], latency: Duration.zero, ); final controller = QueryController(client, productsQuery(repo)); // A controller fetches while something listens. final loaded = Completer>(); void onChange() { if (controller.value case QuerySuccess(:final data)) { if (!loaded.isCompleted) loaded.complete(data); } } controller.addListener(onChange); expect(controller.value, isA>>()); expect(await loaded.future, [lamp]); controller ..removeListener(onChange) ..dispose(); client.clear(); }); ``` This runs on real time: the fake's latency is zero, and the case awaits a completer rather than sleeping. The teardown is shorter than a widget test's because no binding checks for pending timers — but `client.clear()` still cancels the `gcTime` timer the query started. For the pure-Dart core, the same case runs under `dart test` with a `QueryObserver`. When the case is about time, wrap it in `fakeAsync` from [`package:fake_async`](https://pub.dev/packages/fake_async): `clock` follows its fake time, and `async.elapse` fires the timers. ```dart import 'package:fake_async/fake_async.dart'; import 'package:query_kit/query_kit.dart'; import 'package:test/test.dart'; void main() { test('a price goes stale after two minutes', () { fakeAsync((async) { final client = QueryClient(); final observer = client.observe( productQuery(FakeProductRepository(catalogue: [lamp]), 'p1'), ); final unsubscribe = observer.subscribe((_) {}); async.elapse(const Duration(milliseconds: 300)); // the fake's latency expect(observer.currentResult.dataOrNull, lamp); expect(observer.currentResult.isStale, isFalse); async.elapse(const Duration(minutes: 2)); expect(observer.currentResult.isStale, isTrue); unsubscribe(); client.clear(); }); }); } ``` `productQuery` is the product-detail options from the [options reference](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-options.md), with a two-minute `staleTime`. The observer marks its result stale on a timer of its own, which is why `elapse` alone flips `isStale` with no refetch. > **Note: In React Query** > > The React docs recommend a fresh `QueryClient` per test with `retry: false` > and wrapping the component in a provider — the same shape as here. What has > no React counterpart is the teardown: Jest does not check for pending timers > when a test ends, and Flutter's test binding does. ## In the examples The *Task manager* example's acceptance suite, [`examples/task_manager/test/acceptance_test.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/main/examples/task_manager/test/acceptance_test.dart), is one widget test per feature of a whole app — the first load, detail entries seeded from the list, a debounced search, a rename that rolls back, a switch confirmed by polling, a retried error — against a fake of its backend, in the shape this page describes. --- # Without Flutter > Using query_kit on its own — observers, subscribe, and the one thing you have to do yourself. `query_kit` has no Flutter dependency. A CLI, a server, a shared package, a `dart:io` daemon — the cache works the same in all of them; what the Flutter binding adds is the widget plumbing. ## Imperative ```dart final client = QueryClient(); client.mount(); final tasks = await client.query>( QueryOptions>( queryKey: QueryKey(['tasks']), queryFn: (context) => api.listTasks(signal: context.signal), staleTime: const StaleTime.duration(Duration(seconds: 45)), ), ); ``` ## Reactive ```dart final observer = client.observe( QueryObserverOptions( queryKey: QueryKey(['tasks', id]), queryFn: (context) => api.getTask(id, signal: context.signal), ), ); final unsubscribe = observer.subscribe((result) { switch (result) { case QueryPending(): print('loading'); case QuerySuccess(:final data): print(data.name); case QueryError(:final error, :final staleData): print('$error (still showing ${staleData?.name})'); } }); ``` The result is the same sealed `QueryResult` a Flutter widget switches over; the binding's readers are this observer with a widget's lifetime around it. The *Simple* example is that Flutter version of one query: press the refetch button and watch the post stay on screen while the *refreshing* pill shows and the strip's `fetches` goes up. The post and the pill are fields of the result above; the count comes from the cache's events. Live demo: [Simple](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/simple), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/simple)). One query, its states, and a refetch. `client.observeInfinite` is the same for an infinite query. `QueriesObserver` observes a list; `MutationObserver` and `MutationStateObserver` are the write side. ## What you have to do yourself **`client.mount()` at start-up.** The Flutter binding mounts the client it is given; in pure Dart it is your call, and without it nothing reacts to focus or to the network coming back: no `refetchOnWindowFocus`, no `refetchOnReconnect`, no resuming of paused mutations, and a `query` that paused offline waits for a reconnect only while mounted. **`client.clear()` at the end — and unsubscribe the observers first.** A client owns `gcTime` timers, and a process with a pending timer does not exit. `clear()` empties the caches but does not stop observers: a subscribed observer with a `refetchInterval` keeps its timer across `clear()` and rebuilds its query on the next tick, so an observer you created is yours to unsubscribe or `destroy()` before the client is cleared. **Focus and online, if they mean anything to you.** There is no window and no connectivity plugin outside Flutter, so `client.focusManager.setFocused(…)` and `client.onlineManager.setOnline(…)` are yours to drive — or to leave alone, in which case the client stays focused and online. ## Where it runs The core is tested on the Dart VM **and** compiled to JavaScript, because a cache full of timers and microtasks is exactly the sort of code where the two disagree. Timers are clamped to 2^31−1 ms so a 30-day `gcTime` does not fire immediately on the web. ## What is Dart rather than JavaScript The core follows TanStack Query's behaviour, not its type tricks. The differences you feel at the call site: - **Two type parameters, not five.** `Query` at the cache layer, `QueryObserver` where `select` needs a second. There is no `TError` — errors are `Object` plus a `StackTrace` — and no `TQueryKey`. - **`QueryKey` is a value type**, deep-frozen with structural equality, not a hashed string. `queryKeyHashFn` is gone; the hash survives as `debugString`. - **One key, one exact type** — see [type safety in Dart](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md#one-key-one-exact-type). - **Every option union is a sealed value type** — see [describing a query once](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md). - **Cancellation is `QueryCancelToken.onCancel`**, because Dart has no ecosystem-wide cancellation primitive. - **Time goes through `package:clock`**, so `fake_async` controls it completely. The full name map is [coming from React Query](https://dualmeta-gmbh.github.io/query_kit/docs/coming-from-react-query.md), and the behaviour that differs is in [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Does this replace state management? > Server state and client state are different problems — what moves into the cache, what stays in your state-management package, and how the two meet. For the part of your state that came from a server, mostly yes. For the rest, no — and it does not try to. ## Two kinds of state **Server state** lives somewhere else. You hold a copy that can go out of date, that someone else can change, and that has to be fetched, cached, refreshed and invalidated. That is this library's whole job. **Client state** is yours alone: which tab is open, what is typed into a form, whether a panel is expanded, a theme, a draft. It is never stale, because nothing else owns it. Most apps built with `provider`, `riverpod` or `bloc` hold both kinds in one place, and most of the code is the first kind — loading flags, error fields, refresh logic, "is this cached yet". Moving that into queries usually leaves a much smaller client state behind, often small enough for `setState` and an `InheritedWidget`. ## What moves - A repository or bloc whose job is "fetch, keep, refresh" becomes a query options function and a key. - Loading, error and "refreshing" flags become the [query result](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md). - Manual refresh-after-write becomes an [invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md). - A cache you wrote yourself — with its expiry — becomes [`staleTime` and `gcTime`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md). ## What stays Form input, navigation state, selections, feature flags, anything the server never sees. Keep those in whatever you use today. ## How the two meet - **Client state picks the query.** A selected filter or page number goes into the query's **key**, and the query follows it; see [query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md). - **A query is a listenable.** A `QueryController` is a `ValueListenable` of the query's result — a `ChangeNotifier`, to be exact — so anything that can wrap a listenable can hold one; see [four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md#querycontroller). - **Nothing here needs another package**, and nothing here competes for the same job as one: the binding depends on Flutter alone. The four ways the binding reads a query — `context.query`, the builder widgets, `QueryMixin` and `QueryController` — are equal alternatives, and they mix in one screen. In the *Four call styles* example, five readers share one cache entry; press *Refetch*, then *Invalidate*, and watch one request go out and every card update, with the strip still saying `fetches=1` per request. Live demo: [Four call styles](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/four-call-styles), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/four_call_styles)). The same query through context, builder, mixin and controller. ## Next to Riverpod, Bloc, Provider and signals The rule for all of them is the same: **the cache stays the owner of server data**. Your state-management package may hold a controller, forward its result, or react to it — but it does not copy the data into its own state and keep it there, because then two places disagree after the next refetch. Keep one `QueryClient` for the app, created once. If both your package and the widgets read queries, hand the same client to `QueryClientProvider`. ### Riverpod A notifier owns a `QueryController` and forwards its result as its state. Riverpod's `autoDispose` and the controller's own lifetime line up: the controller subscribes while the notifier is alive, and disposing it lets the cache entry go after its `gcTime`. ```dart final queryClientProvider = Provider((ref) => QueryClient()); class TasksNotifier extends Notifier>> { @override QueryResult> build() { final controller = QueryController(ref.watch(queryClientProvider), tasksQuery()); void forward() => state = controller.value; controller.addListener(forward); ref.onDispose(() { controller ..removeListener(forward) ..dispose(); }); return controller.value; } } final tasksProvider = NotifierProvider.autoDispose>>( TasksNotifier.new, ); ``` A `ConsumerWidget` then switches over `ref.watch(tasksProvider)` exactly as it would over any `QueryResult`. The sample is written for Riverpod 3; on Riverpod 2 the class extends `AutoDisposeNotifier`. A notifier whose options depend on another provider — a filter, a user id — watches that provider in `build`, and Riverpod rebuilds it with a new controller for the new key. ### Bloc A Cubit wraps a controller the same way and emits each result: ```dart class TasksCubit extends Cubit>> { TasksCubit(QueryClient client) : this._(QueryController(client, tasksQuery())); TasksCubit._(this._controller) : super(_controller.value) { _controller.addListener(_forward); } final QueryController, List> _controller; void _forward() => emit(_controller.value); Future refresh() => _controller.refetch(); @override Future close() { _controller ..removeListener(_forward) ..dispose(); return super.close(); } } ``` The controller starts fetching when the listener is added, so the Cubit's first state is the pending result and the next `emit` is the data. For a Bloc that should *react* to a query — log out when a profile request fails with a 401, say — listen to the controller in the Bloc and add an event, rather than copying the result into the Bloc's state. ### Provider `ChangeNotifierProvider` takes a `QueryController` as it is, since the controller is a `ChangeNotifier`, and disposes it with the provider: `ChangeNotifierProvider(create: (context) => QueryController(client, tasksQuery()))`. Reading it with `context.watch` then rebuilds on every result. ### Signals A signals package that can wrap a `ValueListenable` wraps a `QueryController` directly; one that cannot takes a listener that sets the signal, as in the Cubit above. Either way, dispose the controller where the signal is disposed. > **Note: In React Query** > > The TanStack docs make the same split: "server state" in the query cache, > client state in whatever the app already uses — `useState`, Zustand, Redux. > The integrations here play the role of those libraries' hooks and selectors. ## In practice The *Task manager* example has no state-management package at all. Its server state — the task list, each task, the search results — is queries and mutations. Its client state — which task is open, the search text, the project filter — is one small `ChangeNotifier`, `AppState` in [`examples/task_manager/lib/src/app_state.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/main/examples/task_manager/lib/src/app_state.dart), with no copy of any task in it. The search text and the project go into the list's query key, debounced, so the list follows them. A larger app keeps its package for the client side and loses most of the server-side code. --- # Examples > Every feature as a live screen you can run in the browser, one whole small app that composes them, and the package's one-file example. Each page below is one screen of the showcase app: what it demonstrates, a live demo that runs in your browser against an in-memory backend (nothing is downloaded until you press *Run*), what to try in it, and the source of the screen, taken from the compiled file. Every screen draws its cache entries' state as it changes, so you can watch what the library does, not just what the widget shows. A demo is a Flutter web app in a frame, and it behaves like one: clicking outside it and back in is a window focus change, so a stale query refetches on the way back in, as it would in your app. A `fetches=` count a page promises can therefore be one higher if you clicked away in between. ## Whole apps - **[Task manager](https://dualmeta-gmbh.github.io/query_kit/docs/examples/task-manager.md)**: one ordinary small app, a to-do list against a slow backend that fails on cue. Where the showcase lets you look a feature up, this shows how six of them compose: a list two widgets share, a detail screen, optimistic writes with rollback, and a reminder that is accepted before it is confirmed. - **[One-file tour](https://dualmeta-gmbh.github.io/query_kit/docs/examples/one-file-tour.md)**: the package's own example, a provider, one query read two ways and a mutation, in a single file with no server. ## Basics - [Simple](https://dualmeta-gmbh.github.io/query_kit/docs/examples/simple.md): One query, its states, and a refetch. - [Basic](https://dualmeta-gmbh.github.io/query_kit/docs/examples/basic.md): A list, a detail, and what the cache already knows. - [Four call styles](https://dualmeta-gmbh.github.io/query_kit/docs/examples/four-call-styles.md): The same query through context, builder, mixin and controller. ## Queries - [Default query function](https://dualmeta-gmbh.github.io/query_kit/docs/examples/default-query-function.md): A query function derived from the key, set once as a default. - [Dependent queries](https://dualmeta-gmbh.github.io/query_kit/docs/examples/dependent-queries.md): A query that waits for another to have data. - [Parallel queries](https://dualmeta-gmbh.github.io/query_kit/docs/examples/parallel-queries.md): Several queries in one widget, and the global fetching count. - [Query collections](https://dualmeta-gmbh.github.io/query_kit/docs/examples/query-collections.md): A list of queries that grows, shrinks and reorders at runtime. - [Combine](https://dualmeta-gmbh.github.io/query_kit/docs/examples/combine.md): Three queries of three types, read as one result. - [Initial and placeholder data](https://dualmeta-gmbh.github.io/query_kit/docs/examples/initial-and-placeholder.md): Data before the first fetch: written to the cache, or shown only. - [Select and structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/examples/select-and-sharing.md): What a reader rebuilds on, and what it does not. ## Paging - [Pagination](https://dualmeta-gmbh.github.io/query_kit/docs/examples/pagination.md): Page by page, keeping the previous page on screen while the next loads. - [Load more and infinite scroll](https://dualmeta-gmbh.github.io/query_kit/docs/examples/load-more.md): An infinite query that appends pages as you scroll. - [Infinite query with max pages](https://dualmeta-gmbh.github.io/query_kit/docs/examples/max-pages.md): Pages in both directions, with a window of three. ## Mutations - [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/examples/mutations.md): mutate, mutateAsync, reset, callbacks, and scopes. - [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/examples/optimistic-updates.md): Show the write before the server answers — two ways. - [Mutation context and cancel](https://dualmeta-gmbh.github.io/query_kit/docs/examples/mutation-cancel.md): A write that reads what onMutate kept, and can be called off. - [Mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/examples/mutation-state.md): Every running mutation in the cache, read by a widget that owns none of them. ## Cache - [Prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/examples/prefetching.md): Warm the cache before the screen that needs it opens. - [Stale time and garbage collection](https://dualmeta-gmbh.github.io/query_kit/docs/examples/stale-and-gc.md): When data goes stale, and when an unused entry is dropped. - [Invalidation and filters](https://dualmeta-gmbh.github.io/query_kit/docs/examples/invalidation-and-filters.md): Invalidate, refetch, reset and remove, by prefix, type or predicate. - [Playground](https://dualmeta-gmbh.github.io/query_kit/docs/examples/playground.md): Todos with live knobs for stale time, gc time, latency and errors. - [Cache inspector](https://dualmeta-gmbh.github.io/query_kit/docs/examples/cache-inspector.md): Every entry and every event, live. ## Network - [Auto refetching](https://dualmeta-gmbh.github.io/query_kit/docs/examples/auto-refetching.md): Polling on an interval, in the foreground or not. - [Retry](https://dualmeta-gmbh.github.io/query_kit/docs/examples/retry.md): Retry policies and delays, and what the result shows meanwhile. - [Cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/examples/cancellation.md): A query cancelled is a request aborted. - [Offline](https://dualmeta-gmbh.github.io/query_kit/docs/examples/offline.md): Network modes, paused mutations, and coming back online. - [Focus refetch](https://dualmeta-gmbh.github.io/query_kit/docs/examples/focus-refetch.md): What happens when the app comes back to the foreground. ## Advanced - [Filtering rebuilds](https://dualmeta-gmbh.github.io/query_kit/docs/examples/build-when.md): buildWhen on the eight keyless reads, each beside its unfiltered twin. - [Global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/examples/global-callbacks.md): Cache-level callbacks, and meta on its way through. - [Diagnostics](https://dualmeta-gmbh.github.io/query_kit/docs/examples/diagnostics.md): What the library throws, and when: the wrong type, the missing function. ## Running them yourself Both apps live in the repository with a small backend each. The showcase: ```bash cd examples/showcase/server && npm install && npm run dev ``` ```bash cd examples/showcase && flutter run -d chrome ``` The task manager runs on the web and iOS: ```bash cd examples/task_manager/server && npm install && npm run dev ``` ```bash cd examples/task_manager && flutter run -d chrome ``` Either app also runs with no server, as the live demos do: `flutter run -d chrome --dart-define=QK_BACKEND=inmemory`. How all of them are tested, from widget tests to a browser suite against the real backend, is on [how the examples are built](https://dualmeta-gmbh.github.io/query_kit/docs/project/examples.md). --- # Task manager > One small, whole app on query_kit — a shared list and detail, optimistic writes with rollback, and a poll that stops once the server confirms. The showcase is a catalogue: one screen per feature, each on its own. The task manager is the other kind of example, an ordinary small app that needs six of those features at once, so you can see how they compose. It has an overview with search and a sync badge, a detail screen with a rename field and a reminder switch, and a backend that is slow on purpose and fails on cue. Each row of the table below is covered by a widget test. Live demo: [Task manager](https://dualmeta-gmbh.github.io/query_kit/demo/task_manager/), the whole example app, running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/task_manager/lib)). The demo answers from an in-memory copy of the backend inside the page, as slow as the real one: about 900 ms for the list, 350 ms for one task, 700 ms for a write, and three seconds for the reminder scheduler to confirm. It starts with three tasks. ## What to try | Try this | What happens | |---|---| | Open a task | The detail renders at once: the row and the detail read the same cache entry, already filled by the list. | | Rename it, then go straight back | The row already shows the new name; the write is followed by one request for that task and no list refetch. | | Rename a task to `fail` | The backend refuses. The optimistic name reverts and an error shows. | | Toggle the reminder | The switch flips at once, the screen waits for the scheduler, polls until the confirmation arrives about three seconds later, then stops polling. | | Type in the search box | One request per pause in typing, not one per keystroke. | | Add a task (**+**) | Every list is invalidated: which tasks exist is the list's concern. | | Delete a task | The row disappears at once. Deletes alternate: the first is refused and springs back with a notice; the next goes through. | | Look at the header badge | "x of y synced" is a second shape of the list's own cache entry; it costs no request. | ## The cache policy, in one file Everything the app decides about caching, fetching, seeding, polling and rolling back lives in `lib/src/queries.dart`. The screens only call the functions it exports. What follows walks through it top to bottom. ### Client defaults The app turns the eager defaults down: no refetch when the app comes back to the foreground or the network returns, and one retry instead of three. A backend that needs most of a second per list should not be asked again every time the window regains focus. The acceptance tests build their client from the same constant, so they test the policy the app ships. [`examples/task_manager/lib/src/queries.dart`, lines 25–31](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L25-L31): ```dart const DefaultOptions appDefaultOptions = DefaultOptions( queries: QueryDefaults( refetchOnWindowFocus: RefetchOn.never, refetchOnReconnect: RefetchOn.never, retry: RetryPolicy.times(1), ), ); ``` ### The list seeds each task Two kinds of query split the data. The list query owns *which* tasks exist; a per-task query owns *what each task is*. When a list arrives, its query function writes every task into that task's own entry. `initialData` could not do this: it is read only while an entry is empty, so later list responses would never reach a row that had already been built. Seeding also stamps each entry fresh, so rendering the rows does not start a burst of per-task fetches. The options are one shared function, never re-declared per screen, so every observer of the key runs the same query function. [`examples/task_manager/lib/src/queries.dart`, lines 39–63](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L39-L63): ```dart QueryObserverOptions taskListQuery( QueryClient client, TaskApi api, TaskFilters filters, ) => QueryObserverOptions( queryKey: TaskKeys.list(filters), queryFn: (context) async { final result = await api.listTasks(filters, signal: context.signal); // Push the list response into each per-task cache. // // `initialData` cannot do this job: it is only consulted when a cache // entry is empty, so once a row has been built, later list responses // have no path into that entry and the row renders its own stale copy // forever. // // Seeding also stamps those entries fresh, so building the rows never // triggers a burst of per-task fetches. for (final task in result.tasks) { client.setQueryData(TaskKeys.detail(task.id), task); } return result; }, staleTime: const StaleTime.duration(Duration(seconds: 45)), ); ``` ### Two widgets, two shapes, one request The header's sync badge uses the same key, function and stale time as the unfiltered list, and adds a `select`. It reads the entry the overview already fetched, so it costs nothing. [`examples/task_manager/lib/src/queries.dart`, lines 65–80](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L65-L80): ```dart /// Used by the header. Same key and same options as the overview's unfiltered /// list, so this costs no extra request — `select` derives from the cache entry /// that is already there. Two widgets, two shapes of the same data, one fetch. QuerySelectOptions syncedTasksQuery(QueryClient client, TaskApi api) { final list = taskListQuery(client, api, TaskFilters.all); return QuerySelectOptions( queryKey: list.queryKey, queryFn: list.queryFn, staleTime: list.staleTime, select: (data) => ( synced: data.tasks.where((task) => task.synced).length, total: data.tasks.length, ), ); } ``` ### The per-task query: a fallback seed and a poll that stops Both the overview's rows and the detail screen read `taskQuery`. If a task is opened before any list has seeded it, the query looks for it in whatever lists the cache holds and uses that as initial data, **with the list's timestamp**, so old data does not pass for freshly fetched and still revalidates. Its `refetchInterval` is a function of the data: while the task has a reminder write the scheduler has not confirmed, it polls every half second; once the server confirms, the function returns `null` and polling stops. The poll keeps running in the background, so a confirmation that lands while the user looks away is still picked up. [`examples/task_manager/lib/src/queries.dart`, lines 83–126](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L83-L126): ```dart QueryObserverOptions taskQuery( QueryClient client, TaskApi api, String id, ) { ({QueryKey key, Task match})? findInLists() { for (final (key, data) in client.getQueriesData( filters: QueryFilters(queryKey: TaskKeys.lists), )) { for (final task in data?.tasks ?? const []) { if (task.id == id) { return (key: key, match: task); } } } return null; } final seed = findInLists(); return QueryObserverOptions( queryKey: TaskKeys.detail(id), queryFn: (context) => api.getTask(id, signal: context.signal), staleTime: const StaleTime.duration(Duration(seconds: 45)), // Fallback seed for the case where a task is opened before any list // response has seeded it. Inheriting the list's timestamp matters: dated // data must not look freshly fetched, or it would never revalidate. initialData: seed == null ? null : InitialData.compute(() => seed.match), initialDataUpdatedAt: seed == null ? null : client.getQueryState(seed.key)?.dataUpdatedAt, // While the scheduler has an unconfirmed write outstanding, poll until it // settles. The poll must survive the app losing focus, otherwise a // confirmation that lands while the user glances away is never picked up. refetchInterval: RefetchInterval.dynamic((query) { final task = query.state.data; return task is Task && task.reminderPending ? const Duration(milliseconds: 500) : null; }), refetchIntervalInBackground: true, ); } ``` ### Optimistic rename with rollback The rename cancels any fetch of the task in flight (so a slow response cannot overwrite the patch), keeps the current value, and patches the entry. If the server refuses, `onError` writes the kept value back. Either way, one invalidation of one key reconciles both screens, because both read it. [`examples/task_manager/lib/src/queries.dart`, lines 133–162](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L133-L162): ```dart MutationOptions renameTaskMutation( QueryClient client, TaskApi api, ) => MutationOptions( mutationKey: QueryKey(const ['renameTask']), mutationFn: (input) => api.renameTask(input.id, input.name), onMutate: (input) async { final key = TaskKeys.detail(input.id); // Stop in-flight fetches so a stale response cannot overwrite the // patch. await client.cancelQueries(filters: QueryFilters(queryKey: key)); final previous = client.getQueryData(key); client.updateQueryData( key, (old) => old?.copyWith(name: input.name), ); return previous; }, onError: (_, __, input, previous) { if (previous != null) { client.setQueryData(TaskKeys.detail(input.id), previous); } }, // One invalidation, one key. The detail screen and the overview row both // read this query, so both reconcile from the single refetch. onSettled: (_, __, ___, input, ____) => client.invalidateQueries( filters: QueryFilters(queryKey: TaskKeys.detail(input.id)), ), ); ``` ### A reminder that is accepted before it is confirmed The reminder write puts the requested value in a separate field, `reminderTarget`, which the screen shows in preference to the confirmed one. It deliberately does not mark the task as pending: that would start the poll before the write had reached the server, and the first poll would read the old state. The server's answer carries `pending: true`, `onSuccess` writes it into the cache, and that is what starts the poll in `taskQuery`. No invalidation: the poll is the reconciliation. [`examples/task_manager/lib/src/queries.dart`, lines 166–199](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L166-L199): ```dart MutationOptions setReminderMutation( QueryClient client, TaskApi api, ) => MutationOptions( mutationKey: QueryKey(const ['setReminder']), mutationFn: (input) => api.setReminder(input.id, value: input.value), onMutate: (input) async { final key = TaskKeys.detail(input.id); await client.cancelQueries(filters: QueryFilters(queryKey: key)); final previous = client.getQueryData(key); // Write the requested value to `reminderTarget`, not to the // confirmed field, and deliberately leave `pending` alone: setting it // here would start the confirmation poll before the write had even // reached the backend, and the first poll would read pre-write state. // The UI renders `target ?? reminder`, so the switch still // flips instantly, and every later response agrees with it. client.updateQueryData( key, (old) => old?.copyWith(reminderTarget: input.value), ); return previous; }, onError: (_, __, input, previous) { if (previous != null) { client.setQueryData(TaskKeys.detail(input.id), previous); } }, // The accepted response carries `pending: true`, which starts the poll // above. No invalidation here — the poll is already the reconciliation // loop. onSuccess: (accepted, input, ___) => client.setQueryData(TaskKeys.detail(input.id), accepted), ); ``` ### Create and delete touch membership Creating a task has no optimistic step, so it uses `MutationOptions.simple` and invalidates every list. Deleting removes the row from every cached list at once and keeps a snapshot of all of them for the rollback. The task's own entry is removed only once the server agrees, so a refused delete springs back to a row whose detail still renders from cache. [`examples/task_manager/lib/src/queries.dart`, lines 205–217](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L205-L217): ```dart MutationOptions createTaskMutation( QueryClient client, TaskApi api, ) => MutationOptions.simple( mutationKey: QueryKey(const ['createTask']), mutationFn: (input) => api.createTask( name: input.name, project: input.project, priority: input.priority), // Membership is a list concern, so this one invalidates every list. onSuccess: (_, __, ___) => client.invalidateQueries( filters: QueryFilters(queryKey: TaskKeys.lists), ), ); ``` [`examples/task_manager/lib/src/queries.dart`, lines 222–259](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L222-L259): ```dart MutationOptions deleteTaskMutation( QueryClient client, TaskApi api, ) => MutationOptions( mutationKey: QueryKey(const ['deleteTask']), mutationFn: api.deleteTask, onMutate: (id) async { final lists = QueryFilters(queryKey: TaskKeys.lists); await client.cancelQueries(filters: lists); final snapshot = client.getQueriesData(filters: lists); client.updateQueriesData( (old) => old?.withTasks( old.tasks.where((task) => task.id != id).toList(), ), filters: lists, ); return snapshot; }, onError: (_, __, ___, snapshot) { for (final (key, data) in snapshot ?? const <(QueryKey, TaskListResponse?)>[]) { if (data != null) { client.setQueryData(key, data); } } }, // The per-task entry goes only once the server has agreed: a refused // delete springs the row back, and the detail screen behind it must // still render from the seeded entry rather than fetch it again. onSuccess: (_, id, __) => client.removeQueries( filters: QueryFilters(queryKey: TaskKeys.detail(id)), ), onSettled: (_, __, ___, ____, _____) => client.invalidateQueries( filters: QueryFilters(queryKey: TaskKeys.lists), ), ); ```
The whole policy file [`examples/task_manager/lib/src/queries.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart): ```dart /// The whole cache policy of this demo lives in this file — every staleness, /// seeding, polling and rollback rule the app has, in about 230 lines /// including comments. Note how small it is compared to the manual reload /// wiring it replaces. /// /// Shape: the list query owns *which* tasks exist, and a per-task query /// owns *what each one is*. Both the overview rows and the detail screen read /// the same per-task query, so invalidating one task key updates both /// screens — there is no cross-cache patching to keep in sync. library; import 'package:query_kit_flutter/query_kit_flutter.dart'; import 'api.dart'; import 'models.dart'; /// The client-wide defaults. /// /// This app deliberately turns the aggressive ones off: no refetch when it /// comes back to the foreground or the network returns, and one retry instead /// of three — a demo backend that answers in ~900 ms should not be asked twice /// for the same thing every time the window regains focus. Both the app and /// the acceptance suite build their client from this, so the tests run the /// policy the app ships with. const DefaultOptions appDefaultOptions = DefaultOptions( queries: QueryDefaults( refetchOnWindowFocus: RefetchOn.never, refetchOnReconnect: RefetchOn.never, retry: RetryPolicy.times(1), ), ); /// Options for a task-list query, shared by every caller. /// /// They have to be shared rather than re-declared per screen: two observers on /// one key with two different query functions is a coin flip over which one /// actually runs, and the seeding below would silently stop happening depending /// on which widget built first. QueryObserverOptions taskListQuery( QueryClient client, TaskApi api, TaskFilters filters, ) => QueryObserverOptions( queryKey: TaskKeys.list(filters), queryFn: (context) async { final result = await api.listTasks(filters, signal: context.signal); // Push the list response into each per-task cache. // // `initialData` cannot do this job: it is only consulted when a cache // entry is empty, so once a row has been built, later list responses // have no path into that entry and the row renders its own stale copy // forever. // // Seeding also stamps those entries fresh, so building the rows never // triggers a burst of per-task fetches. for (final task in result.tasks) { client.setQueryData(TaskKeys.detail(task.id), task); } return result; }, staleTime: const StaleTime.duration(Duration(seconds: 45)), ); /// Used by the header. Same key and same options as the overview's unfiltered /// list, so this costs no extra request — `select` derives from the cache entry /// that is already there. Two widgets, two shapes of the same data, one fetch. QuerySelectOptions syncedTasksQuery(QueryClient client, TaskApi api) { final list = taskListQuery(client, api, TaskFilters.all); return QuerySelectOptions( queryKey: list.queryKey, queryFn: list.queryFn, staleTime: list.staleTime, select: (data) => ( synced: data.tasks.where((task) => task.synced).length, total: data.tasks.length, ), ); } /// Used by both the overview rows and the detail screen. QueryObserverOptions taskQuery( QueryClient client, TaskApi api, String id, ) { ({QueryKey key, Task match})? findInLists() { for (final (key, data) in client.getQueriesData( filters: QueryFilters(queryKey: TaskKeys.lists), )) { for (final task in data?.tasks ?? const []) { if (task.id == id) { return (key: key, match: task); } } } return null; } final seed = findInLists(); return QueryObserverOptions( queryKey: TaskKeys.detail(id), queryFn: (context) => api.getTask(id, signal: context.signal), staleTime: const StaleTime.duration(Duration(seconds: 45)), // Fallback seed for the case where a task is opened before any list // response has seeded it. Inheriting the list's timestamp matters: dated // data must not look freshly fetched, or it would never revalidate. initialData: seed == null ? null : InitialData.compute(() => seed.match), initialDataUpdatedAt: seed == null ? null : client.getQueryState(seed.key)?.dataUpdatedAt, // While the scheduler has an unconfirmed write outstanding, poll until it // settles. The poll must survive the app losing focus, otherwise a // confirmation that lands while the user glances away is never picked up. refetchInterval: RefetchInterval.dynamic((query) { final task = query.state.data; return task is Task && task.reminderPending ? const Duration(milliseconds: 500) : null; }), refetchIntervalInBackground: true, ); } /// What an optimistic write stashes so it can be rolled back. typedef TaskSnapshot = Task?; typedef RenameInput = ({String id, String name}); MutationOptions renameTaskMutation( QueryClient client, TaskApi api, ) => MutationOptions( mutationKey: QueryKey(const ['renameTask']), mutationFn: (input) => api.renameTask(input.id, input.name), onMutate: (input) async { final key = TaskKeys.detail(input.id); // Stop in-flight fetches so a stale response cannot overwrite the // patch. await client.cancelQueries(filters: QueryFilters(queryKey: key)); final previous = client.getQueryData(key); client.updateQueryData( key, (old) => old?.copyWith(name: input.name), ); return previous; }, onError: (_, __, input, previous) { if (previous != null) { client.setQueryData(TaskKeys.detail(input.id), previous); } }, // One invalidation, one key. The detail screen and the overview row both // read this query, so both reconcile from the single refetch. onSettled: (_, __, ___, input, ____) => client.invalidateQueries( filters: QueryFilters(queryKey: TaskKeys.detail(input.id)), ), ); typedef ReminderInput = ({String id, bool value}); MutationOptions setReminderMutation( QueryClient client, TaskApi api, ) => MutationOptions( mutationKey: QueryKey(const ['setReminder']), mutationFn: (input) => api.setReminder(input.id, value: input.value), onMutate: (input) async { final key = TaskKeys.detail(input.id); await client.cancelQueries(filters: QueryFilters(queryKey: key)); final previous = client.getQueryData(key); // Write the requested value to `reminderTarget`, not to the // confirmed field, and deliberately leave `pending` alone: setting it // here would start the confirmation poll before the write had even // reached the backend, and the first poll would read pre-write state. // The UI renders `target ?? reminder`, so the switch still // flips instantly, and every later response agrees with it. client.updateQueryData( key, (old) => old?.copyWith(reminderTarget: input.value), ); return previous; }, onError: (_, __, input, previous) { if (previous != null) { client.setQueryData(TaskKeys.detail(input.id), previous); } }, // The accepted response carries `pending: true`, which starts the poll // above. No invalidation here — the poll is already the reconciliation // loop. onSuccess: (accepted, input, ___) => client.setQueryData(TaskKeys.detail(input.id), accepted), ); typedef CreateInput = ({String name, String? project, Priority? priority}); /// No optimistic step, so no `onMutate` and nothing to roll back: /// `MutationOptions.simple` is the form for that. MutationOptions createTaskMutation( QueryClient client, TaskApi api, ) => MutationOptions.simple( mutationKey: QueryKey(const ['createTask']), mutationFn: (input) => api.createTask( name: input.name, project: input.project, priority: input.priority), // Membership is a list concern, so this one invalidates every list. onSuccess: (_, __, ___) => client.invalidateQueries( filters: QueryFilters(queryKey: TaskKeys.lists), ), ); /// Every list entry as it was before an optimistic delete. typedef ListSnapshot = List<(QueryKey, TaskListResponse?)>; MutationOptions deleteTaskMutation( QueryClient client, TaskApi api, ) => MutationOptions( mutationKey: QueryKey(const ['deleteTask']), mutationFn: api.deleteTask, onMutate: (id) async { final lists = QueryFilters(queryKey: TaskKeys.lists); await client.cancelQueries(filters: lists); final snapshot = client.getQueriesData(filters: lists); client.updateQueriesData( (old) => old?.withTasks( old.tasks.where((task) => task.id != id).toList(), ), filters: lists, ); return snapshot; }, onError: (_, __, ___, snapshot) { for (final (key, data) in snapshot ?? const <(QueryKey, TaskListResponse?)>[]) { if (data != null) { client.setQueryData(key, data); } } }, // The per-task entry goes only once the server has agreed: a refused // delete springs the row back, and the detail screen behind it must // still render from the seeded entry rather than fetch it again. onSuccess: (_, id, __) => client.removeQueries( filters: QueryFilters(queryKey: TaskKeys.detail(id)), ), onSettled: (_, __, ___, ____, _____) => client.invalidateQueries( filters: QueryFilters(queryKey: TaskKeys.lists), ), ); ```
## The four call styles, one per screen The binding offers four equal ways to read a query. The app uses each where its screen's shape suits it, which also shows that they share one cache without friction. | Style | Where | Why there | |---|---|---| | `QueryController` | the overview's list | the toolbar and the body are siblings that both need the list | | `context.query` | each task row | rows read different keys; only the row whose task changed rebuilds | | `QueryMixin` | the detail screen | already stateful for the rename field; the query and both mutations sit at the top of `build` | | `QuerySelectBuilder` | the header badge | a leaf widget, so the builder stays visible in the tree | The overview creates its controller once and re-keys it in place when the filters change; the old entry stays cached until its garbage-collection time. [`examples/task_manager/lib/src/screens/overview.dart`, lines 54–70](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/screens/overview.dart#L54-L70): ```dart void didChangeDependencies() { super.didChangeDependencies(); final filters = AppScope.of(context).filters; final options = taskListQuery(_client, widget.api, filters); if (_list == null) { _list = QueryController( _client, options, ); } else if (filters != _filters) { // A changed filter re-keys the list. The observer switches queries in // place; the previous entry stays cached and is garbage-collected on its // own schedule. _list!.setOptions(options); } _filters = filters; } ``` A row reads its own task: [`examples/task_manager/lib/src/screens/overview.dart`, lines 272–276](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/screens/overview.dart#L272-L276): ```dart final client = QueryClientProvider.of(context); final task = context.query(taskQuery(client, api, id)).dataOrNull; if (task == null) { return const SizedBox.shrink(); } ``` The badge selects from the list's entry: [`examples/task_manager/lib/src/widgets/header.dart`, lines 54–74](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/widgets/header.dart#L54-L74): ```dart QuerySelectBuilder( options: syncedTasksQuery(client, api), builder: (context, result) => switch (result) { QuerySuccess(:final data) => StatusPill( label: '${data.synced} of ${data.total} synced', color: data.synced == data.total ? AppColors.accent : AppColors.muted, background: data.synced == data.total ? AppColors.accentSoft : AppColors.ground, dot: true, ), QueryError() => StatusPill( label: 'Server offline', color: AppColors.danger, background: AppColors.dangerSoft, ), QueryPending() => const SkeletonBox(width: 128, height: 22), }, ), ``` ## How it is tested Three layers, all in `examples/task_manager/`: - **Widget tests** (`test/acceptance_test.dart`): one test per row of the feature checklist in the app's README, plus regressions. They run the real app, with its own client defaults, against the same in-memory backend the live demo uses, wired in as a dio adapter with its latencies at zero unless a test sets one. Only the transport is replaced; the app's api class, JSON handling, errors and cancellation all run. - **A contract test** (`test/backend_contract_test.dart`): the same cases against the in-memory backend and, when a server is running, against the real express server, so the stand-in cannot quietly drift from what it stands in for. - **End-to-end tests** (`e2e/tests/tasks.spec.ts`): the real web build in Chromium against the real server, asserting on what a user sees and on the wire: how many requests a screen costs, that a rename shows before the write returns (the test holds the request at the network layer rather than timing it), that the reminder poll stops, that an unreachable backend is retried exactly once. The widget-test teardown a `QueryClient` needs is on [Testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md). ## Related - Guides: [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md), [Polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md), [Initial query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md), [Four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) - [The app's README](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/task_manager), with how to run it against the real backend - [View the app on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/task_manager/lib) --- # One-file tour > The package's own example, a provider, one query read two ways and a mutation that invalidates it, in a single file with no server. The binding ships one example with the package, and it is the one pub.dev shows on its *Example* tab: a single `main.dart` of some 130 lines that you can paste into a fresh `flutter create` project and run. It has no server; the "API" is a list behind a delay. It is the whole loop of a screen that reads and writes remote data, small enough to hold in your head: a provider at the root, a list read in `build`, a refresh indicator in the app bar that reads the same cache entry through a builder, and an *add* button whose mutation invalidates the list when it succeeds. There is no live demo of this one; the showcase's [Simple](https://dualmeta-gmbh.github.io/query_kit/docs/examples/simple.md) and [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/examples/mutations.md) screens cover the same ground with more to look at. ## Walking through it The stand-in for an HTTP client, and the query built on it: one key, one function, a stale time of 30 seconds. [`packages/query_kit_flutter/example/lib/main.dart`, lines 14–37](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/packages/query_kit_flutter/example/lib/main.dart#L14-L37): ```dart class Api { final List _tasks = ['Kitchen', 'Hallway', 'Garage']; Future> list() async { await Future.delayed(const Duration(milliseconds: 600)); return List.unmodifiable(_tasks); } Future add(String name) async { await Future.delayed(const Duration(milliseconds: 400)); _tasks.add(name); } } final api = Api(); final tasksKey = QueryKey(['tasks']); Future> fetchTasks(QueryFunctionContext context) => api.list(); QueryObserverOptions> tasksQuery() => QueryObserverOptions( queryKey: tasksKey, queryFn: fetchTasks, staleTime: const StaleTime.duration(Duration(seconds: 30)), ); ``` A second options object for the same key, reduced by `select`. Its reader shares the cache entry. It still rebuilds whenever the result changes (a refetch changes `isFetching`), which is exactly what a spinner wants. [`packages/query_kit_flutter/example/lib/main.dart`, lines 44–49](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/packages/query_kit_flutter/example/lib/main.dart#L44-L49): ```dart QuerySelectOptions, bool> fetchingQuery() => QuerySelectOptions( queryKey: tasksKey, queryFn: fetchTasks, staleTime: const StaleTime.duration(Duration(seconds: 30)), select: (_) => true, ); ``` The client goes at the root, above `MaterialApp`, so every route can reach it. [`packages/query_kit_flutter/example/lib/main.dart`, lines 51–58](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/packages/query_kit_flutter/example/lib/main.dart#L51-L58): ```dart void main() { runApp( QueryClientProvider( client: QueryClient(), child: const MaterialApp(home: TasksScreen()), ), ); } ``` The screen reads the list with `context.query`, takes the client in `build` (a mutation's callback can run after the widget that started it is gone, so it closes over the client rather than the `BuildContext`), and declares the *add* mutation with `context.mutation`. The app bar reads the select options through a `QuerySelectBuilder`; the body switches over the list's sealed result. These are two of the binding's four equal call styles, side by side on one key. [`packages/query_kit_flutter/example/lib/main.dart`, lines 60–129](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/packages/query_kit_flutter/example/lib/main.dart#L60-L129): ```dart class TasksScreen extends StatelessWidget { const TasksScreen({super.key}); @override Widget build(BuildContext context) { // One of the four equal call styles: read in build. The widget rebuilds // when the result changes. final tasks = context.query(tasksQuery()); // The client, taken here in `build` rather than inside the callback // below. A mutation outlives the widget that started it — disposing its // controller does not cancel it — so `onSuccess` can run after this // element is gone, and looking an ancestor up from a deactivated element // throws. The cache work has to happen either way; the client is the // right thing to close over, the `BuildContext` is not. final client = QueryClientProvider.of(context); // A mutation, the same way. `MutationOptions.simple` is the form without // an `onMutate` step: its types come from `api.add`. final add = context.mutation( MutationOptions.simple( mutationFn: api.add, onSuccess: (_, __, ___) => client.invalidateQueries( filters: QueryFilters(queryKey: tasksKey), ), ), ); return Scaffold( appBar: AppBar( title: const Text('Tasks'), actions: [ // Another, equally valid: a builder, for a leaf that only wants one // flag. QuerySelectBuilder, bool>( options: fetchingQuery(), builder: (context, result) => result.isFetching ? const Padding( padding: EdgeInsets.all(16), child: SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ) : IconButton( icon: const Icon(Icons.refresh), onPressed: () => result.refetch(), ), ), ], ), body: switch (tasks) { QueryPending() => const Center(child: CircularProgressIndicator()), QueryError(:final error, staleData: null) => Center(child: Text('$error')), QuerySuccess(:final data) || QueryError(staleData: final data!) => ListView( children: [ for (final name in data) ListTile(title: Text(name)), ], ), }, floatingActionButton: FloatingActionButton( onPressed: add.value.isPending ? null : () => add.mutate('Task ${DateTime.now().second}'), child: const Icon(Icons.add), ), ); } } ``` ## Running it ```bash cd packages/query_kit_flutter/example && flutter run ``` Or copy the file into your own project as `lib/main.dart` after `flutter pub add query_kit_flutter`. ## Related - [Installation](https://dualmeta-gmbh.github.io/query_kit/docs/installation.md) and [Quick start](https://dualmeta-gmbh.github.io/query_kit/docs/quick-start.md) - [Four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) - [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) and [Query invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md) - [View on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/packages/query_kit_flutter/example) --- # Simple > One query read in build, its three states told apart with a switch, and a refetch that keeps the data on screen. The smallest useful screen: one widget reads one query while it builds, switches over the sealed result to draw a skeleton, an error or the post, and offers a refresh button that fetches again in the background while the old post stays visible. It is the shape of every "show one record" screen in an app, a profile header, an order summary, a settings page loaded from the server, before anything else is layered on. Live demo: [Simple](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/simple), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/simple)). One query, its states, and a refetch. ## What to try - Watch the skeleton in the *Post #1* card give way to the post, *Local development: setup guide*: one request, and the debug strip under the card reads `status=success` and `fetches=1`. - Press the refresh icon. A *refreshing* pill appears beside it while the fetch runs, the post never leaves the screen, and `fetches` goes up by one. - Press it several times in a row: the button is disabled while a fetch is running, so each press is one request. ## The code The query is a function returning options, so its `queryFn` can close over the screen's api client; the key is what the cache stores the post under. [`examples/showcase/lib/features/simple/simple_screen.dart`, lines 33–37](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/simple/simple_screen.dart#L33-L37): ```dart QueryObserverOptions firstPostQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: ShowcaseKeys.post(1), queryFn: (context) => api.post(1, signal: context.signal), ); ``` The screen reads it with `context.query` and switches over the result. A refetch that fails keeps the last good data (`QueryError(staleData: …)`), so the error pattern with data and the success pattern share one branch. [`examples/showcase/lib/features/simple/simple_screen.dart`, lines 46–96](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/simple/simple_screen.dart#L46-L96): ```dart final post = context.query(firstPostQuery(api)); return FeatureScaffold( feature: simpleFeature, children: [ SectionCard( title: 'Post #1', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (post.isFetching) const Pill('refreshing'), IconButton( tooltip: 'Refetch', onPressed: post.isFetching ? null : post.refetch, icon: const Icon(Icons.refresh), ), ], ), child: switch (post) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: 20, width: 240), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 4), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (post case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Text( data.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text(data.body), ], ), }, ), QueryDebugStrip(queryKey: ShowcaseKeys.post(1), label: 'post'), ```
The whole screen [`examples/showcase/lib/features/simple/simple_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/simple/simple_screen.dart): ```dart /// Upstream's `simple` example: one query read in build, its states told /// apart with a `switch` over the sealed result, and a refetch button that /// shows `isFetching` while the background fetch runs. /// /// Proofs (widget tests in `test/features/simple_test.dart`, end-to-end in /// `e2e/tests/simple.spec.ts`): the skeleton gives way to the post after one /// request; a refetch shows the "refreshing" pill while the data stays on /// screen and bumps the strip's `fetches`; a refused first fetch ends in the /// error state after the default retries; a refused refetch keeps the stale /// data next to the error. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature simpleFeature = Feature( id: 'simple', title: 'Simple', summary: 'One query, its states, and a refetch.', upstream: 'simple', ); /// The screen's one query. The options are a function, not a constant, so /// the `queryFn` can close over the api; the key is what the cache goes by. QueryObserverOptions firstPostQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: ShowcaseKeys.post(1), queryFn: (context) => api.post(1, signal: context.signal), ); class SimpleScreen extends StatelessWidget { const SimpleScreen({super.key}); @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); // Read in build: the widget rebuilds when the result changes. final post = context.query(firstPostQuery(api)); return FeatureScaffold( feature: simpleFeature, children: [ SectionCard( title: 'Post #1', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (post.isFetching) const Pill('refreshing'), IconButton( tooltip: 'Refetch', onPressed: post.isFetching ? null : post.refetch, icon: const Icon(Icons.refresh), ), ], ), child: switch (post) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: 20, width: 240), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 4), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (post case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Text( data.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text(data.body), ], ), }, ), QueryDebugStrip(queryKey: ShowcaseKeys.post(1), label: 'post'), ], ); } } ```
## Related - Guides: [Queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md), [Four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) - Upstream: TanStack's React [`simple`](https://github.com/TanStack/query/tree/main/examples/react/simple) example - Tested by `test/features/simple_test.dart` (widget) and `e2e/tests/simple.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/simple) --- # Basic > A list and a detail sharing one cache, a mark on every row the cache already holds, and a detail entry that is dropped once nobody reads it. A list of posts and a detail opened from it, both reading from the same cache. A row whose post the cache already holds is marked *cached*, reopening that post shows it at once while a background fetch refreshes it, and a post nobody has looked at for ten seconds is dropped again. It is the shape of a product list with a product page, or a device list with a device screen: the second visit to a detail should never show a spinner, and entries nobody needs should not pile up. Live demo: [Basic](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/basic), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/basic)). A list, a detail, and what the cache already knows. ## What to try - Watch the list arrive: the `posts` debug strip reads `status=success` and `fetches=1`, and no row carries a *cached* mark yet. - Open a post, then press the back arrow (*Back to list*). Its row is now bold and marked *cached*, and no other row is. - Open the same post again. The title is on screen immediately, a *refreshing* pill shows the background fetch, and the post's strip counts `fetches=2` when it is done. - Go back and wait ten seconds without opening anything. The post's entry is garbage-collected and its *cached* mark disappears; the list itself stays. ## The code Two option functions: the list with the client's defaults, and one post with a short `gcTime`, so an entry without readers is removed ten seconds after its last one left. [`examples/showcase/lib/features/basic/basic_screen.dart`, lines 45–49](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/basic/basic_screen.dart#L45-L49): ```dart QueryObserverOptions> postsQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal), ); ``` [`examples/showcase/lib/features/basic/basic_screen.dart`, lines 53–58](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/basic/basic_screen.dart#L53-L58): ```dart QueryObserverOptions postQuery(ShowcaseApi api, int id) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), gcTime: const GcTime.duration(postGcTime), ); ``` The list reads through a `QueryBuilder`. Whether a row is cached is not part of the list's result, so the rows ask the client with `getQueryData` and rebuild on the cache's own events through the showcase's `CacheListener`. [`examples/showcase/lib/features/basic/basic_screen.dart`, lines 110–152](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/basic/basic_screen.dart#L110-L152): ```dart return QueryBuilder>( options: postsQuery(api), builder: (context, posts) => SectionCard( title: 'Posts', trailing: posts.isFetching && posts.dataOrNull != null ? const Pill('refreshing') : null, child: switch (posts) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 8), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => // Whether a row's post is cached is not part of this query's // result: it is read straight from the cache, so the rows are // rebuilt on the cache's own events. CacheListener( builder: (context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final post in data) _PostRow( post: post, cached: client .getQueryData(ShowcaseKeys.post(post.id)) != null, onOpen: () => onOpen(post.id), ), ], ), ), }, ), ); ``` The detail is its own widget reading `context.query`, so going back unmounts the reader and the post's entry starts counting down its `gcTime`. [`examples/showcase/lib/features/basic/basic_screen.dart`, lines 213–262](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/basic/basic_screen.dart#L213-L262): ```dart final post = context.query(postQuery(api, id)); return SectionCard( title: 'Post #$id', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ // A refetch over data already on screen: upstream's "Background // Updating...". The first load shows the skeleton instead. if (post.isFetching && post.dataOrNull != null) const Pill('refreshing'), IconButton( tooltip: 'Back to list', onPressed: onBack, icon: const Icon(Icons.arrow_back), ), ], ), child: switch (post) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: 20, width: 240), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 4), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (post case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Text( data.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text(data.body), ], ), }, ); ```
The whole screen [`examples/showcase/lib/features/basic/basic_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/basic/basic_screen.dart): ```dart /// Upstream's `basic` example: a list of posts, a detail opened from it in /// the same screen, and what the cache already knows shown in the list — /// a row is marked `cached` when `getQueryData` finds its post. Reopening a /// visited post shows it at once and refreshes it in the background; the /// detail entry has a short `gcTime`, so a post left alone is dropped from /// the cache ten seconds later and its mark disappears. /// /// The list is a `QueryBuilder`, the detail reads `context.query` — two of /// the four call styles, side by side. /// /// Proofs (widget tests in `test/features/basic_test.dart`, end-to-end in /// `e2e/tests/basic.spec.ts`): the list arrives after one request with no /// row marked; opening a post fetches it once and marks its row on the way /// back, no other row; reopening it shows the title from the cache while the /// refetch is still in flight; the entry is collected once `gcTime` passes /// and the mark goes with it; leaving the screen releases every observer. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature basicFeature = Feature( id: 'basic', title: 'Basic', summary: 'A list, a detail, and what the cache already knows.', upstream: 'basic', ); /// How long a post's entry outlives its last reader. Upstream keeps posts /// for a day; ten seconds is long enough to see the `cached` mark and short /// enough to watch it go. const Duration postGcTime = Duration(seconds: 10); /// The list's query, with the client's defaults for everything else. QueryObserverOptions> postsQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal), ); /// One post's query. The default `staleTime` is what makes a reopened post /// refetch in the background; [postGcTime] is what lets it go. QueryObserverOptions postQuery(ShowcaseApi api, int id) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), gcTime: const GcTime.duration(postGcTime), ); class BasicScreen extends StatefulWidget { const BasicScreen({super.key}); @override State createState() => _BasicScreenState(); } class _BasicScreenState extends State { /// The open post, or null for the list — upstream's `postId` state. int? _selectedId; @override Widget build(BuildContext context) { final selectedId = _selectedId; return FeatureScaffold( feature: basicFeature, children: [ if (selectedId == null) ...[ // The strip goes above the list: thirty rows push anything below // them out of the scaffold's lazily built viewport, and a strip // that is not built is a strip no test can read. QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'), _PostList(onOpen: (id) => setState(() => _selectedId = id)), ] else ...[ // Its own widget, so leaving it unmounts the `context.query` // reader and releases the post's observer at once. _PostDetail( id: selectedId, onBack: () => setState(() => _selectedId = null), ), QueryDebugStrip( queryKey: ShowcaseKeys.post(selectedId), label: 'post-$selectedId', ), QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'), ], ], ); } } class _PostList extends StatelessWidget { const _PostList({required this.onOpen}); final ValueChanged onOpen; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final client = QueryClientProvider.of(context); return QueryBuilder>( options: postsQuery(api), builder: (context, posts) => SectionCard( title: 'Posts', trailing: posts.isFetching && posts.dataOrNull != null ? const Pill('refreshing') : null, child: switch (posts) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 8), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => // Whether a row's post is cached is not part of this query's // result: it is read straight from the cache, so the rows are // rebuilt on the cache's own events. CacheListener( builder: (context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final post in data) _PostRow( post: post, cached: client .getQueryData(ShowcaseKeys.post(post.id)) != null, onOpen: () => onOpen(post.id), ), ], ), ), }, ), ); } } class _PostRow extends StatelessWidget { const _PostRow({ required this.post, required this.cached, required this.onOpen, }); final Post post; final bool cached; final VoidCallback onOpen; @override Widget build(BuildContext context) => SemanticsGroup( // A named group per row, so a test can ask for row 3's mark and // nobody else's; the mark stays a text of its own outside the button. name: 'post ${post.id}', child: Row( children: [ Expanded( child: MergeSemantics( child: Semantics( button: true, child: InkWell( onTap: onOpen, borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 10), child: Text( post.title, style: cached ? TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ) : null, ), ), ), ), ), ), if (cached) const Pill('cached'), ], ), ); } class _PostDetail extends StatelessWidget { const _PostDetail({required this.id, required this.onBack}); final int id; final VoidCallback onBack; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final post = context.query(postQuery(api, id)); return SectionCard( title: 'Post #$id', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ // A refetch over data already on screen: upstream's "Background // Updating...". The first load shows the skeleton instead. if (post.isFetching && post.dataOrNull != null) const Pill('refreshing'), IconButton( tooltip: 'Back to list', onPressed: onBack, icon: const Icon(Icons.arrow_back), ), ], ), child: switch (post) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: 20, width: 240), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 4), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (post case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Text( data.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text(data.body), ], ), }, ); } } ```
## Related - Guides: [Quick start](https://dualmeta-gmbh.github.io/query_kit/docs/quick-start.md), [Queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md), [Caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md) - Upstream: TanStack's React [`basic`](https://github.com/TanStack/query/tree/main/examples/react/basic) example - Tested by `test/features/basic_test.dart` (widget) and `e2e/tests/basic.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/basic) --- # Four call styles > One query read through context.query, QueryBuilder, QueryMixin and QueryController at once, with the listeners and the mutation and infinite-query counterparts beside them. The post list is read five ways on one screen: through each of the four call styles and through a bare `QueryObserver` from `client.observe(...)`, which is what the four are built on. All five share one cache entry, so they make one request and show the same data. Below them, a `QueryListener` reacts to the same query without reading it, one mutation runs through `MutationBuilder`, `context.mutation` and a `MutationController` with a `MutationListener`, and one infinite query is read through `context.infiniteQuery`, `watchInfiniteQuery` and `client.observeInfinite`. The styles mix freely, so a real app can pick per widget: a product list's header, its rows and a detail sheet can each read the same catalogue query in whichever shape suits that widget. Live demo: [Four call styles](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/four-call-styles), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/four_call_styles)). The same query through context, builder, mixin and controller. ## What to try - On open the `posts` strip reads `observers=5` and `fetches=1`, and every card shows `posts=30`. - Press *Refetch* (it goes through card 4's controller) or *Invalidate* (it goes through the client): one request, and every card shows `fetching=true` and then the data again. The `builds` counts move together; card 4's can sit one ahead, because a `ListenableBuilder` has no value to compare. - In card 6, press *Drop a post*: every reader shows 29, the listener logs one line, and `child-builds=1` never moves. *Drop two posts* logs two transitions, *Drop two posts, batched* logs one. A *Refetch* brings the 30 back; press it again and the listener says nothing, because `listenWhen` refuses a fetch that returns the same posts (`listener-skips` goes up). - In card 7, press any of the *Increment* buttons: the counter in the card's header goes up once the invalidation has refetched it. Under *Increment (controller)* the listener reports `mutation-last=pending->success`. - In card 8, press *Load next*: all three infinite readers show `pages=2` after one request, and the listener reads `infinite-last=1->2`. ## The code The four styles read the same `postsQuery(api)` options, one card each, in the order the screen shows them. `context.query` reads in the build of any widget: [`examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart`, lines 284–304](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart#L284-L304): ```dart class _ContextCard extends StatelessWidget { const _ContextCard({required this.builds}); final _Builds builds; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final posts = context.query(postsQuery(api)); return _ReaderCard( title: '1. context.query', code: 'context.query(postsQuery(api))', note: 'Read in build. The observer is this widget\'s, and it is ' 'released when the widget unmounts or stops reading the key.', name: 'context', result: posts, builds: builds.next(), ); } } ``` `QueryBuilder` puts the read in the tree and rebuilds its own subtree: [`examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart`, lines 308–330](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart#L308-L330): ```dart class _BuilderCard extends StatelessWidget { const _BuilderCard({required this.builds}); final _Builds builds; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); return QueryBuilder>( options: postsQuery(api), builder: (context, posts) => _ReaderCard( title: '2. QueryBuilder', code: 'QueryBuilder>(options: …, builder: …)', note: 'Takes buildWhen, the port\'s answer to notifyOnChangeProps — ' 'as context.query and watchQuery do. This one does not use it, ' 'so it rebuilds like the rest.', name: 'builder', result: posts, builds: builds.next(), ), ); } } ``` `QueryMixin` reads in a `State`'s build and releases with the `State`: [`examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart`, lines 344–360](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart#L344-L360): ```dart class _MixinCardState extends State<_MixinCard> with QueryMixin { @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final posts = watchQuery(postsQuery(api)); return _ReaderCard( title: '3. QueryMixin', code: 'watchQuery(postsQuery(api))', note: 'Everything this State watches is disposed with it. A key read ' 'last build but not this one is released after the frame.', name: 'mixin', result: posts, builds: widget.builds.next(), ); } } ``` `QueryController` is a `ValueListenable` you create and dispose yourself; here the screen owns it, and a `ListenableBuilder` reads it: [`examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart`, lines 367–388](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart#L367-L388): ```dart class _ControllerCard extends StatelessWidget { const _ControllerCard({required this.controller, required this.builds}); final QueryController, List> controller; final _Builds builds; @override Widget build(BuildContext context) => ListenableBuilder( listenable: controller, builder: (context, _) => _ReaderCard( title: '4. QueryController', code: 'QueryController.create(client, postsQuery(api))', note: 'A ListenableBuilder rebuilds on every notification, ' 'because a Listenable carries no value to compare. That can ' 'leave this count one ahead of the other four, which drop a ' 'notification whose result they have already built.', name: 'controller', result: controller.value, builds: builds.next(), ), ); } ```
The whole screen [`examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/four_call_styles/four_call_styles_screen.dart): ```dart /// The binding's own story, with no upstream counterpart: one query — the /// post list — read five ways at once, next to one mutation run three ways. /// /// The four call styles are **equal alternatives**: there is no default /// and no recommendation, so this screen ranks nothing. It shows what each /// one looks like, and that they all end up at the same cache entry — five /// readers, one request, one set of data. The fifth card drops the binding /// altogether and drives a bare `QueryObserver` from `client.observe(...)`, /// which is what the other four are wrapped around. /// /// Every card also counts its own builds. The four binding styles drop a /// notification whose result they have already built — the first one, about /// the fetch their own subscribe started, is exactly that — and a plain /// `ListenableBuilder`, which has no value to compare, cannot; so card 4's /// count can sit one ahead. From there the five move together: a refetch is /// two builds everywhere, the fetch starting and the data landing. /// /// The sixth card reads nothing at all. `QueryListener` is the other half of /// the story — the four styles answer "what does this query show", the /// listener answers "what should happen when it changes" — and it is the only /// thing here that adds no observer: it borrows card 4's controller and never /// disposes it. Three things it does are visible on the card. Nothing is /// delivered on mount, so its first call is the first fetch landing, the /// transition after it. Its `listenWhen` accepts a change of the posts /// themselves and refuses a refetch that returns the same ones, however far /// `dataUpdatedAt` and `fetchStatus` have moved. And a refused transition /// still advances what the next comparison starts from, which is why the line /// logged for a change during a refetch starts at the fetching state the /// refusal saw and not at the state before the refetch. The child is handed /// back unchanged: `child-builds` is still 1 after every button on the screen /// has been pressed. The card's last two buttons write the cache twice, once /// as two writes and once inside one `NotifyManager.shared.batch(...)`: the /// app's client is built on the shared manager (`main.dart`), a batch holds /// every notification until it ends, and a notification carries the /// controller's *latest* value — so the listener hears two transitions from /// the plain pair and one, straight to the second value, from the batched /// pair. /// /// The seventh card runs one mutation three ways — `MutationBuilder`, /// `context.mutation`, and a `MutationController` read through a /// `ListenableBuilder` — and the third has a `MutationListener` over it, the /// mutation's counterpart of card 6: nothing on mount, one call per state /// change, `idle->pending` and `pending->success`. /// /// The eighth card is the same story for an infinite query: the builder is /// on the `load-more` screen and the controller on `max-pages`, so here are /// the other two — `context.infiniteQuery` in a `StatelessWidget` and /// `watchInfiniteQuery` in a `QueryMixin`, both of which hand back the /// *controller* because paging lives on it — next to the core's own /// `client.observeInfinite(...)`. Three readers, one entry, `observers=3`; /// `Load next` goes through the mixin's controller and all three show the /// page. An `InfiniteQueryListener` borrows that controller and logs each /// change of the page count. /// /// Proofs (widget tests in `test/features/four_call_styles_test.dart`, /// end-to-end in `e2e/tests/four_call_styles.spec.ts`): five readers make one /// `GET /api/posts` and the strip says `observers=5`; a refetch through the /// controller updates all five; either mutation button increments the counter /// and the invalidation refetches it; leaving the screen releases every /// observer, the hand-rolled ones included; the hand-rolled observer sees the /// same result as the binding's readers after a refetch; the listener says /// nothing on mount, one thing per change of the data, nothing at all for a /// refetch that changes none of it, while its child builds once and never /// again; two plain writes are two transitions and two batched ones are one; /// the mutation listener hears `idle->pending` and `pending->success` and /// nothing on mount; and the three infinite readers share one entry, `Load /// next` reaches all three with one request, and the infinite listener logs /// `none->1` then `1->2`. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature fourCallStylesFeature = Feature( id: 'four-call-styles', title: 'Four call styles', summary: 'The same query through context, builder, mixin and controller.', ); /// The one query all five readers share. /// /// The `staleTime` is what makes "five readers, one request" hold no matter /// which frame a card first builds in: a reader that subscribes after the /// data is in joins it instead of starting a refetch of its own. It does not /// stand in the way of the buttons — `refetch()` ignores staleness, and /// `invalidateQueries` marks the entry stale explicitly. QueryObserverOptions> postsQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal), staleTime: const StaleTime.duration(Duration(minutes: 5)), ); /// The entry the mutation writes to, and the one it invalidates. QueryKey get counterKey => QueryKey(const ['counter']); /// Card 8's entry: cursor pages of the projects, this screen's own key so the /// `load-more` and `max-pages` entries are untouched by what happens here. QueryKey get stylesKey => QueryKey(const ['projects', 'styles']); /// Ten projects a page; fresh for five minutes for the same reason as the /// posts — three readers, one request, whichever frame each first builds in. InfiniteQueryObserverOptions stylesQuery(ShowcaseApi api) => InfiniteQueryObserverOptions( queryKey: stylesKey, initialPageParam: 0, pageFn: (context) => api.projectsFrom( context.pageParam, limit: 10, signal: context.signal, ), getNextPageParam: (page, _, __, ___) => page.nextId, staleTime: const StaleTime.duration(Duration(minutes: 5)), ); typedef ProjectPages = InfiniteData; /// How many pages a result holds, as the infinite listener's `listenWhen` /// sees it: `none` before the first one. String _pagesOf(QueryResult result) => '${result.dataOrNull?.pages.length ?? 'none'}'; QueryObserverOptions counterQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: counterKey, queryFn: (context) => api.counter(signal: context.signal), ); /// One increment. `onSuccess` returns the invalidation's future, so the /// mutation stays `pending` until the counter has refetched — upstream's /// "return the promise" idiom, and the reason a success is never shown next /// to a stale number. MutationOptions incrementMutation( ShowcaseApi api, QueryClient client, ) => MutationOptions.simple( mutationFn: (by) => api.increment(by: by), onSuccess: (_, __, ___) => client.invalidateQueries(filters: QueryFilters(queryKey: counterKey)), ); /// How many times one card has built. /// /// A counter per card, held by the screen rather than by the card, so the two /// `StatelessWidget` readers can keep one as well. class _Builds { int value = 0; int next() => ++value; } class FourCallStylesScreen extends StatefulWidget { const FourCallStylesScreen({super.key}); @override State createState() => _FourCallStylesScreenState(); } class _FourCallStylesScreenState extends State { late final ShowcaseApi _api; late final QueryClient _client; /// The controller behind card 4 — and behind the `Refetch` button, so the /// refetch provably goes through the controller and not through the client. late final QueryController, List> _controller; /// The mutation behind card 7's third panel, and under its listener. late final MutationController _increment; final _Builds _contextBuilds = _Builds(); final _Builds _builderBuilds = _Builds(); final _Builds _mixinBuilds = _Builds(); final _Builds _controllerBuilds = _Builds(); final _Builds _observerBuilds = _Builds(); /// The listener's child counts its builds like a card, and for the opposite /// reason: this one is supposed to stay at 1. final _Builds _listenerChildBuilds = _Builds(); @override void initState() { super.initState(); // Neither lookup subscribes: the api and the client are fixed for the // life of the app, and a subscribing lookup is not allowed here anyway. _api = context.getInheritedWidgetOfExactType()!.api; _client = QueryClientProvider.read(context); _controller = QueryController.create(_client, postsQuery(_api)); _increment = MutationController( _client, incrementMutation(_api, _client), ); } @override void dispose() { _controller.dispose(); _increment.dispose(); super.dispose(); } void _invalidate() => _client .invalidateQueries(filters: QueryFilters(queryKey: ShowcaseKeys.posts)) .ignore(); @override Widget build(BuildContext context) => FeatureScaffold( feature: fourCallStylesFeature, children: [ SectionCard( title: 'One entry, five readers', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'The library has four ways to read a query and no default: ' 'they are equal alternatives, layered on one another, and ' 'they mix freely inside one screen. Each card below reads ' 'the same key with a reader of its own; what they share is ' 'the entry in the cache, which the core deduplicates. The ' 'strip says observers=5 and fetches=1.', ), const SizedBox(height: 8), const Text( 'The fifth card uses no binding at all: a QueryObserver ' 'from client.observe(...), subscribed in initState and ' 'destroyed in dispose. That is what the other four wrap.', ), const SizedBox(height: 12), Toolbar( children: [ ActionButton( label: 'Refetch', filled: true, onPressed: () => _controller.refetch().ignore(), ), ActionButton(label: 'Invalidate', onPressed: _invalidate), ], ), const SizedBox(height: 8), const Text( 'Refetch goes through card 4\'s controller; Invalidate ' 'goes through the client. Either way one request goes out ' 'and all five readers show the new data.', ), ], ), ), QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'), _ContextCard(builds: _contextBuilds), _BuilderCard(builds: _builderBuilds), _MixinCard(builds: _mixinBuilds), _ControllerCard( controller: _controller, builds: _controllerBuilds, ), _ObserverCard(builds: _observerBuilds), _ListenerCard( controller: _controller, client: _client, childBuilds: _listenerChildBuilds, ), _MutationCard(api: _api, client: _client, controller: _increment), QueryDebugStrip(queryKey: counterKey, label: 'counter'), _InfiniteCard(api: _api), QueryDebugStrip(queryKey: stylesKey, label: 'styles'), ], ); } /// 1. `context.query` — the flattest of the four, and it works in a /// `StatelessWidget`. Small on purpose: the rebuild is this widget, not the /// screen. class _ContextCard extends StatelessWidget { const _ContextCard({required this.builds}); final _Builds builds; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final posts = context.query(postsQuery(api)); return _ReaderCard( title: '1. context.query', code: 'context.query(postsQuery(api))', note: 'Read in build. The observer is this widget\'s, and it is ' 'released when the widget unmounts or stops reading the key.', name: 'context', result: posts, builds: builds.next(), ); } } /// 2. `QueryBuilder` — the `StreamBuilder` shape: everything is in the tree, /// and the rebuild is exactly this builder's subtree. class _BuilderCard extends StatelessWidget { const _BuilderCard({required this.builds}); final _Builds builds; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); return QueryBuilder>( options: postsQuery(api), builder: (context, posts) => _ReaderCard( title: '2. QueryBuilder', code: 'QueryBuilder>(options: …, builder: …)', note: 'Takes buildWhen, the port\'s answer to notifyOnChangeProps — ' 'as context.query and watchQuery do. This one does not use it, ' 'so it rebuilds like the rest.', name: 'builder', result: posts, builds: builds.next(), ), ); } } /// 3. `QueryMixin` — flat like `context.query`, owned by the `State`. Reads /// are identified by key and types, so there is no equivalent of the rules of /// hooks. class _MixinCard extends StatefulWidget { const _MixinCard({required this.builds}); final _Builds builds; @override State<_MixinCard> createState() => _MixinCardState(); } class _MixinCardState extends State<_MixinCard> with QueryMixin { @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final posts = watchQuery(postsQuery(api)); return _ReaderCard( title: '3. QueryMixin', code: 'watchQuery(postsQuery(api))', note: 'Everything this State watches is disposed with it. A key read ' 'last build but not this one is released after the frame.', name: 'mixin', result: posts, builds: widget.builds.next(), ); } } /// 4. `QueryController` — a plain `ValueListenable`, so `ListenableBuilder` /// reads it with nothing from this package involved. /// /// The controller is the screen's, because the `Refetch` button up top drives /// the same one. class _ControllerCard extends StatelessWidget { const _ControllerCard({required this.controller, required this.builds}); final QueryController, List> controller; final _Builds builds; @override Widget build(BuildContext context) => ListenableBuilder( listenable: controller, builder: (context, _) => _ReaderCard( title: '4. QueryController', code: 'QueryController.create(client, postsQuery(api))', note: 'A ListenableBuilder rebuilds on every notification, ' 'because a Listenable carries no value to compare. That can ' 'leave this count one ahead of the other four, which drop a ' 'notification whose result they have already built.', name: 'controller', result: controller.value, builds: builds.next(), ), ); } /// 5. The core on its own: a `QueryObserver` from `client.observe(...)`, /// subscribed by hand and destroyed in `dispose`. /// /// This is what the binding is a shell around — the same result, the same /// cache entry, roughly twenty lines of bookkeeping the other four do for /// you. class _ObserverCard extends StatefulWidget { const _ObserverCard({required this.builds}); final _Builds builds; @override State<_ObserverCard> createState() => _ObserverCardState(); } class _ObserverCardState extends State<_ObserverCard> { late final QueryObserver, List> _observer; late final void Function() _unsubscribe; /// Guards the very first notification: `subscribe` can report on the spot, /// and a `setState` from `initState` is not allowed. bool _built = false; @override void initState() { super.initState(); final api = context.getInheritedWidgetOfExactType()!.api; final client = QueryClientProvider.read(context); _observer = client.observe, List>(postsQuery(api)); // Through the notify manager, the way every controller in the binding // does it: that is what puts the notification on the scheduler the // provider installed, so a result arriving mid-build lands after the // frame instead of inside it. _unsubscribe = _observer.subscribe( client.notifyManager.batchCalls>>((_) { if (_built && mounted) { setState(() {}); } }), ); } @override void dispose() { _unsubscribe(); // The observer is not the subscription: without this it stays attached to // the query, and the entry never drops to zero observers. _observer.destroy(); super.dispose(); } @override Widget build(BuildContext context) { _built = true; return _ReaderCard( title: '5. QueryObserver, no binding', code: 'client.observe(postsQuery(api)).subscribe(…)', note: 'The core alone. subscribe in initState, unsubscribe and destroy ' 'in dispose — miss the destroy and the entry keeps an observer ' 'forever.', name: 'observer', result: _observer.currentResult, builds: widget.builds.next(), ); } } /// One reader's card: the same three facts everywhere, plus the line of code /// that produced them. class _ReaderCard extends StatelessWidget { const _ReaderCard({ required this.title, required this.code, required this.note, required this.name, required this.result, required this.builds, }); final String title; /// The call, in one line — what a reader compares the styles by. final String code; final String note; /// The group is `reader `, in both test layers. final String name; final QueryResult> result; final int builds; @override Widget build(BuildContext context) { final posts = switch (result) { QueryPending() => 'posts=…', QueryError(staleData: null) => 'posts=error', QuerySuccess(:final data) || QueryError(staleData: final data!) => 'posts=${data.length}', }; return SectionCard( title: title, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(code, style: monoStyle), const SizedBox(height: 8), Text(note), const SizedBox(height: 8), FactGroup( name: 'reader $name', facts: [ posts, 'status=${result.status.name}', 'fetching=${result.isFetching}', 'builds=$builds', ], ), if (result case QueryError(:final error)) ...[ const SizedBox(height: 8), Notice('$error', error: true), ], ], ), ); } } /// 6. `QueryListener` — not a sixth way of reading the query, but the answer /// to the other question: what should *happen* when it changes. /// /// It borrows card 4's controller. Borrowing is the whole contract: the /// listener never disposes it, adds no observer of its own — the strip still /// says `observers=5` — and hands its child straight back, so no notification /// it receives rebuilds anything below it. class _ListenerCard extends StatefulWidget { const _ListenerCard({ required this.controller, required this.client, required this.childBuilds, }); final QueryController, List> controller; final QueryClient client; final _Builds childBuilds; @override State<_ListenerCard> createState() => _ListenerCardState(); } class _ListenerCardState extends State<_ListenerCard> { /// The accepted transitions, oldest first, the last [_logLength] of them. final List _log = []; int _calls = 0; int _skips = 0; String _last = 'none'; /// Where the transition currently being judged came from, written by /// [_dataChanged] for [_record] — the callback is handed the new result /// only, and the pair is what makes the line readable. String _from = 'none'; static const int _logLength = 4; /// The listener's child, built once and kept. /// /// Every rebuild of this card hands `QueryListener` the same widget /// *instance*, so the element is reused and this subtree never builds /// again. That is what makes `child-builds` a proof rather than a /// coincidence: the counter would move if anything rebuilt it, and the card /// around it rebuilds on every call and every refusal below. late final Widget _child = _ListenerChild(builds: widget.childBuilds); /// A result as one word: the status, what the query is doing, and how much /// data there is. `fetchStatus` is in it on purpose — it is what shows that /// a refused transition still moved the comparison forward. static String _shape(QueryResult> result) { final data = result.dataOrNull; return '${result.status.name}/${result.fetchStatus.name}:' '${data == null ? 'none' : data.length}'; } /// What this screen means by "the data changed": the posts, compared post /// by post. `dataUpdatedAt` moves every time a fetch lands and /// `fetchStatus` every time one starts; neither is a change of data, and a /// refetch that returns the same 30 posts is refused here. /// /// Every transition passes through this, accepted or not, which is why /// [_from] is written for all of them. bool _dataChanged( QueryResult> previous, QueryResult> next, ) { _from = _shape(previous); if (!_samePosts(previous.dataOrNull, next.dataOrNull)) { return true; } setState(() => _skips++); return false; } static bool _samePosts(List? previous, List? next) { if (previous == null || next == null) { return previous == null && next == null; } if (previous.length != next.length) { return false; } for (var i = 0; i < previous.length; i++) { if (previous[i] != next[i]) { return false; } } return true; } /// The side effect. A `setState` from here is safe — so would a `SnackBar` /// or a route push be — because the callback is delivered off the build /// phase even when the result changed while the tree was building. void _record(BuildContext context, QueryResult> next) { setState(() { _calls++; _last = '$_from->${_shape(next)}'; _log.add('#$_calls $_last'); if (_log.length > _logLength) { _log.removeAt(0); } }); } /// A change of the data with nothing fetched for it: `/posts` is read-only, /// and a cache write is a change of data like any other. Refetch up top /// puts the dropped post back. void _drop() { widget.client.updateQueryData>( ShowcaseKeys.posts, (previous) => previous == null || previous.isEmpty ? null : previous.sublist(1), ); } /// Two writes, each delivered as it happens: two transitions. void _dropTwo() { _drop(); _drop(); } /// The same two writes inside one batch on the shared notify manager — /// the one the app's client was built on. Every notification is held until /// the batch ends, and a notification carries the controller's latest /// value, so the listener hears one transition, to the second value. void _dropTwoBatched() => NotifyManager.shared.batch(_dropTwo); @override Widget build(BuildContext context) => SectionCard( title: '6. QueryListener, a side effect', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Not a sixth way of reading the query: a way of reacting to ' 'it. The listener borrows the controller card 4 holds — it ' 'never disposes it, and it adds no observer, so the strip ' 'above still says observers=5 — and returns its child ' 'unchanged, which is why child-builds below stays at 1 however ' 'often the query changes.', ), const SizedBox(height: 8), const Text( 'Nothing is delivered on mount, so the first call is the first ' 'fetch landing. listenWhen accepts a change of the posts and ' 'refuses everything else, and a refused transition still ' 'advances what the next comparison starts from — which is why ' 'a change during a fetch is logged from the fetching state.', ), const SizedBox(height: 8), const Text( 'QueryListener(controller: …, listenWhen: …, listener: …, ' 'child: …)', style: monoStyle, ), const SizedBox(height: 12), Toolbar( children: [ ActionButton(label: 'Drop a post', onPressed: _drop), ActionButton(label: 'Drop two posts', onPressed: _dropTwo), ActionButton( label: 'Drop two posts, batched', onPressed: _dropTwoBatched, ), ], ), const SizedBox(height: 8), const Text( 'Drop a post writes the cache directly, so the data genuinely ' 'changes and the listener has something to say. Refetch and ' 'Invalidate up top return the same 30 posts, and it says ' 'nothing about either. Drop two posts is two writes and two ' 'transitions; the batched pair runs inside ' 'NotifyManager.shared.batch — the app\'s client is built on ' 'the shared manager — which holds every notification until ' 'the batch ends, and a notification carries the latest value: ' 'one transition, straight to the second value.', ), const SizedBox(height: 12), SemanticsGroup( name: 'listener', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FactList([ 'listener-calls=$_calls', 'listener-skips=$_skips', 'last=$_last', ]), const SizedBox(height: 4), for (final line in _log) Text(line, style: monoStyle), const SizedBox(height: 8), QueryListener, List>( controller: widget.controller, listenWhen: _dataChanged, listener: _record, child: _child, ), ], ), ), ], ), ); } /// The listener's child: handed back unchanged, and rebuilt by nothing the /// controller does. class _ListenerChild extends StatelessWidget { const _ListenerChild({required this.builds}); final _Builds builds; @override Widget build(BuildContext context) => Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('child-builds=${builds.next()}', style: monoStyle), const SizedBox(width: 12), const Expanded( child: Text( 'The child of the listener. It built once, on mount, and no ' 'transition above has touched it since.', ), ), ], ); } /// 7. The same mutation through three of the styles, side by side, with the /// counter it invalidates read through a fourth — and a `MutationListener` /// over the controller-backed one. class _MutationCard extends StatelessWidget { const _MutationCard({ required this.api, required this.client, required this.controller, }); final ShowcaseApi api; final QueryClient client; final MutationController controller; @override Widget build(BuildContext context) => SectionCard( title: '7. One mutation, three styles', trailing: QueryBuilder( options: counterQuery(api), builder: (context, counter) => Text( switch (counter) { QueryPending() => 'counter=…', QueryError(staleData: null) => 'counter=error', QuerySuccess(:final data) || QueryError(staleData: final data!) => 'counter=$data', }, style: monoStyle, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Mutations have the same four shapes as queries, and a mutation ' 'is owned by the widget that asks for it — these three are ' 'separate runs of the same options. All invalidate the ' 'counter, and all stay pending until that refetch has landed, ' 'because onSuccess returns its future. The third is a ' 'MutationController read through a ListenableBuilder, with a ' 'MutationListener over it: the side-effect half for mutations, ' 'silent on mount and called once per state change.', ), const SizedBox(height: 12), _BuilderMutation(api: api, client: client), const SizedBox(height: 12), _ContextMutation(api: api, client: client), const SizedBox(height: 12), _ControllerMutation(controller: controller), ], ), ); } /// The third style, with the listener: `MutationListener` borrows the /// screen's controller, disposes nothing, and logs each transition as /// `from->to` by status. class _ControllerMutation extends StatefulWidget { const _ControllerMutation({required this.controller}); final MutationController controller; @override State<_ControllerMutation> createState() => _ControllerMutationState(); } class _ControllerMutationState extends State<_ControllerMutation> { int _calls = 0; String _last = 'none'; String _from = 'none'; bool _statusChanged( MutationResult previous, MutationResult next, ) { _from = previous.status.name; return previous.status != next.status; } void _record(BuildContext context, MutationResult next) { setState(() { _calls++; _last = '$_from->${next.status.name}'; }); } @override Widget build(BuildContext context) => MutationListener( controller: widget.controller, listenWhen: _statusChanged, listener: _record, child: ListenableBuilder( listenable: widget.controller, builder: (context, _) => _MutationPanel( code: 'MutationController(client, …) + MutationListener', name: 'controller', label: 'Increment (controller)', result: widget.controller.value, onPressed: () => widget.controller.mutate(1), extraFacts: [ 'mutation-listener-calls=$_calls', 'mutation-last=$_last', ], ), ), ); } class _BuilderMutation extends StatelessWidget { const _BuilderMutation({required this.api, required this.client}); final ShowcaseApi api; final QueryClient client; @override Widget build(BuildContext context) => MutationBuilder( options: incrementMutation(api, client), builder: (context, increment) => _MutationPanel( code: 'MutationBuilder(options: …)', name: 'builder', label: 'Increment (builder)', result: increment.value, onPressed: () => increment.mutate(1), ), ); } class _ContextMutation extends StatelessWidget { const _ContextMutation({required this.api, required this.client}); final ShowcaseApi api; final QueryClient client; @override Widget build(BuildContext context) { final increment = context.mutation(incrementMutation(api, client)); return _MutationPanel( code: 'context.mutation(incrementMutation(api, client))', name: 'context', label: 'Increment (context)', result: increment.value, onPressed: () => increment.mutate(1), ); } } /// One mutation reader: the call, the button, and the result's facts. class _MutationPanel extends StatelessWidget { const _MutationPanel({ required this.code, required this.name, required this.label, required this.result, required this.onPressed, this.extraFacts = const [], }); final String code; /// The group is `mutation `, in both test layers. final String name; final String label; final MutationResult result; final VoidCallback onPressed; /// More `key=value` texts for the same group — the listener's, on the /// third panel. final List extraFacts; @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(code, style: monoStyle), const SizedBox(height: 8), Toolbar( children: [ ActionButton(label: label, filled: true, onPressed: onPressed), ], ), const SizedBox(height: 8), FactGroup( name: 'mutation $name', facts: [ 'status=${result.status.name}', if (result case MutationSuccess(:final data)) 'data=$data', if (result case MutationError(:final error)) 'error=$error', ...extraFacts, ], ), ], ); } /// 8. The infinite shapes: one infinite entry read three ways, the two /// styles the paging screens do not use plus the core's own observer, and an /// `InfiniteQueryListener` over the mixin's controller. class _InfiniteCard extends StatelessWidget { const _InfiniteCard({required this.api}); final ShowcaseApi api; @override Widget build(BuildContext context) => SectionCard( title: '8. The infinite shapes', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'An infinite query has the same four styles. The builder is on ' 'the load-more screen and the controller on max-pages; here ' 'are the other two — context.infiniteQuery and ' 'watchInfiniteQuery, which hand back the controller because ' 'paging lives on it — next to the core\'s ' 'client.observeInfinite. Three readers, one entry: the strip ' 'below says observers=3 and fetches=1. Load next goes through ' 'the mixin\'s controller, and all three show the page. The ' 'InfiniteQueryListener borrows that same controller and logs ' 'each change of the page count.', ), const SizedBox(height: 12), _InfiniteContextReader(api: api), const SizedBox(height: 8), _InfiniteMixinReader(api: api), const SizedBox(height: 8), _InfiniteObserverReader(api: api), ], ), ); } /// One infinite reader's row: the call and its facts, in the group /// `infinite `. class _InfiniteRow extends StatelessWidget { const _InfiniteRow({ required this.code, required this.name, required this.result, this.extraFacts = const [], this.trailing = const [], }); final String code; final String name; final QueryResult result; final List extraFacts; final List trailing; @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(code, style: monoStyle), const SizedBox(height: 4), SemanticsGroup( name: 'infinite $name', // Not a bare [FactGroup]: one card puts a button beside the facts, // and it belongs inside the group the test addresses. child: Wrap( spacing: 12, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ FactList([ 'pages=${_pagesOf(result)}', 'status=${result.status.name}', ...extraFacts, ]), ...trailing, ], ), ), ], ); } /// `context.infiniteQuery`, in a `StatelessWidget`. class _InfiniteContextReader extends StatelessWidget { const _InfiniteContextReader({required this.api}); final ShowcaseApi api; @override Widget build(BuildContext context) { final projects = context.infiniteQuery(stylesQuery(api)); return _InfiniteRow( code: 'context.infiniteQuery(stylesQuery(api))', name: 'context', result: projects.value, ); } } /// `watchInfiniteQuery`, in a `QueryMixin` — and the listener, which borrows /// the controller the mixin hands back. The button pages through that same /// controller. class _InfiniteMixinReader extends StatefulWidget { const _InfiniteMixinReader({required this.api}); final ShowcaseApi api; @override State<_InfiniteMixinReader> createState() => _InfiniteMixinReaderState(); } class _InfiniteMixinReaderState extends State<_InfiniteMixinReader> with QueryMixin { int _calls = 0; String _last = 'none'; String _from = 'none'; /// A change of the page count, and nothing else: a page fetch starting /// moves `fetchStatus` and is refused here. bool _pagesChanged( QueryResult previous, QueryResult next, ) { _from = _pagesOf(previous); return _pagesOf(previous) != _pagesOf(next); } void _record(BuildContext context, QueryResult next) { setState(() { _calls++; _last = '$_from->${_pagesOf(next)}'; }); } @override Widget build(BuildContext context) { final projects = watchInfiniteQuery(stylesQuery(widget.api)); return InfiniteQueryListener( controller: projects, listenWhen: _pagesChanged, listener: _record, child: _InfiniteRow( code: 'watchInfiniteQuery(stylesQuery(api)) + InfiniteQueryListener', name: 'mixin', result: projects.value, extraFacts: [ 'infinite-listener-calls=$_calls', 'infinite-last=$_last', ], trailing: [ ActionButton( label: 'Load next', filled: true, onPressed: projects.hasNextPage && !projects.isFetchingNextPage ? () => projects.fetchNextPage().ignore() : null, ), ], ), ); } } /// The core alone: an `InfiniteQueryObserver` from `client.observeInfinite`, /// subscribed by hand and destroyed in `dispose`, like card 5. class _InfiniteObserverReader extends StatefulWidget { const _InfiniteObserverReader({required this.api}); final ShowcaseApi api; @override State<_InfiniteObserverReader> createState() => _InfiniteObserverReaderState(); } class _InfiniteObserverReaderState extends State<_InfiniteObserverReader> { late final InfiniteQueryObserver _observer; late final void Function() _unsubscribe; bool _built = false; @override void initState() { super.initState(); final client = QueryClientProvider.read(context); _observer = client.observeInfinite(stylesQuery(widget.api)); _unsubscribe = _observer.subscribe( client.notifyManager.batchCalls>((_) { if (_built && mounted) { setState(() {}); } }), ); } @override void dispose() { _unsubscribe(); _observer.destroy(); super.dispose(); } @override Widget build(BuildContext context) { _built = true; return _InfiniteRow( code: 'client.observeInfinite(stylesQuery(api)).subscribe(…)', name: 'observer', result: _observer.currentResult, ); } } ```
## Related - Guides: [Four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md), [Side effects](https://dualmeta-gmbh.github.io/query_kit/docs/guides/side-effects.md), [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md), [Infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md) - Tested by `test/features/four_call_styles_test.dart` (widget) and `e2e/tests/four_call_styles.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/four_call_styles) --- # Default query function > Queries that are nothing but a key, fetched by one function registered as a default for a key prefix, and a mutation that gets its function the same way. None of the queries on this screen has a `queryFn`. One function, registered with `setQueryDefaults` for every key under `['api', …]`, reads the request path out of the key and fetches it, so a query is its key plus a `select` that parses the JSON. A mutation carrying only a `mutationKey` gets its function from `setMutationDefaults` in the same way. Reach for this when an app talks to one REST backend whose paths map cleanly onto keys, say a device list, a device's detail and its event log, and writing the same fetch for each of them would only repeat the path. Live demo: [Default query function](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/default-query-function), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/default_query_function)). A query function derived from the key, set once as a default. ## What to try - Read the *Defaults on the client* card: while the screen is open it reports `default queryFn=set` and `default mutationFn=set`. - Watch the three cards fill: the posts list, post #1 and its comments, each fetched once through the default (`fetches=1` in the `posts`, `post-1` and `comments-1` strips). - Press *Fetch a missing post*. The key `['api', '/posts/999']` goes through the same default, the backend answers 404, and the card shows its *Post not found* message at once: that query sets `retry: RetryPolicy.never`. - Press *Create a todo*. The mutation has nothing but a key; the default `mutationFn` posts the todo and the card shows the new todo's id and text. ## The code The defaults: the query function takes the path from the key's second part and returns the JSON untyped, because one function serves every key under the prefix; the mutation function is registered under the mutation's key. [`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`, lines 54–59](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart#L54-L59): ```dart QueryDefaults apiDefaults(ShowcaseApi api) => QueryDefaults( queryFn: (context) => api.getJson( context.queryKey.parts[1]! as String, signal: context.signal, ), ); ``` [`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`, lines 63–65](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart#L63-L65): ```dart MutationDefaults createTodoDefaults(ShowcaseApi api) => MutationDefaults( mutationFn: (variables) => api.createTodo(variables! as String), ); ``` The queries carry a key and a `select` and nothing else. The selectors are top-level functions, so options built on every build compare equal. [`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`, lines 72–101](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart#L72-L101): ```dart QuerySelectOptions> postsQuery() => QuerySelectOptions>( queryKey: apiKey('/posts'), select: _parsePosts, ); QuerySelectOptions postQuery(int id) => QuerySelectOptions( queryKey: apiKey('/posts/$id'), select: _parsePost, ); QuerySelectOptions> commentsQuery(int postId) => QuerySelectOptions>( queryKey: apiKey('/posts/$postId/comments'), select: _parseComments, ); /// A key whose path has no post behind it. Every other option is still the /// query's own: what the backend's 404 means is settled here, once, rather /// than after the default backoff. QuerySelectOptions missingPostQuery() => QuerySelectOptions( queryKey: apiKey('/posts/999'), select: _parsePost, retry: RetryPolicy.never, ); MutationOptions createTodoMutation() => MutationOptions(mutationKey: createTodoKey); ``` The screen registers the defaults in `initState` and blanks them in `dispose`, so no other screen of the showcase sees them; in an app you would register them once, next to where the client is created. It reads the queries with `QueryMixin`'s `watchSelectQuery` and `watchMutation`. [`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`, lines 128–158](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart#L128-L158): ```dart void initState() { super.initState(); // Plain reads, not dependencies: a State may not depend on an inherited // widget before `initState` has completed, and neither the client nor // the api changes underneath a screen. _client = QueryClientProvider.read(context); final api = context.getInheritedWidgetOfExactType()!.api; _client.setQueryDefaults(apiPrefix, apiDefaults(api)); _client.setMutationDefaults(createTodoKey, createTodoDefaults(api)); } @override void dispose() { // Blanked rather than removed — the client has no "unset" — which comes // to the same: the next query under the prefix finds no `queryFn`. _client.setQueryDefaults(apiPrefix, const QueryDefaults()); _client.setMutationDefaults(createTodoKey, const MutationDefaults()); super.dispose(); } @override Widget build(BuildContext context) { final posts = watchSelectQuery>(postsQuery()); final post = watchSelectQuery(postQuery(1)); final comments = watchSelectQuery>(commentsQuery(1)); // Read only once asked for: the mixin releases a key a build stops // reading, and creates the observer the first time one reads it. final missing = _fetchMissing ? watchSelectQuery(missingPostQuery()) : null; final create = watchMutation(createTodoMutation()); ```
The whole screen [`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart): ```dart /// Upstream's `default-query-function` example: no query on this screen /// carries a `queryFn`. One function, registered once as a default for every /// key under `['api', …]`, reads the request path out of the key and fetches /// it, so a query is nothing but its key. The mutation twin, /// `setMutationDefaults`, hands a mutation with only a `mutationKey` its /// function the same way. /// /// Upstream sets the function client-wide; here it is a per-key default on /// the app's shared client, registered in `initState` and blanked again in /// `dispose`, so no other screen sees it. /// /// Proofs (widget tests in `test/features/default_query_function_test.dart`, /// end-to-end in `e2e/tests/default_query_function.spec.ts`): three keyed /// queries fetch exactly one request each and show parsed data; the defaults /// panel reports `default queryFn=set`; a mutation with only a key posts once /// and shows the new todo's id; leaving the screen blanks the defaults, after /// which a query on the same key fails with `MissingQueryFunctionError` /// instead of fetching; a key whose path has no post behind it shows the /// backend's 404 message. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature defaultQueryFunctionFeature = Feature( id: 'default-query-function', title: 'Default query function', summary: 'A query function derived from the key, set once as a default.', upstream: 'default-query-function', ); /// The prefix the default query function is registered under. QueryKey get apiPrefix => QueryKey(const ['api']); /// The key for a GET of [path]. The key is the request: `['api', '/posts']` /// is fetched by the default as `GET /api/posts`. QueryKey apiKey(String path) => QueryKey(['api', path]); /// The key of the one mutation on this screen, and of its default. QueryKey get createTodoKey => QueryKey(const ['api', 'todos', 'create']); /// The default: the path is the key's second part, and the JSON comes back /// untyped — one function serves every key under the prefix, so it cannot /// know the type; each query's `select` does. QueryDefaults apiDefaults(ShowcaseApi api) => QueryDefaults( queryFn: (context) => api.getJson( context.queryKey.parts[1]! as String, signal: context.signal, ), ); /// The mutation twin: `createTodo` under the mutation's key, erased the same /// way, so the mutation itself carries nothing but the key. MutationDefaults createTodoDefaults(ShowcaseApi api) => MutationDefaults( mutationFn: (variables) => api.createTodo(variables! as String), ); // The queries. None takes the api: they have no function to close over. // The selectors are top-level functions rather than closures, so the options // built on every build compare equal and the parsed list is kept, not // re-parsed into a fresh one per rebuild. QuerySelectOptions> postsQuery() => QuerySelectOptions>( queryKey: apiKey('/posts'), select: _parsePosts, ); QuerySelectOptions postQuery(int id) => QuerySelectOptions( queryKey: apiKey('/posts/$id'), select: _parsePost, ); QuerySelectOptions> commentsQuery(int postId) => QuerySelectOptions>( queryKey: apiKey('/posts/$postId/comments'), select: _parseComments, ); /// A key whose path has no post behind it. Every other option is still the /// query's own: what the backend's 404 means is settled here, once, rather /// than after the default backoff. QuerySelectOptions missingPostQuery() => QuerySelectOptions( queryKey: apiKey('/posts/999'), select: _parsePost, retry: RetryPolicy.never, ); MutationOptions createTodoMutation() => MutationOptions(mutationKey: createTodoKey); List _parsePosts(Object? json) => (json! as List) .map((item) => Post.fromJson(item! as Map)) .toList(); Post _parsePost(Object? json) => Post.fromJson(json! as Map); List _parseComments(Object? json) => (json! as List) .map((item) => Comment.fromJson(item! as Map)) .toList(); class DefaultQueryFunctionScreen extends StatefulWidget { const DefaultQueryFunctionScreen({super.key}); @override State createState() => _DefaultQueryFunctionScreenState(); } class _DefaultQueryFunctionScreenState extends State with QueryMixin { /// Kept from `initState` for `dispose`, which may not look anything up. late final QueryClient _client; bool _fetchMissing = false; @override void initState() { super.initState(); // Plain reads, not dependencies: a State may not depend on an inherited // widget before `initState` has completed, and neither the client nor // the api changes underneath a screen. _client = QueryClientProvider.read(context); final api = context.getInheritedWidgetOfExactType()!.api; _client.setQueryDefaults(apiPrefix, apiDefaults(api)); _client.setMutationDefaults(createTodoKey, createTodoDefaults(api)); } @override void dispose() { // Blanked rather than removed — the client has no "unset" — which comes // to the same: the next query under the prefix finds no `queryFn`. _client.setQueryDefaults(apiPrefix, const QueryDefaults()); _client.setMutationDefaults(createTodoKey, const MutationDefaults()); super.dispose(); } @override Widget build(BuildContext context) { final posts = watchSelectQuery>(postsQuery()); final post = watchSelectQuery(postQuery(1)); final comments = watchSelectQuery>(commentsQuery(1)); // Read only once asked for: the mixin releases a key a build stops // reading, and creates the observer the first time one reads it. final missing = _fetchMissing ? watchSelectQuery(missingPostQuery()) : null; final create = watchMutation(createTodoMutation()); final client = queryClient; final queryDefault = client.getQueryDefaults(apiKey('/posts'))?.queryFn; final mutationDefault = client.getMutationDefaults(createTodoKey)?.mutationFn; return FeatureScaffold( feature: defaultQueryFunctionFeature, children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Notice( 'The key is the request: none of these queries has a queryFn, ' "and the default registered for ['api', …] fetches the path it " 'finds in the key.', ), ), SectionCard( title: 'Defaults on the client', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Asked for ${apiKey('/posts').debugString} and ' '${createTodoKey.debugString}:', ), const SizedBox(height: 4), Text('default queryFn=${_setOrNone(queryDefault)}'), Text('default mutationFn=${_setOrNone(mutationDefault)}'), ], ), ), SectionCard( title: 'Posts', trailing: _fetchingPill(posts), child: _view>( posts, (data) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final item in data.take(5)) Text('#${item.id} ${item.title}'), if (data.length > 5) Text('… and ${data.length - 5} more'), const SizedBox(height: 4), Text('posts=${data.length}'), ], ), ), ), QueryDebugStrip(queryKey: apiKey('/posts'), label: 'posts'), SectionCard( title: 'Post #1', trailing: _fetchingPill(post), child: _view( post, (data) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( data.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text(data.body), ], ), ), ), QueryDebugStrip(queryKey: apiKey('/posts/1'), label: 'post-1'), SectionCard( title: 'Comments on post #1', trailing: _fetchingPill(comments), child: _view>( comments, (data) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final comment in data) _CommentRow(comment.author, comment.text), const SizedBox(height: 4), Text('comments=${data.length}'), ], ), ), ), QueryDebugStrip( queryKey: apiKey('/posts/1/comments'), label: 'comments-1', ), SectionCard( title: 'A key with no post behind it', trailing: Tooltip( message: 'Fetch a missing post', child: OutlinedButton( onPressed: _fetchMissing ? null : () => setState(() => _fetchMissing = true), child: const Text('Fetch a missing post'), ), ), child: missing == null ? const Text( "['api', '/posts/999'] goes through the same default; " 'the backend decides what it answers.', ) : _view(missing, (data) => Text(data.title)), ), QueryDebugStrip(queryKey: apiKey('/posts/999'), label: 'post-999'), SectionCard( title: 'A mutation with only a key', trailing: Tooltip( message: 'Create a todo', child: FilledButton.tonal( onPressed: create.value.isPending ? null : () => create.mutate('Written by the default mutationFn'), child: const Text('Create a todo'), ), ), child: switch (create.value) { MutationIdle() => const Text( 'The mutation carries its key and nothing else; its ' 'function is the default registered for that key.', ), MutationPending() => const Text('creating…'), MutationSuccess(:final data) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('new todo id=${data.id}'), Text(data.text), ], ), MutationError(:final error) => Notice('$error', error: true), }, ), ], ); } static String _setOrNone(Object? function) => function == null ? 'none' : 'set'; static Widget? _fetchingPill(QueryResult result) => result.isFetching && !result.isPending ? const Pill('refreshing') : null; /// One query's states; the data goes through [body]. static Widget _view(QueryResult result, Widget Function(T data) body) => switch (result) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: 20, width: 240), SizedBox(height: 8), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final T data) => body(data), }; } /// One comment: its author in a fixed-width column, its text beside it. /// /// It lives here, beside its one caller, rather than in `lib/shared/`: a /// module in `shared/` is something more than one feature calls. class _CommentRow extends StatelessWidget { const _CommentRow(this.label, this.value); final String label; final String value; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ SizedBox( width: 140, child: Text( label, style: Theme.of(context).textTheme.labelLarge, ), ), Expanded(child: Text(value)), ], ), ); } ```
## Related - Guides: [Default query function](https://dualmeta-gmbh.github.io/query_kit/docs/guides/default-query-function.md), [Query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md) - Upstream: TanStack's React [`default-query-function`](https://github.com/TanStack/query/tree/main/examples/react/default-query-function) example - Tested by `test/features/default_query_function_test.dart` (widget) and `e2e/tests/default_query_function.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/default_query_function) --- # Dependent queries > A query that holds back until another has data, with Enabled.when, and a switch that holds it back regardless with Enabled.no. Choosing a post starts its query; the post's comments have a query of their own that is `Enabled.when` the post has data, so it sits `pending` and `idle` until the post lands and only then sends its request. A *Pause comments* box switches the comments to `Enabled.no`, the other way to keep a query from running. The pattern fits wherever one request needs the answer of another: a user's profile before the projects it lists, a device's detail before the firmware channel it names, a selected order before its shipment tracking. Live demo: [Dependent queries](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/dependent-queries), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/dependent_queries)). A query that waits for another to have data. ## What to try - Before choosing, nothing is requested: the screen reads *Choose a post first.* and there are no debug strips. - Press *Choose post 2*. While the post is fetching, the comments card shows *waiting for the post* and `comments enabled=false`; once the post's title appears, the comments turn to fetching and arrive with one request. - Tick *Pause comments* and choose another post. The post loads, but the comments stay `status=pending`, `fetchStatus=idle` with a *paused* pill; untick it and they are fetched once. - Switch to another post and back. Each choice re-keys both queries; the post you return to shows its title and comments at once from the cache, because the entries you left stayed there without a reader, and a background refetch follows. *Clear choice* releases both readers. ## The code The comments' options take their `enabled` from the caller, so the screen decides when they may run. [`examples/showcase/lib/features/dependent_queries/dependent_queries_screen.dart`, lines 49–58](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/dependent_queries/dependent_queries_screen.dart#L49-L58): ```dart QueryObserverOptions> commentsQuery( ShowcaseApi api, int postId, { required Enabled enabled, }) => QueryObserverOptions>( queryKey: ShowcaseKeys.comments(postId), queryFn: (context) => api.comments(postId, signal: context.signal), enabled: enabled, ); ``` Both are read with `context.query` in one build. The predicate closes over this build's post result: when the post's data arrives the widget rebuilds, the predicate says yes, and the comments start. [`examples/showcase/lib/features/dependent_queries/dependent_queries_screen.dart`, lines 145–155](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/dependent_queries/dependent_queries_screen.dart#L145-L155): ```dart final post = context.query(postQuery(api, id)); // The dependency itself: the comments may run once the post has data. // The predicate is handed the comments query and ignores it; what it // closes over is this build's post result. final comments = context.query(commentsQuery( api, id, enabled: pauseComments ? Enabled.no : Enabled.when((_) => post.dataOrNull != null), )); ``` A disabled query that has never fetched is `QueryPending` with `FetchStatus.idle`, so the switch tells "not started" apart from "loading". [`examples/showcase/lib/features/dependent_queries/dependent_queries_screen.dart`, lines 197–204](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/dependent_queries/dependent_queries_screen.dart#L197-L204): ```dart QueryPending(fetchStatus: FetchStatus.fetching) => const SkeletonBox(), QueryPending() => Text( pauseComments ? 'Paused: no request until the box is unticked.' : 'Not started: the post has no data yet.', style: theme.textTheme.bodySmall, ), ```
The whole screen [`examples/showcase/lib/features/dependent_queries/dependent_queries_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/dependent_queries/dependent_queries_screen.dart): ```dart /// Dependent queries: a query that waits for another's data, upstream's /// `enabled` used the way its dependent-queries guide shows. Port-specific — /// there is no `react` example for it; the guide is /// `docs/framework/react/guides/dependent-queries.md`. /// /// A chooser picks a post. The post's query exists only once a choice is /// made; the comments' query exists alongside it but is `Enabled.when` the /// post has data, so it starts `pending`/`idle`, turns `fetching` the moment /// the post lands, and never runs in parallel with it. A "Pause comments" /// checkbox forces `Enabled.no` regardless of the post, which is the other /// way to hold a query back. Every query here is read with `context.query`. /// /// Proofs (widget tests in `test/features/dependent_queries_test.dart`, /// end-to-end in `e2e/tests/dependent_queries.spec.ts`): before a choice no /// request goes out; choosing a post fetches it first and the comments once /// afterwards, never before; pausing keeps the comments idle and disabled /// even with the post in hand, and unpausing fetches them once; switching /// posts re-keys both queries and keeps the old entries in the cache; clearing /// the choice releases the observers. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature dependentQueriesFeature = Feature( id: 'dependent-queries', title: 'Dependent queries', summary: 'A query that waits for another to have data.', ); /// The post the comments depend on. QueryObserverOptions postQuery(ShowcaseApi api, int id) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), ); /// The comments of a post. [enabled] is the whole point of the screen: the /// caller decides when this query may run, and until then it sits /// `pending`/`idle` without a request. QueryObserverOptions> commentsQuery( ShowcaseApi api, int postId, { required Enabled enabled, }) => QueryObserverOptions>( queryKey: ShowcaseKeys.comments(postId), queryFn: (context) => api.comments(postId, signal: context.signal), enabled: enabled, ); class DependentQueriesScreen extends StatefulWidget { const DependentQueriesScreen({super.key}); @override State createState() => _DependentQueriesScreenState(); } class _DependentQueriesScreenState extends State { int? _chosen; bool _paused = false; @override Widget build(BuildContext context) => FeatureScaffold( feature: dependentQueriesFeature, children: [ SectionCard( title: 'Choose a post', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Wrap( spacing: 8, runSpacing: 8, children: [ for (final id in const [1, 2, 3]) _LabeledButton( label: 'Choose post $id', selected: _chosen == id, onPressed: () => setState(() => _chosen = id), ), _LabeledButton( label: 'Clear choice', onPressed: _chosen == null ? null : () => setState(() => _chosen = null), ), ], ), const SizedBox(height: 8), // No subtitle: a tile folds it into the checkbox's accessible // name, and the tests find the box by its title alone. CheckboxListTile( title: const Text('Pause comments'), contentPadding: EdgeInsets.zero, value: _paused, onChanged: (value) => setState(() => _paused = value ?? false), ), Text( 'Ticked, the comments query is Enabled.no whatever the post ' 'says; unticked, it is Enabled.when the post has data.', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), // The reads live in a widget of their own that is only in the tree // while a post is chosen. `context.query` releases a key a widget // stops reading, but a widget that stops reading altogether gives // the binding nothing to compare against; unmounting does. if (_chosen case final int id) _ChosenPost(id: id, pauseComments: _paused) else const SectionCard( title: 'Post', child: Text('Choose a post first.'), ), ], ); } /// The two dependent queries for one post, read in `build`. /// /// No `key` on purpose: when the choice changes, this same widget reads /// different keys on its next build, and the binding lets the old observers /// go after the frame — the old entries stay in the cache without a reader. class _ChosenPost extends StatelessWidget { const _ChosenPost({required this.id, required this.pauseComments}); final int id; final bool pauseComments; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final post = context.query(postQuery(api, id)); // The dependency itself: the comments may run once the post has data. // The predicate is handed the comments query and ignores it; what it // closes over is this build's post result. final comments = context.query(commentsQuery( api, id, enabled: pauseComments ? Enabled.no : Enabled.when((_) => post.dataOrNull != null), )); final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SectionCard( title: 'Post #$id', trailing: post.isFetching ? const Pill('fetching') : null, child: switch (post) { QueryPending() => const SkeletonBox(height: 20, width: 240), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Text(data.title, style: theme.textTheme.titleLarge), }, ), SectionCard( title: 'Comments', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (comments.isFetching) const Pill('fetching'), if (!comments.isEnabled) ...[ const SizedBox(width: 8), Pill( pauseComments ? 'paused' : 'waiting for the post', color: theme.colorScheme.tertiary, ), ], ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'comments enabled=${comments.isEnabled}', style: const TextStyle(fontFamily: 'monospace'), ), const SizedBox(height: 8), switch (comments) { QueryPending(fetchStatus: FetchStatus.fetching) => const SkeletonBox(), QueryPending() => Text( pauseComments ? 'Paused: no request until the box is unticked.' : 'Not started: the post has no data yet.', style: theme.textTheme.bodySmall, ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'comments count=${data.length}', style: const TextStyle(fontFamily: 'monospace'), ), for (final comment in data) Padding( padding: const EdgeInsets.only(top: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( comment.author, style: theme.textTheme.labelLarge, ), Text(comment.text), ], ), ), ], ), }, ], ), ), QueryDebugStrip(queryKey: ShowcaseKeys.post(id), label: 'post-$id'), QueryDebugStrip( queryKey: ShowcaseKeys.comments(id), label: 'comments-$id', ), ], ); } } /// A text button whose accessible name is exactly its label. /// /// The label is the button's own semantics; the tooltip is hover-only. A /// `Tooltip` that also reaches the semantics tree becomes a node of its own /// *around* the button's — a `FilledButton` has no `tooltip` of its own to /// place inside, the way an `IconButton` does — and the button underneath /// it would be left without a name. class _LabeledButton extends StatelessWidget { const _LabeledButton({ required this.label, required this.onPressed, this.selected = false, }); final String label; final VoidCallback? onPressed; final bool selected; @override Widget build(BuildContext context) => Tooltip( message: label, excludeFromSemantics: true, child: selected ? FilledButton(onPressed: onPressed, child: Text(label)) : FilledButton.tonal(onPressed: onPressed, child: Text(label)), ); } ```
## Related - Guides: [Dependent queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/dependent-queries.md), [Disabling queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/disabling-queries.md) - Tested by `test/features/dependent_queries_test.dart` (widget) and `e2e/tests/dependent_queries.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/dependent_queries) --- # Parallel queries > Three independent queries in one screen, fetched at the same time, and the client's count of everything that is fetching. Three posts, each with a query of its own, all started when the screen opens, so the three requests run at the same time and the screen waits one latency, not three. A status line shows `client.isFetching()`, the number of queries fetching anywhere in the cache. When the number of queries is fixed, parallel is simply what you get by writing them side by side: a dashboard with a device's status, its alerts and its energy readings, or a checkout screen reading the cart, the addresses and the payment methods. When the number changes at runtime, see [Query collections](https://dualmeta-gmbh.github.io/query_kit/docs/examples/query-collections.md). Live demo: [Parallel queries](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/parallel-queries), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/parallel_queries)). Several queries in one widget, and the global fetching count. ## What to try - Watch the screen open: all three cards show a *loading* pill at once, the status line reads `fetching=3`, and all three titles arrive together. - Press *Refetch all*. The titles stay on screen with a *refreshing* pill each, `fetching=3` drops back to `fetching=0`, and every strip counts `fetches=2`. - Press *Refetch post 2*: only `post-2` fetches, and the count reads `fetching=1`. - Turn on *Slow post 3*, then press *Refetch all*. Posts 1 and 2 settle while post 3 keeps fetching for two more seconds with `fetching=1`. Turning the switch on does not refetch by itself: it changes the query function, not the key. ## The code Each post has a `QueryController` of its own, created in `initState` and disposed in `dispose`. Each card listens to its own controller, and a `Listenable.merge` over the three lets the toolbar react to any of them. [`examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart`, lines 65–83](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart#L65-L83): ```dart late final ShowcaseApi _api; late final QueryController _post1; late final QueryController _post2; late final QueryController _post3; late final Listenable _all; bool _slowPost3 = false; @override void initState() { super.initState(); // Neither lookup subscribes: the api and the client are fixed for the // life of the app, and a subscribing lookup is not allowed here anyway. _api = context.getInheritedWidgetOfExactType()!.api; final client = QueryClientProvider.read(context); _post1 = QueryController.create(client, postQuery(_api, 1)); _post2 = QueryController.create(client, postQuery(_api, 2)); _post3 = QueryController.create(client, postQuery(_api, 3)); _all = Listenable.merge([_post1, _post2, _post3]); } ``` *Refetch all* is one `refetch()` per controller. *Slow post 3* swaps the options on the existing controller with `setOptions`: same key, another query function, so the entry and its data are kept and nothing refetches until the next fetch is asked for. [`examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart`, lines 96–100](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart#L96-L100): ```dart void _refetchAll() { for (final controller in _controllers) { controller.refetch().ignore(); } } ``` [`examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart`, lines 102–109](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart#L102-L109): ```dart void _setSlowPost3(bool slow) { setState(() => _slowPost3 = slow); // Same key, another query function: the observer keeps its entry and // does not refetch by itself. The delay applies from the next fetch on. _post3.setOptions( postQuery(_api, 3, delay: slow ? _slowDelay : null), ); } ``` Each card reads its controller's `value` in a `ListenableBuilder` and picks its pill from `isLoading` (a first fetch) or `isRefetching` (a fetch over data already there). [`examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart`, lines 193–233](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart#L193-L233): ```dart Widget build(BuildContext context) => ListenableBuilder( listenable: controller, builder: (context, _) { final post = controller.value; return SectionCard( title: 'Post #$index', trailing: switch (post) { QueryResult(isLoading: true) => const Pill('loading'), QueryResult(isRefetching: true) => const Pill('refreshing'), _ => const SizedBox.shrink(), }, child: switch (post) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: 20), SizedBox(height: 4), SkeletonBox(height: 20, width: 120), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (post case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Text( data.title, style: Theme.of(context).textTheme.titleMedium, ), ], ), }, ); }, ); ```
The whole screen [`examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart): ```dart /// Parallel queries: three independent queries in one widget, and the global /// fetching count. Port-specific — the upstream docs page /// `guides/parallel-queries.md` says a fixed number of queries needs nothing /// more than writing them side by side, which is exactly this screen. When the /// number is *not* fixed, `query-collections` is the screen: it uses /// `QueriesBuilder` over a list that changes at runtime. /// /// Each post has a `QueryController` of its own, created in `initState`, /// disposed in `dispose`, and read through a `ListenableBuilder`; the toolbar /// reads all three at once through `Listenable.merge`. The status line is /// `client.isFetching()` — how many queries are fetching right now, across /// the whole cache — rebuilt on every cache event. /// /// Proofs (widget tests in `test/features/parallel_queries_test.dart`, /// end-to-end in `e2e/tests/parallel_queries.spec.ts`): opening the screen /// starts all three requests at once (`fetching=3`, every strip fetching /// before any answer) and they all settle; `Refetch all` bumps every strip's /// `fetches`; `Refetch post 2` bumps only `post-2`; with post 3 held back the /// other two settle while it still fetches (`fetching=1`); leaving the screen /// releases every observer. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/cache_stats.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature parallelQueriesFeature = Feature( id: 'parallel-queries', title: 'Parallel queries', summary: 'Several queries in one widget, and the global fetching count.', ); /// One post's query. [delay] is the backend's per-request knob, so one of the /// three can be made to finish visibly later than the others. QueryObserverOptions postQuery( ShowcaseApi api, int id, { Duration? delay, }) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal, delay: delay), ); class ParallelQueriesScreen extends StatefulWidget { const ParallelQueriesScreen({super.key}); @override State createState() => _ParallelQueriesScreenState(); } class _ParallelQueriesScreenState extends State { static const Duration _slowDelay = Duration(seconds: 2); late final ShowcaseApi _api; late final QueryController _post1; late final QueryController _post2; late final QueryController _post3; late final Listenable _all; bool _slowPost3 = false; @override void initState() { super.initState(); // Neither lookup subscribes: the api and the client are fixed for the // life of the app, and a subscribing lookup is not allowed here anyway. _api = context.getInheritedWidgetOfExactType()!.api; final client = QueryClientProvider.read(context); _post1 = QueryController.create(client, postQuery(_api, 1)); _post2 = QueryController.create(client, postQuery(_api, 2)); _post3 = QueryController.create(client, postQuery(_api, 3)); _all = Listenable.merge([_post1, _post2, _post3]); } @override void dispose() { _post1.dispose(); _post2.dispose(); _post3.dispose(); super.dispose(); } List> get _controllers => >[_post1, _post2, _post3]; void _refetchAll() { for (final controller in _controllers) { controller.refetch().ignore(); } } void _setSlowPost3(bool slow) { setState(() => _slowPost3 = slow); // Same key, another query function: the observer keeps its entry and // does not refetch by itself. The delay applies from the next fetch on. _post3.setOptions( postQuery(_api, 3, delay: slow ? _slowDelay : null), ); } @override Widget build(BuildContext context) => FeatureScaffold( feature: parallelQueriesFeature, children: [ // Explicit child nodes: a list row folds every plain text inside // it into one label, and `fetching=` is read as an exact text. SemanticsGroup( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: ListenableBuilder( listenable: _all, builder: (context, _) { final anyFetching = _controllers.any((c) => c.value.isFetching); return Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ _FetchingCount(stats: ShowcaseScope.of(context).stats), Tooltip( message: 'Refetch all', child: FilledButton.tonalIcon( onPressed: anyFetching ? null : _refetchAll, icon: const Icon(Icons.refresh), label: const Text('Refetch all'), ), ), Tooltip( message: 'Refetch post 2', child: OutlinedButton( onPressed: _post2.value.isFetching ? null : () => _post2.refetch().ignore(), child: const Text('Refetch post 2'), ), ), ], ); }, ), ), ), SwitchListTile( dense: true, title: const Text('Slow post 3'), value: _slowPost3, onChanged: _setSlowPost3, ), // Side by side where there is room: the point is to watch three // requests run at the same time, and a browser-driven test can // only read what is in view. LayoutBuilder( builder: (context, constraints) { final cards = [ for (final (index, controller) in _controllers.indexed) _PostCard(index: index + 1, controller: controller), ]; return constraints.maxWidth >= 560 ? Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final card in cards) Expanded(child: card), ], ) : Column(children: cards); }, ), for (final id in [1, 2, 3]) QueryDebugStrip(queryKey: ShowcaseKeys.post(id), label: 'post-$id'), ], ); } /// One post, read from its controller. class _PostCard extends StatelessWidget { const _PostCard({required this.index, required this.controller}); final int index; final QueryController controller; @override Widget build(BuildContext context) => ListenableBuilder( listenable: controller, builder: (context, _) { final post = controller.value; return SectionCard( title: 'Post #$index', trailing: switch (post) { QueryResult(isLoading: true) => const Pill('loading'), QueryResult(isRefetching: true) => const Pill('refreshing'), _ => const SizedBox.shrink(), }, child: switch (post) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: 20), SizedBox(height: 4), SkeletonBox(height: 20, width: 120), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (post case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Text( data.title, style: Theme.of(context).textTheme.titleMedium, ), ], ), }, ); }, ); } /// `fetching=`: how many queries in the whole cache are fetching, read /// from the client on every cache event. /// /// Rebuilt the way the debug strip is: a cache event can arrive from a /// sibling's first build, when a rebuild has to wait for the frame to end. class _FetchingCount extends StatefulWidget { const _FetchingCount({required this.stats}); final CacheStats stats; @override State<_FetchingCount> createState() => _FetchingCountState(); } class _FetchingCountState extends State<_FetchingCount> with PhaseSafeRebuild<_FetchingCount> { @override void initState() { super.initState(); widget.stats.addListener(scheduleRebuild); } @override void didUpdateWidget(_FetchingCount oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.stats != widget.stats) { oldWidget.stats.removeListener(scheduleRebuild); widget.stats.addListener(scheduleRebuild); } } @override void dispose() { widget.stats.removeListener(scheduleRebuild); super.dispose(); } @override Widget build(BuildContext context) { final fetching = widget.stats.client.isFetching(); return Row( mainAxisSize: MainAxisSize.min, children: [ Text( 'client.isFetching()', style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(width: 8), Text( 'fetching=$fetching', style: const TextStyle(fontFamily: 'monospace'), ), ], ); } } ```
## Related - Guides: [Parallel queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/parallel-queries.md), [Background fetching indicators](https://dualmeta-gmbh.github.io/query_kit/docs/guides/background-fetching-indicators.md) - Tested by `test/features/parallel_queries_test.dart` (widget) and `e2e/tests/parallel_queries.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/parallel_queries) --- # Query collections > A list of queries of one type that grows, shrinks and reorders at runtime, read with QueriesBuilder and again with a QueriesController. When the set of queries on a screen is data rather than code, one query per id in a list that changes, you need a collection: a `QueriesBuilder` over a list of options, one result per option, in the list's order. Observers are matched by key, so reordering the list starts no request, adding an id fetches only the newcomer, and removing one lets its observer go. A *Summary reader* switch adds the same collection as a `QueriesController`, a `ValueListenable` for code that is not a builder. Think of a comparison screen for the products a user picked, the live status of every device in a room, or the favourites pinned to a home screen. Live demo: [Query collections](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/query-collections), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/query_collections)). A list of queries that grows, shrinks and reorders at runtime. ## What to try - Press *Reverse*. The cards swap order (`ids=3,2,1`) and every strip still reads `fetches=1`: the results were reordered, nothing was fetched. - Press *Add post*: only the new id is fetched. Then press *Remove last* until post 3's card goes: its strip stays and reads `observers=0`, because the entry stays cached without a reader. - Press *Duplicate first*. The first id appears twice and its strip reads `observers=2`: two observers over one cache entry. The entry is stale by then, so the second observer refetches it once for both. - Press *Add missing id*. Post 999 does not exist; its card shows the backend's 404 on its own while the others keep their titles. - Turn on *Summary reader*. A `ready=n/m` and `failed=n` line appears, every entry gains a second observer, and adding an id updates both readers with one fetch between them. ## The code Every member has the same options: the whole post is cached, and `select` narrows it to its title, so the collection is homogeneous in `String`. [`examples/showcase/lib/features/query_collections/query_collections_screen.dart`, lines 62–68](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/query_collections/query_collections_screen.dart#L62-L68): ```dart QuerySelectOptions postTitleQuery(ShowcaseApi api, int id) => QuerySelectOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), select: (post) => post.title, retry: RetryPolicy.never, ); ``` The builder takes a freshly built list on every build; the results come back in the same order, so row *n* belongs to id *n*. [`examples/showcase/lib/features/query_collections/query_collections_screen.dart`, lines 172–192](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/query_collections/query_collections_screen.dart#L172-L192): ```dart child: QueriesBuilder( queries: >[ for (final id in _ids) postTitleQuery(api, id), ], builder: (context, results) { if (results.isEmpty) { return const Notice('No queries in the collection.'); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final (index, result) in results.indexed) _CollectionRow( position: index + 1, id: _ids[index], result: result, ), ], ); }, ), ``` The controller version lives in a `State`: created once the client is known, handed the new list with `setQueries` when the ids change, and disposed with the widget. [`examples/showcase/lib/features/query_collections/query_collections_screen.dart`, lines 218–272](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/query_collections/query_collections_screen.dart#L218-L272): ```dart class _SummaryReaderState extends State<_SummaryReader> { QueriesController? _controller; List> _queries(BuildContext context) { final api = ShowcaseScope.apiOf(context); return >[ for (final id in widget.ids) postTitleQuery(api, id), ]; } @override void didChangeDependencies() { super.didChangeDependencies(); // Created here, not in `initState`: the client is an inherited widget. final client = QueryClientProvider.of(context); if (_controller?.client != client) { _controller?.dispose(); _controller = QueriesController(client, _queries(context)); } } @override void didUpdateWidget(_SummaryReader oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.ids != widget.ids) { _controller!.setQueries(_queries(context)); } } @override void dispose() { _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final controller = _controller!; return ListenableBuilder( listenable: controller, builder: (context, _) { final results = controller.value; final ready = results.where((result) => result.isSuccess).length; final failed = results.where((result) => result.isError).length; return FactGroup( name: 'summary', facts: [ 'ready=$ready/${results.length}', 'failed=$failed', ], ); }, ); } } ```
The whole screen [`examples/showcase/lib/features/query_collections/query_collections_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/query_collections/query_collections_screen.dart): ```dart /// Query collections: a list of queries whose length and order change at /// runtime. Port-specific — it is `QueriesObserver`, the homogeneous stand-in /// for upstream's `useQueries`. Queries of different types are read one by /// one and folded with `combine` over a record of their results (the /// `combine` screen); a collection like this one folds with `combine` over /// its `List`. /// /// `parallel-queries` is the fixed case: three controllers written side by /// side. This screen is the case that needs a collection — the set of posts /// on screen is data, not source code. /// /// What the screen is built from: one `QueriesBuilder` over a /// list of ids. `select` narrows each post to its title, so a refetch that /// returns an equal post rebuilds nothing. Observers are reused by key **and /// occurrence**, which is what makes the two interesting buttons honest: /// reordering starts no request, and a second copy of an id gets its own /// observer over the one shared cache entry. /// /// The `Summary reader` switch adds the other shape: a `QueriesController` /// over the same ids — the collection as a `ValueListenable`, for a widget /// that is not a builder, here a `ready=n/m` line read through a /// `ListenableBuilder`. It is a second collection, so every entry gains a /// second observer, and `setQueries` follows the ids as the buttons change /// them. /// /// Proofs (widget tests in `test/features/query_collections_test.dart`, /// end-to-end in `e2e/tests/query_collections.spec.ts`): opening fetches every /// id once; `Reverse` reorders the results without a single new request; /// adding an id fetches only the new one; removing one releases its observer; /// a duplicate id shares the cache entry (`observers=2`) and, finding it /// stale, refetches it once for both observers (`fetches=2`, not three); /// the missing id fails alone while its neighbours keep their data; and the /// summary reader, switched on, counts every member ready with a second /// observer on each entry, follows an added id with one fetch shared by both /// readers, and releases its observers when switched off. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature queryCollectionsFeature = Feature( id: 'query-collections', title: 'Query collections', summary: 'A list of queries that grows, shrinks and reorders at runtime.', ); /// The id that is not in the seed: the backend answers 404, so one entry of /// the collection fails while the rest are fine. const int missingPostId = 999; /// One member of the collection. `select` is what makes the collection /// homogeneous in `String` while the cache still holds whole `Post`s. QuerySelectOptions postTitleQuery(ShowcaseApi api, int id) => QuerySelectOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), select: (post) => post.title, retry: RetryPolicy.never, ); class QueryCollectionsScreen extends StatefulWidget { const QueryCollectionsScreen({super.key}); @override State createState() => _QueryCollectionsScreenState(); } class _QueryCollectionsScreenState extends State { static const List _initial = [1, 2, 3]; List _ids = _initial; int _nextId = 4; bool _summary = false; void _reverse() => setState(() => _ids = _ids.reversed.toList()); void _add() => setState(() => _ids = [..._ids, _nextId++]); void _removeLast() => setState( () => _ids = _ids.isEmpty ? _ids : _ids.sublist(0, _ids.length - 1)); /// A second occurrence of an id already on screen: one cache entry, two /// observers. The newcomer finds the entry stale (`staleTime` is zero) and /// refetches it — one fetch shared by both observers, not one each. void _duplicateFirst() => setState(() => _ids = _ids.isEmpty ? _ids : [..._ids, _ids.first]); void _addMissing() => setState(() => _ids = [..._ids, missingPostId]); void _reset() => setState(() { _ids = _initial; _nextId = 4; }); @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); return FeatureScaffold( feature: queryCollectionsFeature, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton.tonal( onPressed: _add, child: const Text('Add post'), ), OutlinedButton( onPressed: _ids.isEmpty ? null : _removeLast, child: const Text('Remove last'), ), OutlinedButton( onPressed: _ids.isEmpty ? null : _reverse, child: const Text('Reverse'), ), OutlinedButton( onPressed: _ids.isEmpty ? null : _duplicateFirst, child: const Text('Duplicate first'), ), OutlinedButton( onPressed: _addMissing, child: const Text('Add missing id'), ), TextButton(onPressed: _reset, child: const Text('Reset')), ], ), ), // The ids, as an exact text a test can read without counting cards. SemanticsGroup( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Text( 'ids=${_ids.join(',')}', style: const TextStyle(fontFamily: 'monospace'), ), ), ), SwitchListTile( // No subtitle: it would fold into the switch's accessible name. title: const Text('Summary reader'), value: _summary, onChanged: (value) => setState(() => _summary = value), ), const Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 8), child: Text( 'A QueriesController over the same ids: the collection as a ' 'ValueListenable, for a widget that is not a builder. A second ' 'collection is a second observer on every entry — and a second ' 'observer mounting on a stale entry refetches it once.', ), ), if (_summary) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: _SummaryReader(ids: _ids), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: QueriesBuilder( queries: >[ for (final id in _ids) postTitleQuery(api, id), ], builder: (context, results) { if (results.isEmpty) { return const Notice('No queries in the collection.'); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final (index, result) in results.indexed) _CollectionRow( position: index + 1, id: _ids[index], result: result, ), ], ); }, ), ), // Strips for every id the screen can show, so a test can read the // observer count of an entry that has just been dropped as well. for (final id in {..._ids, ..._initial, missingPostId}) QueryDebugStrip( queryKey: ShowcaseKeys.post(id), label: 'post-$id', ), ], ); } } /// The collection through a `QueriesController`: created once the client is /// known, handed the new ids by `setQueries` whenever they change, read /// through a `ListenableBuilder`, disposed with the widget. class _SummaryReader extends StatefulWidget { const _SummaryReader({required this.ids}); final List ids; @override State<_SummaryReader> createState() => _SummaryReaderState(); } class _SummaryReaderState extends State<_SummaryReader> { QueriesController? _controller; List> _queries(BuildContext context) { final api = ShowcaseScope.apiOf(context); return >[ for (final id in widget.ids) postTitleQuery(api, id), ]; } @override void didChangeDependencies() { super.didChangeDependencies(); // Created here, not in `initState`: the client is an inherited widget. final client = QueryClientProvider.of(context); if (_controller?.client != client) { _controller?.dispose(); _controller = QueriesController(client, _queries(context)); } } @override void didUpdateWidget(_SummaryReader oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.ids != widget.ids) { _controller!.setQueries(_queries(context)); } } @override void dispose() { _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final controller = _controller!; return ListenableBuilder( listenable: controller, builder: (context, _) { final results = controller.value; final ready = results.where((result) => result.isSuccess).length; final failed = results.where((result) => result.isError).length; return FactGroup( name: 'summary', facts: [ 'ready=$ready/${results.length}', 'failed=$failed', ], ); }, ); } } /// One result of the collection, in the collection's own order. class _CollectionRow extends StatelessWidget { const _CollectionRow({ required this.position, required this.id, required this.result, }); final int position; final int id; final QueryResult result; @override Widget build(BuildContext context) => SectionCard( title: '#$position — post $id', trailing: switch (result) { QueryResult(isLoading: true) => const Pill('loading'), QueryResult(isRefetching: true) => const Pill('refreshing'), _ => const SizedBox.shrink(), }, child: switch (result) { QueryPending() => const SkeletonBox(height: 20), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Text(data, style: Theme.of(context).textTheme.titleMedium), }, ); } ```
## Related - Guides: [Parallel queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/parallel-queries.md), [Combining queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/combining-queries.md) - Tested by `test/features/query_collections_test.dart` (widget) and `e2e/tests/query_collections.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/query_collections) --- # Combine > Three queries of three different types read as one sealed CombinedResult, with a retry for the failed sources only and a memo that skips the combiner when nothing changed. A post, its comments and a counter: three queries of three types, a `Post`, a `List` and an `int`, read side by side and then combined into one value with `(a, b, c).combine(...)` over a record of results. The screen switches once over the sealed `CombinedResult` instead of three times over three results: an error as soon as a source has failed with nothing to show, even while another is still loading; pending until every source has data; and data otherwise, with a failed background refresh reported beside the data it kept. Use it when a screen is only meaningful with all its parts, an order page that needs the order, its customer and the stock levels, or a device screen that needs the device, its settings and its firmware info. Live demo: [Combine](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/combine), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/combine)). Three queries of three types, read as one result. ## What to try - Watch the first load: the `combined` facts read `state=pending` until all three strips show `status=success`, then `state=data` with the title, `comments=3` and the counter. - Set *The post read* to *is refused* and press *Refetch all*. The post's refetch fails, but the combination stays `state=data` with every value; `refetchError=Requested: 500` and a *Could not refresh* notice say what went wrong. - With the post still refused, press *Reset*. The data is dropped, so the refusal now has nothing to fall back on: `state=error`, with a *Retry* button. Set the knob back to *answers* and press *Retry*: only the post is fetched again, not the comments or the counter. - Press *Refetch all* with nothing changed at the backend: `builds` goes up, `combines` does not, because every source kept the same data instance and the memo skipped the combiner. *Increment counter* changes a source, and `combines` goes up by one. ## The code Each query says `RetryPolicy.never`, so a refused read is an error at once. The post's query function reads the knob when the request goes out, so a *Reset* or a *Retry* sees the knob as it is now. [`examples/showcase/lib/features/combine/combine_screen.dart`, lines 98–107](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/combine/combine_screen.dart#L98-L107): ```dart QueryObserverOptions _postQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: combinePostKey, queryFn: (context) => api.post( combinedPostId, signal: context.signal, fail: _refusePost ? 500 : null, ), retry: RetryPolicy.never, ); ``` The three reads are ordinary `context.query` calls in one record; `combine` hands the combiner their data, typed, once all three have some. The `CombineMemo` is a field of the `State`, one per call site. [`examples/showcase/lib/features/combine/combine_screen.dart`, lines 143–157](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/combine/combine_screen.dart#L143-L157): ```dart final combined = ( context.query(_postQuery(api), id: 'post'), context.query(_commentsQuery(api), id: 'comments'), context.query(_counterQuery(api), id: 'counter'), ).combine( (post, comments, counter) { _combines += 1; return ( title: post.title, comments: comments.length, counter: counter, ); }, memo: _memo, ); ``` The switch over the result is exhaustive. `retry()` on the error refetches the failed sources only, and `CombinedData` carries `refetchError` beside the data it kept. [`examples/showcase/lib/features/combine/combine_screen.dart`, lines 239–278](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/combine/combine_screen.dart#L239-L278): ```dart CombinedError(:final error) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Notice('$error', error: true), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: ActionButton( label: 'Retry', filled: true, onPressed: combined.isFetching ? null : combined.retry, ), ), ], ), CombinedData(:final data, :final refetchError) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( data.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), FactGroup( name: 'overview', facts: [ 'comments=${data.comments}', 'counter=${data.counter}', 'refetchError=${refetchError ?? 'none'}', ], ), if (refetchError != null) ...[ const SizedBox(height: 8), // The content stays: what failed is the refresh. Notice('Could not refresh: $refetchError', error: true), ], ], ), }, ```
The whole screen [`examples/showcase/lib/features/combine/combine_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/combine/combine_screen.dart): ```dart /// `combine`: what three queries of three different types amount to together. /// /// Port-specific. Upstream's `useQueries({ combine })` types a heterogeneous /// tuple, which a Dart `List` cannot; here the combination is a function over /// a **record of results** — `(post, comments, counter).combine(...)` — and /// nothing new observes anything. The three reads are plain `context.query` /// calls, a `Post`, a `List` and an `int`; they rebuild the widget, /// and `combine` says what they amount to: a sealed `CombinedResult`, read /// with one exhaustive `switch` over `CombinedPending`, `CombinedError` and /// `CombinedData`. Controllers combine the same way under a /// `ListenableBuilder` over `Listenable.merge`. /// /// The rules the screen walks through, in the library's order: a source that /// failed **with nothing to show** makes the whole a `CombinedError` — even /// while another source is still loading, because waiting does not cure it — /// and `retry()` refetches the failed sources only; otherwise a source without /// data makes it `CombinedPending`; otherwise the combiner runs, and a /// background refetch that failed keeps its stale data in the combination and /// shows up as `refetchError`. `isFetching` is "any source is". /// /// `The post read` is the failure: set to `is refused`, every read of the post /// asks the backend for a 500 (`?fail=500`) until it is switched back. The /// knob is read when the request goes out, so it governs a `Reset` or a /// `Retry` as much as a refetch. All three queries say `RetryPolicy.never`, so /// a refusal is an error at once rather than after three more attempts. /// /// The combiner runs behind a `CombineMemo`, a `State` field: `combines=` is /// how often it really ran and `builds=` how often `combine` was called. A /// refetch that changed nothing hands every source the identical instance it /// had — structural sharing — and the combiner is skipped. /// /// Proofs (widget tests in `test/features/combine_test.dart`, end-to-end in /// `e2e/tests/combine.spec.ts`): the whole is `state=pending` while one source /// is still out although the other two have data, and `state=data` once it /// lands; a post refused on first load is `state=error` with the backend's /// message — also while the counter is still loading — and `Retry` sends one /// more read of the post and none of the comments; a refused background /// refetch keeps `state=data` and every value, and says /// `refetchError=Requested: 500`; `isFetching=true` while any source is out; /// and a refetch that changed nothing builds again without combining again, /// while an incremented counter combines once more. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature combineFeature = Feature( id: 'combine', title: 'Combine', summary: 'Three queries of three types, read as one result.', ); /// The post the screen is about. const int combinedPostId = 1; /// The screen's own entries, under one prefix so `Reset` names them all. QueryKey get combineKey => QueryKey(const ['combine']); QueryKey get combinePostKey => combineKey.append(const ['post']); QueryKey get combineCommentsKey => combineKey.append(const ['comments']); QueryKey get combineCounterKey => combineKey.append(const ['counter']); /// What the combiner makes of the three: a record, so two equal combinations /// are equal and the memo's structural sharing keeps the first. typedef Overview = ({String title, int comments, int counter}); class CombineScreen extends StatefulWidget { const CombineScreen({super.key}); @override State createState() => _CombineScreenState(); } class _CombineScreenState extends State { /// Next to the reads it serves, as the guide says: one per call site. final CombineMemo _memo = CombineMemo(); /// Plain fields, not state: both are written during `build` and printed by /// the same build. int _builds = 0; int _combines = 0; bool _refusePost = false; /// Reads the knob when the request goes out, not when the options were /// built: a `Reset` or a `Retry` runs the query function the entry already /// holds, and that closure must see the knob as it is now. QueryObserverOptions _postQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: combinePostKey, queryFn: (context) => api.post( combinedPostId, signal: context.signal, fail: _refusePost ? 500 : null, ), retry: RetryPolicy.never, ); QueryObserverOptions> _commentsQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: combineCommentsKey, queryFn: (context) => api.comments(combinedPostId, signal: context.signal), retry: RetryPolicy.never, ); QueryObserverOptions _counterQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: combineCounterKey, queryFn: (context) => api.counter(signal: context.signal), retry: RetryPolicy.never, ); /// The one write on the screen: it changes what the counter read answers, /// so the next combination has something new to combine. MutationOptions _incrementMutation( ShowcaseApi api, QueryClient client, ) => MutationOptions.simple( mutationFn: (by) => api.increment(by: by), onSuccess: (_, __, ___) => client.invalidateQueries( filters: QueryFilters(queryKey: combineCounterKey), ), ); @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final client = QueryClientProvider.of(context); final increment = context.mutation(_incrementMutation(api, client)); final combined = ( context.query(_postQuery(api), id: 'post'), context.query(_commentsQuery(api), id: 'comments'), context.query(_counterQuery(api), id: 'counter'), ).combine( (post, comments, counter) { _combines += 1; return ( title: post.title, comments: comments.length, counter: counter, ); }, memo: _memo, ); _builds += 1; return FeatureScaffold( feature: combineFeature, children: [ SectionCard( title: 'Controls', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ knob( context, title: 'The post read', name: 'post knob', choices: const <(String, bool)>[ ('answers', false), ('is refused', true), ], selected: _refusePost, onChanged: (value) => setState(() => _refusePost = value), ), const SizedBox(height: 12), Toolbar( children: [ // `refetch()` is every source's; `retry()`, on the error // below, is only the failed ones'. ActionButton( label: 'Refetch all', onPressed: combined.isFetching ? null : combined.refetch, ), ActionButton( label: 'Reset', onPressed: () => client.resetQueries( filters: QueryFilters(queryKey: combineKey), ), ), ActionButton( label: 'Increment counter', onPressed: increment.value.isPending ? null : () => increment.mutate(1), ), ], ), ], ), ), QueryDebugStrip(queryKey: combinePostKey, label: 'post'), QueryDebugStrip(queryKey: combineCommentsKey, label: 'comments'), QueryDebugStrip(queryKey: combineCounterKey, label: 'counter'), SectionCard( title: 'Post, comments and counter', trailing: combined.isFetching ? const Pill('fetching') : null, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FactGroup( name: 'combined', facts: [ // Spelled out per variant, and exhaustively: a fourth // variant would be a compile error here, not a blank fact. switch (combined) { CombinedPending() => 'state=pending', CombinedError() => 'state=error', CombinedData() => 'state=data', }, 'isFetching=${combined.isFetching}', 'builds=$_builds', 'combines=$_combines', ], ), const SizedBox(height: 12), switch (combined) { CombinedPending() => const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SkeletonBox(height: 20, width: 240), SizedBox(height: 8), SkeletonBox(width: 160), ], ), CombinedError(:final error) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Notice('$error', error: true), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: ActionButton( label: 'Retry', filled: true, onPressed: combined.isFetching ? null : combined.retry, ), ), ], ), CombinedData(:final data, :final refetchError) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( data.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), FactGroup( name: 'overview', facts: [ 'comments=${data.comments}', 'counter=${data.counter}', 'refetchError=${refetchError ?? 'none'}', ], ), if (refetchError != null) ...[ const SizedBox(height: 8), // The content stays: what failed is the refresh. Notice('Could not refresh: $refetchError', error: true), ], ], ), }, ], ), ), ], ); } } ```
## Related - Guides: [Combining queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/combining-queries.md), [Structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md) - Tested by `test/features/combine_test.dart` (widget) and `e2e/tests/combine.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/combine) --- # Initial and placeholder data > Showing something before the first fetch returns, either as initial data written to the cache or as placeholder data that is only displayed. Four cards, each showing a post before its request has answered, in the two ways the library offers. `initialData` is written to the cache as if it had been fetched, and `staleTime` decides whether a fetch follows; card A seeds a detail from the list that is already cached, and card D dates a seed with a callback that runs only when the seed is written. `placeholderData` is only shown, never cached, and the result says so with `isPlaceholderData`; card B shows a fixed stand-in title, and card C keeps the previous post on screen while the next one loads. In an app, the first shape opens a product's detail instantly from the product list the user just scrolled; the second fills a settings screen with a skeleton record, or keeps last month's invoice visible while the next month loads. Live demo: [Initial and placeholder data](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/initial-and-placeholder), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/initial_and_placeholder)). Data before the first fetch: written to the cache, or shown only. ## What to try - In card A, within 30 seconds of the list loading, press *Open post 1*. The title is there at once, the detail reads `initialData source=list`, and the `post-1` debug strip shows `fetches=0`: the seed is dated with the list's own fetch time and counts as fresh for 30 seconds from it. Wait longer and the same seed is stale on arrival, so a fetch follows. - Switch on *Treat initial data as old*, then open a post you have not opened yet. The title still shows at once, but the seed is dated a minute earlier, so it is stale and one fetch follows. A post opened before has an entry already, and no seed is consulted for it. - Card B shows *Loading title…* with `isPlaceholderData=true` and `cache=empty` while its slowed request runs, then the real title with `isPlaceholderData=false` and `cache=post`. Press its *Refetch* button: the real title stays, the placeholder does not come back. - In card C, switch from *Post 5* to *Post 6*. Post 5's title stays on screen with `isPlaceholderData=true` until post 6 arrives. - In card D, pick *fresh*: `computeCalls=1`, `refetched=false`. Pick *backdated*: the seed shows, then the mount refetches and `refetched=true`. Press *Rebuild card D* or pick a mode a second time, and `computeCalls` stays at 1. ## The code Card A's detail seeds itself from the cached list with `InitialData.compute`; a callback returning `null` means no seed. `initialDataUpdatedAt` dates the seed, and `staleTime` is measured from it. [`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`, lines 86–98](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart#L86-L98): ```dart QueryObserverOptions seededPostQuery( ShowcaseApi api, int id, { required Post? Function() seed, required DateTime? seededAt, }) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), staleTime: const StaleTime.duration(seededPostStaleTime), initialData: InitialData.compute(seed), initialDataUpdatedAt: seededAt, ); ``` Card B's placeholder is a fixed value. It shows while the entry has no data and is never written to the cache. [`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`, lines 102–113](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart#L102-L113): ```dart QueryObserverOptions placeholderPostQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: ShowcaseKeys.post(4), queryFn: (context) => api.post( 4, signal: context.signal, delay: const Duration(seconds: 1), ), placeholderData: const PlaceholderData.value( Post(id: 4, title: 'Loading title…', body: ''), ), ); ``` Card C's placeholder is computed from what the observer showed last. The function is a top-level tear-off, so the options built on each rebuild compare equal. [`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`, lines 168–182](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart#L168-L182): ```dart Post? keepPreviousPost(Post? previousData, Query? previousQuery) => previousData; /// Card C: post [id], with the previous post as its placeholder. Slowed a /// little for the same reason as card B. QueryObserverOptions previousPostQuery(ShowcaseApi api, int id) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post( id, signal: context.signal, delay: const Duration(milliseconds: 750), ), placeholderData: const PlaceholderData.compute(keepPreviousPost), ); ``` The read passes an `id`, so the same observer follows the key when the post changes and has a previous post to hand to the placeholder. [`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`, lines 285–288](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart#L285-L288): ```dart final previous = watchQuery( previousPostQuery(api, _previousId), id: 'previous', ); ```
The whole screen [`examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/initial_and_placeholder/initial_and_placeholder_screen.dart): ```dart /// Data before the first fetch, the two ways: `initialData`, which is written /// to the cache as if it had been fetched and ages by `staleTime` from /// `initialDataUpdatedAt`; and `placeholderData`, which is only shown — never /// cached — and flagged `isPlaceholderData` on the result. Port-specific; it /// walks upstream's guides `initial-query-data` and `placeholder-query-data`. /// /// Three cards, all read through `QueryMixin`'s `watchQuery`: /// /// - **A.** A post's detail seeds itself from the cached posts list with /// `InitialData.compute` (returning `null` when the list is not there yet, /// which means "no seed") and dates the seed with the list's own /// `dataUpdatedAt`. Under a 30 s `staleTime` that costs no request; a /// switch dates the seed a minute older, and then a fetch follows. /// - **B.** A fixed `PlaceholderData.value` shows a stand-in title while post /// 4's request is in flight, and the cache stays empty until the answer. /// - **C.** `PlaceholderData.compute((previous, _) => previous)` — upstream's /// `keepPreviousData` — keeps the last post on screen while the segmented /// button switches the key to the next one. The read carries an `id`, which /// is what makes the mixin's observer follow the key instead of starting a /// new one (see the binding's `key_change_test.dart`). /// - **D.** `initialDataUpdatedAtCompute`, the lazy form of /// `initialDataUpdatedAt`: a callback consulted only when the seed is /// actually written, so a rebuild — and a mode whose entry already holds /// data — costs nothing. `computeCalls=` counts it. The consequence is the /// point: `fresh` returns `null`, which the library reads as "date it /// `clock.now()`", and the seed is inside `staleTime`, so the mount /// fetches nothing; `backdated` returns a timestamp older than /// `staleTime`, and the same mount refetches at once. The two modes seed /// two different keys, so flipping back to one already seeded shows the /// callback staying at one call. Supplying `initialDataUpdatedAt` *and* /// `initialDataUpdatedAtCompute` is an `ArgumentError` when the options are /// defaulted; the screen supplies only the callback. /// /// Proofs (widget tests in `test/features/initial_and_placeholder_test.dart`, /// end-to-end in `e2e/tests/initial_and_placeholder.spec.ts`): a detail /// opened from a fresh list shows its title from the seed with `fetches=0` /// and no `GET /api/posts/`; the same seed dated old shows the title and /// fetches once; the placeholder title shows with `isPlaceholderData=true` /// while the request is held and `getQueryData` stays null, then the real /// title with `false`; switching post 5 to 6 keeps 5's title as placeholder /// until 6 arrives; a detail opened before the list has settled seeds nothing /// and fetches; card D's callback shows `computeCalls=1` through any number /// of rebuilds and through a mode selected a second time, `fresh` settles on /// `isStale=false` with `fetches=0`, and `backdated` shows the seed with /// `isStale=true` while its one refetch is in flight. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature initialAndPlaceholderFeature = Feature( id: 'initial-and-placeholder', title: 'Initial and placeholder data', summary: 'Data before the first fetch: written to the cache, or shown only.', ); /// How long a seeded detail counts as fresh. Long enough that a seed dated /// with the list's timestamp is fresh, short enough that one dated a minute /// earlier is not. const Duration seededPostStaleTime = Duration(seconds: 30); /// The posts list card A seeds its details from. QueryObserverOptions> postsQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal), ); /// Card A: post [id]'s detail, seeded by [seed] and dated [seededAt]. /// /// `InitialData.compute` is consulted when the entry is created — and again /// on every options update until the entry has data, as upstream does — so /// [seed] returning `null` while the list is still loading means "no seed", /// and the detail fetches like any other query. With a seed, the entry starts /// in `success` dated [seededAt], and [seededPostStaleTime] decides whether /// the mount refetches. QueryObserverOptions seededPostQuery( ShowcaseApi api, int id, { required Post? Function() seed, required DateTime? seededAt, }) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), staleTime: const StaleTime.duration(seededPostStaleTime), initialData: InitialData.compute(seed), initialDataUpdatedAt: seededAt, ); /// Card B: post 4 behind a fixed placeholder. The request is slowed on /// purpose so the placeholder is on screen long enough to see. QueryObserverOptions placeholderPostQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: ShowcaseKeys.post(4), queryFn: (context) => api.post( 4, signal: context.signal, delay: const Duration(seconds: 1), ), placeholderData: const PlaceholderData.value( Post(id: 4, title: 'Loading title…', body: ''), ), ); /// Card D's mode: nothing seeded yet, or one of the two timestamps. enum LazySeedMode { /// No reader, so nothing is seeded and the callback has not run. off, /// The callback returns `null`; the library dates the seed `clock.now()`. fresh, /// The callback returns a timestamp older than [seededPostStaleTime]. backdated, } /// Card D's two seeds. A key each, so selecting a mode a second time meets an /// entry that already holds data — which is where the callback is *not* /// consulted again, and that is the guarantee the card is about. const Post freshSeedPost = Post(id: 8, title: 'Seed · fresh timestamp', body: ''); /// The seed card D writes under [LazySeedMode.backdated]. Replaced on screen /// by the real post 9 as soon as the mount's refetch lands. const Post backdatedSeedPost = Post(id: 9, title: 'Seed · backdated timestamp', body: ''); /// Which post [mode] seeds, or `null` while nothing is seeded. Post? lazySeedFor(LazySeedMode mode) => switch (mode) { LazySeedMode.off => null, LazySeedMode.fresh => freshSeedPost, LazySeedMode.backdated => backdatedSeedPost, }; /// Card D: [seed] written to the cache, dated by [seededAt] — the lazy form /// of `initialDataUpdatedAt`. /// /// The callback runs exactly once per entry, when the seed is written, and /// never again: not on a rebuild, and not when the entry is met a second time /// with data already in it. Returning `null` is "no opinion", and the library /// falls back to `clock.now()`. QueryObserverOptions lazySeededPostQuery( ShowcaseApi api, { required Post seed, required DateTime? Function() seededAt, }) => QueryObserverOptions( queryKey: ShowcaseKeys.post(seed.id), queryFn: (context) => api.post(seed.id, signal: context.signal), staleTime: const StaleTime.duration(seededPostStaleTime), initialData: InitialData.value(seed), initialDataUpdatedAtCompute: seededAt, ); /// Upstream's `keepPreviousData`: whatever this observer showed last stands /// in for the new key. A tear-off rather than an inline closure, so the /// options built on every rebuild compare equal. Post? keepPreviousPost(Post? previousData, Query? previousQuery) => previousData; /// Card C: post [id], with the previous post as its placeholder. Slowed a /// little for the same reason as card B. QueryObserverOptions previousPostQuery(ShowcaseApi api, int id) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post( id, signal: context.signal, delay: const Duration(milliseconds: 750), ), placeholderData: const PlaceholderData.compute(keepPreviousPost), ); class InitialAndPlaceholderScreen extends StatefulWidget { const InitialAndPlaceholderScreen({super.key}); @override State createState() => _InitialAndPlaceholderScreenState(); } class _InitialAndPlaceholderScreenState extends State with QueryMixin { /// Card A's open post, if any. int? _openId; bool _treatAsOld = false; /// Where the open detail's seed came from. Preset to `unused` on every /// open; the seed callback overwrites it when the library consults it, /// which it does not for an entry that already holds data. String _seedSource = 'unused'; /// Card C's selected post. int _previousId = 5; /// Card D's mode, and how often its timestamp callback has run per mode. /// Counted rather than logged: the number is the assertion, and it must /// stay at one however often the card rebuilds. LazySeedMode _lazySeedMode = LazySeedMode.off; final Map _lazySeedCalls = {}; void _openPost(int id) { setState(() { _openId = id; _seedSource = 'unused'; }); } Post? _seedFor(int id) { final post = queryClient .getQueryData>(ShowcaseKeys.posts) ?.where((post) => post.id == id) .firstOrNull; // Called inside `watchQuery`, from this build: the text below reads the // field after the call, so no `setState` is needed or allowed here. _seedSource = post == null ? 'none' : 'list'; return post; } /// When the seed counts as fetched: the list's own timestamp, or a minute /// before it when the switch is on. Derived from the list rather than from /// a wall clock, so it is right under a test's fake clock too. DateTime? _seededAt() { final listUpdatedAt = queryClient .getQueryState>(ShowcaseKeys.posts) ?.dataUpdatedAt; if (listUpdatedAt == null) { return null; } return _treatAsOld ? listUpdatedAt.subtract(const Duration(minutes: 1)) : listUpdatedAt; } /// Card D's `initialDataUpdatedAtCompute`. A tear-off of this method rather /// than a closure built in `build`, so the options a rebuild produces carry /// the same callback and the observer sees no change; it reads /// [_lazySeedMode] instead of taking it as an argument for the same reason. /// /// `null` means "no opinion": the library dates the seed `clock.now()`, so /// it is inside [seededPostStaleTime] and the mount fetches nothing. The /// backdated timestamp is derived from the posts list's own `dataUpdatedAt` /// rather than from a wall clock, the way card A's is — right under a /// test's fake clock too. With no list yet there is nothing to backdate /// from, and the seed is dated `clock.now()` like the fresh one. DateTime? _lazySeededAt() { final mode = _lazySeedMode; _lazySeedCalls.update(mode, (count) => count + 1, ifAbsent: () => 1); if (mode != LazySeedMode.backdated) { return null; } return queryClient .getQueryState>(ShowcaseKeys.posts) ?.dataUpdatedAt ?.subtract(seededPostStaleTime * 2); } @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final posts = watchQuery(postsQuery(api)); final openId = _openId; final open = openId == null ? null : watchQuery(seededPostQuery( api, openId, seed: () => _seedFor(openId), seededAt: _seededAt(), )); final placeholder = watchQuery(placeholderPostQuery(api)); // The `id` is what lets the observer follow the key: without it a new // key is a new observer, and a fresh observer has no previous data to // hand to `PlaceholderData.compute`. final previous = watchQuery( previousPostQuery(api, _previousId), id: 'previous', ); final lazySeed = lazySeedFor(_lazySeedMode); final lazy = lazySeed == null ? null : watchQuery(lazySeededPostQuery( api, seed: lazySeed, seededAt: _lazySeededAt, )); // Seeding is not fetching: `dataUpdateCount` stays at zero for an entry // that only ever held its seed, so it says whether the mount refetched // without anyone reading a clock. final lazyState = lazySeed == null ? null : queryClient.getQueryState(ShowcaseKeys.post(lazySeed.id)); // Read from the cache, not from the result: a placeholder is only ever // in the result, and this is what shows it. final cached = queryClient.getQueryData(ShowcaseKeys.post(4)); return FeatureScaffold( feature: initialAndPlaceholderFeature, children: [ SectionCard( title: 'A. Initial data from another entry', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _PostsList(posts), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 4, children: [ for (final id in const [1, 2, 3]) OutlinedButton( onPressed: () => _openPost(id), child: Text('Open post $id'), ), ], ), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Treat initial data as old'), subtitle: const Text( 'Dates the seed a minute before the list arrived — older ' 'than staleTime, so a fetch follows.', ), value: _treatAsOld, onChanged: (value) => setState(() => _treatAsOld = value), ), if (open == null) const Text('Open a post: its detail seeds itself from the ' 'list above.') else _PostDetail( open, card: 'A', facts: ['initialData source=$_seedSource'], ), ], ), ), if (openId != null) QueryDebugStrip( queryKey: ShowcaseKeys.post(openId), label: 'post-$openId', ), SectionCard( title: 'B. Placeholder value', trailing: IconButton( tooltip: 'Refetch', onPressed: placeholder.isFetching ? null : placeholder.refetch, icon: const Icon(Icons.refresh), ), child: _PostDetail( placeholder, card: 'B', facts: [ 'isPlaceholderData=${placeholder.isPlaceholderData}', 'cache=${cached == null ? 'empty' : 'post'}', ], ), ), QueryDebugStrip(queryKey: ShowcaseKeys.post(4), label: 'post-4'), SectionCard( title: 'C. Placeholder from the previous query', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SegmentedButton( segments: >[ for (final id in const [5, 6, 7]) ButtonSegment(value: id, label: Text('Post $id')), ], selected: {_previousId}, onSelectionChanged: (selection) => setState(() => _previousId = selection.first), ), const SizedBox(height: 12), _PostDetail( previous, card: 'C', facts: [ 'isPlaceholderData=${previous.isPlaceholderData}', ], ), ], ), ), QueryDebugStrip( queryKey: ShowcaseKeys.post(_previousId), label: 'post-$_previousId', ), SectionCard( title: 'D. A seed timestamp computed lazily', trailing: IconButton( tooltip: 'Rebuild card D', onPressed: () => setState(() {}), icon: const Icon(Icons.refresh), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'initialDataUpdatedAtCompute is the lazy form of ' 'initialDataUpdatedAt: it runs only when the seed is actually ' 'written. fresh returns null, which the library reads as ' 'clock.now(), so the seed is inside the 30 s staleTime and ' 'the mount fetches nothing. backdated returns a minute ' 'earlier, so the same mount refetches at once. Picking a mode ' 'again meets an entry that already holds data, and the ' 'callback is not consulted a second time.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), // Explicit child nodes: without them the three segments fold // into one label and `fresh` is not a text a test can read. SemanticsGroup( name: 'lazy-seed mode', child: SegmentedButton( showSelectedIcon: false, segments: const >[ ButtonSegment( value: LazySeedMode.off, label: Text('off'), ), ButtonSegment( value: LazySeedMode.fresh, label: Text('fresh'), ), ButtonSegment( value: LazySeedMode.backdated, label: Text('backdated'), ), ], selected: {_lazySeedMode}, onSelectionChanged: (selection) => setState(() => _lazySeedMode = selection.first), ), ), const SizedBox(height: 12), if (lazy == null) const Text('Nothing seeded: pick a timestamp above.') else _PostDetail( lazy, card: 'D', facts: [ 'mode=${_lazySeedMode.name}', 'computeCalls=${_lazySeedCalls[_lazySeedMode] ?? 0}', 'refetched=${(lazyState?.dataUpdateCount ?? 0) > 0}', ], ), ], ), ), if (lazySeed != null) QueryDebugStrip( queryKey: ShowcaseKeys.post(lazySeed.id), label: 'lazy-seed', ), ], ); } } /// The first few posts of the list — the ones the cards below use — each as /// `#id · title`, so a title on its own is always a detail's. class _PostsList extends StatelessWidget { const _PostsList(this.posts); final QueryResult> posts; static const int _shown = 7; @override Widget build(BuildContext context) => switch (posts) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(), SizedBox(height: 4), SkeletonBox(), SizedBox(height: 4), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('posts=${data.length}', style: Theme.of(context).textTheme.labelLarge), for (final post in data.take(_shown)) Text('#${post.id} · ${post.title}'), if (data.length > _shown) Text('… and ${data.length - _shown} more', style: Theme.of(context).textTheme.bodySmall), ], ), }; } /// One post's title and the facts a test reads, or a skeleton while it has /// nothing to show. /// /// A group named `detail `, the way the debug strip is one: two cards /// show `isPlaceholderData=false` at once, and a test has to say which one it /// means. class _PostDetail extends StatelessWidget { const _PostDetail(this.post, {required this.card, required this.facts}); final QueryResult post; final String card; final List facts; @override Widget build(BuildContext context) => SemanticsGroup( name: 'detail $card', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ switch (post) { QueryPending() => const SkeletonBox(height: 20, width: 240), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Text( data.title, style: Theme.of(context).textTheme.titleLarge, ), }, const SizedBox(height: 4), Wrap( spacing: 12, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [ FactList(facts, dense: true), if (post.isFetching) const Pill('fetching'), ], ), ], ), ); } ```
## Related - Guides: [Initial query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md), [Placeholder query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md) - Tested by `test/features/initial_and_placeholder_test.dart` (widget) and `e2e/tests/initial_and_placeholder.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/initial_and_placeholder) --- # Select and structural sharing > Five readers of one cache entry, each selecting something different, with build counters that show which changes reach which reader. One list of todos in the cache, read by five widgets at once: four of them use `select` to take one piece of it, a count, the first text, a done/open record, the list of texts, and the fifth reads the whole list. Each reader counts its builds, and `data builds` counts only the builds where its selected value changed. A refetch that brings back equal data, or a write that changes one field, then shows which readers it reaches. This is the pattern for a screen where several widgets derive from one response: a device list whose header shows a count of online devices, a badge with the number of unread items, a summary row over an order's lines. Each widget selects what it shows and is left alone when something else changes. Live demo: [Select and structural sharing](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/select-and-sharing), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/select_and_sharing)). What a reader rebuilds on, and what it does not. ## What to try - Press *Refetch*. The `todos` debug strip shows `fetches=2`, but no reader's `data builds` moves: structural sharing kept the old list, so every selection is equal to the last one. The reader with `buildWhen` (*QuerySelectBuilder + buildWhen*) does not move its `builds` either; the others count the fetching and idle notifications in `builds`. - Press *Toggle todo 1*. Only the done/open record and the reader without `select` get a new `data builds`; the count, the first text and the list of texts are unchanged. - Press *Rename todo 2*. Now only the list of texts and the reader without `select` move. - Switch on *Structural sharing off* and press *Refetch*. The list in the cache is a new instance each time, so the reader without `select` moves, and so does the list of texts, a new list on every run of its selector. The count, the first text and the record are compared by value and stay put. ## The code The selectors are top-level functions, so the same function object is passed on every build. [`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`, lines 85–95](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart#L85-L95): ```dart int countTodos(List todos) => todos.length; String firstText(List todos) => todos.isEmpty ? '' : todos.first.text; ({int done, int open}) doneAndOpen(List todos) => ( done: todos.where((todo) => todo.done).length, open: todos.where((todo) => !todo.done).length, ); List todoTexts(List todos) => [for (final todo in todos) todo.text]; ``` Every reader shares one query; a `select` makes it a `QuerySelectOptions, T>`. Leaving `structuralSharing` null keeps the default sharing, and `keepNext`, the screen's name for `noStructuralSharing()`, switches it off for the cache write and for what `select` produces. [`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`, lines 108–118](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart#L108-L118): ```dart QuerySelectOptions, T> todosQuery( ShowcaseApi api, { required T Function(List todos) select, bool sharing = true, }) => QuerySelectOptions, T>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), select: select, structuralSharing: sharing ? null : keepNext, ); ``` The first reader is `context.selectQuery`, which returns a result whose data is the selected `int`. [`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`, lines 380–387](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart#L380-L387): ```dart final result = context.selectQuery, int>( todosQuery( ShowcaseScope.apiOf(context), select: countTodos, sharing: widget.sharing, ), ); _counter.record(result.dataOrNull); ``` The second is a `QuerySelectBuilder` whose `buildWhen` lets a rebuild through only when the selected data changed. [`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`, lines 414–432](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart#L414-L432): ```dart Widget build(BuildContext context) => QuerySelectBuilder, String>( options: todosQuery( ShowcaseScope.apiOf(context), select: firstText, sharing: widget.sharing, ), buildWhen: (previous, next) => previous.dataOrNull != next.dataOrNull, builder: (context, result) { _counter.record(result.dataOrNull); return _ReaderRow( id: 'builder', style: 'QuerySelectBuilder + buildWhen', selection: 'String: the first text', counter: _counter, result: result, facts: () => ['first=${result.dataOrNull}'], ); }, ); ```
The whole screen [`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart): ```dart /// Port-specific: what a reader rebuilds on, and what it does not. /// /// One cache entry — the todos — read by five readers at once: one per call /// style, each with a different `select`, plus a control without one. Every /// reader counts its own builds. `builds` is every call of its build; /// `data builds` only the calls whose selected value differed from the one /// built before (`!=`, so identity for a list — which is exactly what /// structural sharing is about). Upstream's guide is /// `docs/framework/react/guides/render-optimizations.md`; the binding's rules /// are the README's "What rebuilds, and when". /// /// What the counters show, read from the binding's source /// (`query_context.dart`, `query_mixin.dart`, `query_builder.dart`): /// /// - `context.selectQuery` and `watchSelectQuery` rebuild whenever the result /// differs from the one last built, and a `QueryResult` carries /// `fetchStatus` and `dataUpdatedAt`: a refetch that brings back equal data /// is still two rebuilds — fetching, then idle with a newer /// `dataUpdatedAt`. `select` decides what the *data* comparison sees, so /// `data builds` is the honest measure of "did my selection change", and it /// is what the proofs assert on for those readers. /// - `QuerySelectBuilder` with `buildWhen: previous.dataOrNull != /// next.dataOrNull` skips both of those rebuilds, so its `builds` counter /// is the one that stands still. It is the only reader *on this screen* /// that passes a predicate, not the only one that could: all four builders /// and all eight keyless reads take the same `buildWhen`, and the /// `build-when` screen is where each of the eight is shown doing it. /// - `ListenableBuilder` over a `QueryController` rebuilds on every /// notification, with no equality guard at all: the same two rebuilds per /// refetch as the context and mixin readers, and a first load of two /// builds like everyone's — the fetch its subscription starts is already /// in the first result it builds from. /// - `structuralSharing` governs the cache write *and* what `select` /// produces, as upstream's `replaceData` does (see `StructuralSharing` in /// the core). With the switch off, a /// selection is reported exactly as the selector built it, so a reader /// moves when its own selected value is not `==` to the last one: the /// controller's list of texts is a new instance every time and moves, while /// the `int`, the `String` and the record do not. The fifth reader, the one /// without `select`, is the control: with sharing off /// the list in the cache is a new instance on every refetch, and upstream /// hands an observer without `select` the cache's data as it is — and so /// does this library: an observer without a `select` passes the cached /// data through untouched, so with sharing off the fifth reader moves on /// every refetch. /// /// Proofs (widget tests in `test/features/select_and_sharing_test.dart`, /// end-to-end in `e2e/tests/select_and_sharing.spec.ts`): every reader shows /// its selection after one `GET /api/todos`; a refetch with equal data moves /// no reader's `data builds` and not the `buildWhen` reader's `builds`, while /// the strip's `fetches` becomes 2; toggling todo 1 moves only the done/open /// record (and the control); renaming todo 2 moves only the list of texts /// (and the control); with sharing off, a refetch with equal data provably /// reaches the cache write (an equal list, but a new instance, where sharing /// on kept the old one) and moves the one `select` reader whose selection is /// a new instance — the controller's list of texts — while the three whose /// selections are `==` to the last stand still and the guard-less readers' /// `builds` climb; and the control's `data builds` climbs with sharing off /// and stands still once it is back on. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature selectAndSharingFeature = Feature( id: 'select-and-sharing', title: 'Select and structural sharing', summary: 'What a reader rebuilds on, and what it does not.', ); // The selectors are top-level functions, not closures built in `build`: a // tear-off of one is the same object every build, which is what lets the // observer skip re-running it while the data has not changed — upstream's // "extract it to a stable function reference". int countTodos(List todos) => todos.length; String firstText(List todos) => todos.isEmpty ? '' : todos.first.text; ({int done, int open}) doneAndOpen(List todos) => ( done: todos.where((todo) => todo.done).length, open: todos.where((todo) => !todo.done).length, ); List todoTexts(List todos) => [for (final todo in todos) todo.text]; /// The opt-out: what arrives is written as it is, never reconciled with what /// the cache held — upstream's `structuralSharing: false`. The core's /// `noStructuralSharing()` rather than a `(_, next) => next` of the screen's /// own: a hook of one's own governs the cache write only, and only the /// recognised opt-out turns sharing off for what `select` produces too. final StructuralSharing> keepNext = noStructuralSharing(); /// The one query every reader shares. They differ only in what they select /// and in whether the cache write shares structure. A select is its own /// options shape (`QuerySelectOptions`), so the raw reader has /// [rawTodosQuery]. QuerySelectOptions, T> todosQuery( ShowcaseApi api, { required T Function(List todos) select, bool sharing = true, }) => QuerySelectOptions, T>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), select: select, structuralSharing: sharing ? null : keepNext, ); /// [todosQuery] without a select: the same key, the same fetch, the list as /// the cache holds it. QueryObserverOptions> rawTodosQuery( ShowcaseApi api, { bool sharing = true, }) => QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), structuralSharing: sharing ? null : keepNext, ); typedef _TodoPatch = ({int id, String? text, bool? done}); class SelectAndSharingScreen extends StatefulWidget { const SelectAndSharingScreen({super.key}); @override State createState() => _SelectAndSharingScreenState(); } class _SelectAndSharingScreenState extends State { bool _sharingOff = false; @override Widget build(BuildContext context) { final sharing = !_sharingOff; return FeatureScaffold( feature: selectAndSharingFeature, children: [ SectionCard( title: 'One cache entry: the todos', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const _Actions(), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Structural sharing off'), value: _sharingOff, onChanged: (value) => setState(() => _sharingOff = value), ), ], ), ), // Above the readers, not below: the end-to-end suite reads the // semantics tree, and a lazily built list only has the rows in view. QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'), SectionCard( title: 'Five readers, one entry', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _ContextReader(sharing: sharing), _BuilderReader(sharing: sharing), _MixinReader(sharing: sharing), _ControllerReader(sharing: sharing), _RawReader(sharing: sharing), ], ), ), ], ); } } /// The buttons, and the mutation behind two of them. A widget of its own /// because a mutation rebuilds the widget that reads it on every state /// change, and that widget must not be the readers' parent. class _Actions extends StatefulWidget { const _Actions(); @override State<_Actions> createState() => _ActionsState(); } class _ActionsState extends State<_Actions> { int _renames = 0; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final client = QueryClientProvider.of(context); final update = context.mutation( MutationOptions.simple( mutationFn: (patch) => api.updateTodo(patch.id, text: patch.text, done: patch.done), onSuccess: (_, __, ___) => client.invalidateQueries( filters: QueryFilters(queryKey: ShowcaseKeys.todos), ), ), ); final busy = update.value.isPending; return Wrap( spacing: 8, runSpacing: 8, children: [ _ActionButton( 'Refetch', enabled: !busy, onPressed: () => client .refetchQueries( filters: QueryFilters(queryKey: ShowcaseKeys.todos), ) .ignore(), ), _ActionButton( 'Toggle todo 1', enabled: !busy, onPressed: () { // Read, not watched: the button needs the current `done` once, // at the tap, and must not become a reader of its own. final todo = client .getQueryData>(ShowcaseKeys.todos) ?.where((todo) => todo.id == 1) .firstOrNull; if (todo != null) { update.mutate((id: 1, text: null, done: !todo.done)); } }, ), _ActionButton( 'Rename todo 2', enabled: !busy, onPressed: () { _renames += 1; update .mutate((id: 2, text: 'Renamed todo 2 x$_renames', done: null)); }, ), ], ); } } class _ActionButton extends StatelessWidget { const _ActionButton( this.label, { required this.onPressed, this.enabled = true, }); final String label; final VoidCallback onPressed; final bool enabled; // The visible label is the button's accessible name; the tooltip is for // hovering only, so it stays out of the semantics tree rather than // doubling the text. @override Widget build(BuildContext context) => Tooltip( message: label, excludeFromSemantics: true, child: FilledButton.tonal( onPressed: enabled ? onPressed : null, child: Text(label), ), ); } /// An honest build count. [builds] is every build; [dataBuilds] only the /// ones whose selected value differed from the one built before — `!=`, so /// value equality for a scalar or a record and identity for a list. class _Counter { int builds = 0; int dataBuilds = 0; Object? _lastData; void record(Object? data) { builds += 1; if (data != _lastData) { dataBuilds += 1; _lastData = data; } } } /// One reader's row: its style, what it selects, the selected value as exact /// `key=value` texts, and its two counters. A semantics group named /// `reader `, so a browser-driving test can scope to one reader the way /// it scopes to a debug strip. class _ReaderRow extends StatelessWidget { const _ReaderRow({ required this.id, required this.style, required this.selection, required this.counter, required this.result, required this.facts, }); final String id; final String style; final String selection; final _Counter counter; final QueryResult result; /// The selected value as exact texts, given the data. final List Function() facts; @override Widget build(BuildContext context) { final theme = Theme.of(context); final values = switch (result) { QueryPending() => const ['pending'], QueryError(:final error, staleData: null) => ['error=$error'], QuerySuccess() || QueryError() => facts(), }; return SemanticsGroup( name: 'reader $id', child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text(style, style: theme.textTheme.labelLarge), ), Text(selection, style: theme.textTheme.bodySmall), ], ), const SizedBox(height: 2), Wrap( spacing: 12, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [ FactList(values, dense: true), Pill('builds=${counter.builds}'), Pill( 'data builds=${counter.dataBuilds}', color: theme.colorScheme.tertiary, ), ], ), ], ), ), ); } } /// Reader 1: `context.selectQuery`, selecting the count. class _ContextReader extends StatefulWidget { const _ContextReader({required this.sharing}); final bool sharing; @override State<_ContextReader> createState() => _ContextReaderState(); } class _ContextReaderState extends State<_ContextReader> { final _Counter _counter = _Counter(); @override Widget build(BuildContext context) { final result = context.selectQuery, int>( todosQuery( ShowcaseScope.apiOf(context), select: countTodos, sharing: widget.sharing, ), ); _counter.record(result.dataOrNull); return _ReaderRow( id: 'context', style: 'context.selectQuery', selection: 'int: the count', counter: _counter, result: result, facts: () => ['count=${result.dataOrNull}'], ); } } /// Reader 2: `QuerySelectBuilder`, selecting the first todo's text, with a /// `buildWhen` that ignores everything but the data. class _BuilderReader extends StatefulWidget { const _BuilderReader({required this.sharing}); final bool sharing; @override State<_BuilderReader> createState() => _BuilderReaderState(); } class _BuilderReaderState extends State<_BuilderReader> { final _Counter _counter = _Counter(); @override Widget build(BuildContext context) => QuerySelectBuilder, String>( options: todosQuery( ShowcaseScope.apiOf(context), select: firstText, sharing: widget.sharing, ), buildWhen: (previous, next) => previous.dataOrNull != next.dataOrNull, builder: (context, result) { _counter.record(result.dataOrNull); return _ReaderRow( id: 'builder', style: 'QuerySelectBuilder + buildWhen', selection: 'String: the first text', counter: _counter, result: result, facts: () => ['first=${result.dataOrNull}'], ); }, ); } /// Reader 3: `QueryMixin.watchSelectQuery`, selecting a done/open record. class _MixinReader extends StatefulWidget { const _MixinReader({required this.sharing}); final bool sharing; @override State<_MixinReader> createState() => _MixinReaderState(); } class _MixinReaderState extends State<_MixinReader> with QueryMixin { final _Counter _counter = _Counter(); @override Widget build(BuildContext context) { final result = watchSelectQuery, ({int done, int open})>( todosQuery( ShowcaseScope.apiOf(context), select: doneAndOpen, sharing: widget.sharing, ), ); _counter.record(result.dataOrNull); return _ReaderRow( id: 'mixin', style: 'QueryMixin.watchSelectQuery', selection: 'record: done and open', counter: _counter, result: result, facts: () { final data = result.dataOrNull!; return ['done=${data.done}', 'open=${data.open}']; }, ); } } /// Reader 4: a `QueryController` selecting the list of texts, read through /// a `ListenableBuilder`. class _ControllerReader extends StatefulWidget { const _ControllerReader({required this.sharing}); final bool sharing; @override State<_ControllerReader> createState() => _ControllerReaderState(); } class _ControllerReaderState extends State<_ControllerReader> { final _Counter _counter = _Counter(); QueryController, List>? _controller; QuerySelectOptions, List> get _options => todosQuery( ShowcaseScope.apiOf(context), select: todoTexts, sharing: widget.sharing, ); @override void didChangeDependencies() { super.didChangeDependencies(); // The controller is created here, not in `initState`: it needs the // provider's client, which is an inherited widget. final client = QueryClientProvider.of(context); if (_controller?.client != client) { _controller?.dispose(); _controller = QueryController, List>(client, _options); } } @override void didUpdateWidget(_ControllerReader oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.sharing != widget.sharing) { _controller!.setOptions(_options); } } @override void dispose() { _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final controller = _controller!; return ListenableBuilder( listenable: controller, builder: (context, _) { final result = controller.value; _counter.record(result.dataOrNull); return _ReaderRow( id: 'controller', style: 'QueryController + ListenableBuilder', selection: 'List: the texts', counter: _counter, result: result, facts: () => result.dataOrNull!, ); }, ); } } /// Reader 5, the control: no `select`, the cache's own list. The only reader /// whose selected value is the thing `structuralSharing` decides about. class _RawReader extends StatefulWidget { const _RawReader({required this.sharing}); final bool sharing; @override State<_RawReader> createState() => _RawReaderState(); } class _RawReaderState extends State<_RawReader> { final _Counter _counter = _Counter(); @override Widget build(BuildContext context) => QueryBuilder>( options: rawTodosQuery( ShowcaseScope.apiOf(context), sharing: widget.sharing, ), builder: (context, result) { _counter.record(result.dataOrNull); return _ReaderRow( id: 'raw', style: 'QueryBuilder, no select', selection: "List: the cache's own list", counter: _counter, result: result, facts: () => ['length=${result.dataOrNull!.length}'], ); }, ); } ```
## Related - Guides: [Structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md), [What rebuilds, and when](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md), [Four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) - Tested by `test/features/select_and_sharing_test.dart` (widget) and `e2e/tests/select_and_sharing.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/select_and_sharing) --- # Pagination > Page-numbered results, one cache entry per page, the previous page kept on screen while the next loads, and the next page prefetched. A list of projects, ten to a page, with *Previous page* and *Next page* buttons. The page number is part of the query key, so every page is its own cache entry. `PlaceholderData.keepPrevious()` keeps the last page's rows on screen while a new page loads instead of dropping back to a skeleton, and as soon as a page has real data the next one is prefetched, so the usual press of *Next page* costs no request. This is the shape of any paged table backed by a `?page=` API: an order history, an admin list of users, search results with numbered pages. Live demo: [Pagination](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/pagination), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/pagination)). Page by page, keeping the previous page on screen while the next loads. ## What to try - Watch the first load: `page-0` shows one fetch, and then the `page-1` debug strip reads `status=success` with `observers=0`. The next page was prefetched with nobody reading it. - Press *Next page* within five seconds of the load. Page 1's rows appear at once with `isPlaceholderData=false` and no new request (after that the prefetched page is stale and refetches in the background as it is shown), and the strip for page 2 shows its prefetch. - Press *Next page* twice in quick succession. For the moment the next page's prefetch is still running, the previous page's rows stay on screen with `isPlaceholderData=true`, a *loading* pill, and *Next page* disabled, so nobody skips past a page they have not seen. - Press *Previous page* within five seconds: the cached page comes back with no request. Wait longer and the cached rows still show at once, with a background refetch because the page has gone stale. - Page through to `page=9`. `hasMore=false`, *Next page* is disabled, and the `page-10` strip reads `status=absent`: nothing beyond the last page is prefetched. ## The code The read and the prefetch share a key and a `staleTime`, so a prefetched page counts as fresh when it is opened. The read's placeholder is the `const` `keepPrevious()`, which compares equal on every rebuild. [`examples/showcase/lib/features/pagination/pagination_screen.dart`, lines 64–73](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/pagination/pagination_screen.dart#L64-L73): ```dart QueryObserverOptions projectsPageQuery( ShowcaseApi api, int page, ) => QueryObserverOptions( queryKey: projectsPageKey(page), queryFn: (context) => api.projectsPage(page, signal: context.signal), staleTime: projectsPageStaleTime, placeholderData: const PlaceholderData.keepPrevious(), ); ``` [`examples/showcase/lib/features/pagination/pagination_screen.dart`, lines 78–83](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/pagination/pagination_screen.dart#L78-L83): ```dart QueryOptions projectsPagePrefetch(ShowcaseApi api, int page) => QueryOptions( queryKey: projectsPageKey(page), queryFn: (context) => api.projectsPage(page, signal: context.signal), staleTime: projectsPageStaleTime, ); ``` The screen reads the current page with `context.query` under an `id`, so one observer follows the key from page to page and has the previous page's data to keep. Once the page has real data and there is more, it prefetches the next one, after the frame. [`examples/showcase/lib/features/pagination/pagination_screen.dart`, lines 117–132](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/pagination/pagination_screen.dart#L117-L132): ```dart // The `id` is what lets the observer follow the key: without it a new // page would be a new observer, with no previous data to keep. final result = context.query(projectsPageQuery(api, page), id: 'page'); final data = result.dataOrNull; final hasMore = data?.hasMore ?? false; if (data != null && !result.isPlaceholderData && hasMore && _prefetchedFrom != page) { _prefetchNext(page); } final canGoBack = page > 0; // Upstream's rule: no skipping past a page that is still a placeholder. final canGoForward = !result.isPlaceholderData && hasMore; ```
The whole screen [`examples/showcase/lib/features/pagination/pagination_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/pagination/pagination_screen.dart): ```dart /// Upstream's `pagination` example: page-numbered projects, one cache entry /// per page, read with `context.query` under an `id:` so the observer follows /// the key from page to page. `const PlaceholderData.keepPrevious()` — /// upstream's `keepPreviousData` — keeps the last page's rows on screen while /// the next loads, and `isPlaceholderData` says when that is what is showing. /// It does what `.compute((previous, _) => previous)` does, a previous `null` /// meaning "no placeholder" included, and being `const` it is canonicalised: /// every build of this screen passes the identical value, so the options /// compare equal and the observer's placeholder memoisation holds. A fresh /// inline closure never compares equal to the last one, and cannot. /// /// `Next page` is disabled while a placeholder is on screen, /// as upstream disables it, so nobody skips past a page they have not seen; /// and while the current page has real data and `hasMore`, the next page is /// prefetched with `client.query(...).ignore()`, so the usual `Next page` /// costs no request at all. `staleTime` is 5 s, upstream's, which is what /// makes the prefetched page count as fresh when it is opened — within those /// 5 s. A page opened or returned to after that is shown from the cache at /// once and refetched in the background. /// /// Proofs (widget tests in `test/features/pagination_test.dart`, end-to-end /// in `e2e/tests/pagination.spec.ts`): page 0 costs one request and, once it /// has data, page 1 is fetched with nobody observing it; `Next page` onto the /// prefetched page costs no request and prefetches the page after; a page /// whose prefetch has not answered shows the previous page's rows with /// `isPlaceholderData=true` and `Next page` disabled until it does; `Previous /// page` returns to a cached, still fresh page with no request; on the last page `Next /// page` is disabled and nothing beyond it is prefetched. library; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature paginationFeature = Feature( id: 'pagination', title: 'Pagination', summary: 'Page by page, keeping the previous page on screen while the next loads.', upstream: 'pagination', ); /// One entry per page: the page number is part of the key. QueryKey projectsPageKey(int page) => QueryKey(['projects', 'page', page]); /// Upstream's 5 s: the window within which the prefetched next page counts as /// fresh, so opening it costs no request. const StaleTime projectsPageStaleTime = StaleTime.duration(Duration(seconds: 5)); /// The screen's read. The placeholder is the previous page's data, whatever /// it was, so the rows never blank out between pages — and it is the `const` /// variant, so a rebuild hands the observer a value equal to the last one. QueryObserverOptions projectsPageQuery( ShowcaseApi api, int page, ) => QueryObserverOptions( queryKey: projectsPageKey(page), queryFn: (context) => api.projectsPage(page, signal: context.signal), staleTime: projectsPageStaleTime, placeholderData: const PlaceholderData.keepPrevious(), ); /// The prefetch's options: the cache-layer kind, since nothing observes it. /// Same key and `staleTime` as [projectsPageQuery], so the read finds the /// entry fresh. QueryOptions projectsPagePrefetch(ShowcaseApi api, int page) => QueryOptions( queryKey: projectsPageKey(page), queryFn: (context) => api.projectsPage(page, signal: context.signal), staleTime: projectsPageStaleTime, ); class PaginationScreen extends StatefulWidget { const PaginationScreen({super.key}); @override State createState() => _PaginationScreenState(); } class _PaginationScreenState extends State { int _page = 0; /// The page whose successor has been prefetched. Upstream prefetches from /// an effect on `[data, page]`; here the build is the effect, and this is /// what keeps one page from prefetching on every rebuild. int? _prefetchedFrom; void _prefetchNext(int page) { _prefetchedFrom = page; final api = ShowcaseScope.apiOf(context); final client = QueryClientProvider.of(context); // After the frame, not inside the build: starting a fetch fires cache // events, and the widgets listening to them are mid-build right now. SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) { client.query(projectsPagePrefetch(api, page + 1)).ignore(); } }); } @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final page = _page; // The `id` is what lets the observer follow the key: without it a new // page would be a new observer, with no previous data to keep. final result = context.query(projectsPageQuery(api, page), id: 'page'); final data = result.dataOrNull; final hasMore = data?.hasMore ?? false; if (data != null && !result.isPlaceholderData && hasMore && _prefetchedFrom != page) { _prefetchNext(page); } final canGoBack = page > 0; // Upstream's rule: no skipping past a page that is still a placeholder. final canGoForward = !result.isPlaceholderData && hasMore; return FeatureScaffold( feature: paginationFeature, children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Notice( 'Each page keeps the previous one on screen while it loads, and ' 'the next page is prefetched as soon as this one has data. Pages ' 'stay fresh for 5 s: going back within that costs no request; ' 'later, the cached page shows at once and refetches in the ' 'background.', ), ), SectionCard( title: 'Pages', trailing: result.isFetching ? const Pill('loading') : null, child: SemanticsGroup( // Explicit children keep each button and each `key=value` text a // node of its own rather than folded into the row. child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 12, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ OutlinedButton( onPressed: canGoBack ? () => setState(() => _page -= 1) : null, child: const Text('Previous page'), ), FilledButton( onPressed: canGoForward ? () => setState(() => _page += 1) : null, child: const Text('Next page'), ), Text('page=$page'), ], ), const SizedBox(height: 8), Wrap( spacing: 12, runSpacing: 2, children: [ Text('isPlaceholderData=${result.isPlaceholderData}'), Text('hasMore=$hasMore'), Text('isFetching=${result.isFetching}'), ], ), ], ), ), ), QueryDebugStrip(queryKey: projectsPageKey(page), label: 'page-$page'), QueryDebugStrip( queryKey: projectsPageKey(page + 1), label: 'page-${page + 1}', ), SectionCard( title: 'Projects', child: switch (result) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(), SizedBox(height: 4), SkeletonBox(), SizedBox(height: 4), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (result case QueryError(:final error)) ...[ Notice('Fetch failed: $error', error: true), const SizedBox(height: 8), ], for (final project in data.projects) Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Text('Project ${project.id}'), ), ], ), }, ), ], ); } } ```
## Related - Guides: [Paginated queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/paginated-queries.md), [Placeholder query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md), [Prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md) - Upstream: TanStack's React [`pagination`](https://github.com/TanStack/query/tree/main/examples/react/pagination) example - Tested by `test/features/pagination_test.dart` (widget) and `e2e/tests/pagination.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/pagination) --- # Load more and infinite scroll > One infinite query that appends a page per cursor, from a button or from scrolling near the end, and whose pages outlive the list that read them. A list that grows as you reach its end: one infinite query whose pages are cursor slices of a hundred projects, a *Load more* button that appends the next slice, and the same append fired by a scroll listener when the list nears its bottom. An *About* view unmounts the list and reads the held pages straight from the cache, and going back shows every page at once with no request. It is the shape of an activity feed, a product catalogue, or an event log on a device screen: anything too long to fetch in one go, where leaving and coming back should not start from page one. Live demo: [Load more and infinite scroll](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/load-more), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/load_more)). An infinite query that appends pages as you scroll. ## What to try - Watch the first page arrive: the facts in the card read `pages=1`, `rows=10` and `hasNextPage=true`. - Press *Load more*. While the next page is on its way the button is disabled, a *loading* pill sits beside it and `isFetchingNextPage=true`; then `pages=2`, `rows=20`. - Scroll the list to its bottom instead: the next page is appended without the button, one page per arrival at the end. After the tenth page the button stays dark and the card says *Nothing more to load*. - Press *Go to about*. The debug strip reads `observers=0`, and the About view still reports `cached pages` and `cached rows` read off the cache. *Back to list* shows the same rows at once, and `fetches` in the strip does not move. - Press the refresh icon (*Refetch*): a *refreshing* pill appears and `fetches` in the strip goes up by one while every page the list holds is fetched again, first to last; the rows stay on screen. ## The code The query names its first cursor and reads the next one off the last page; `null` there is what makes `hasNextPage` false. A `select` keeps the page structure but drops the cursors, and a five-minute `staleTime` is what lets the list come back from About without refetching. [`examples/showcase/lib/features/load_more/load_more_screen.dart`, lines 74–103](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/load_more/load_more_screen.dart#L74-L103): ```dart /// The screen's one query. `initialPageParam` is the first cursor, /// `getNextPageParam` reads the cursor the backend sent with the last page — /// `null` means there is no more, and that is what `hasNextPage` reports. /// /// Fresh for five minutes: with the default stale time coming back from /// `About` would be a mount over stale data, and that refetches every held /// page — ten requests to show that the cache survived. Upstream's example /// does exactly that; here the survival is what the screen is about. InfiniteQuerySelectOptions projectsInfiniteQuery(ShowcaseApi api) => InfiniteQuerySelectOptions( queryKey: projectsInfiniteKey, initialPageParam: 0, pageFn: (context) => api.projectsFrom( context.pageParam, limit: pageSize, signal: context.signal, ), getNextPageParam: (page, pages, param, params) => page.nextId, select: _rowsOf, staleTime: const StaleTime.duration(Duration(minutes: 5)), ); /// A top-level function, not a closure: the observer runs `select` again /// whenever the function is a different one, and the options are rebuilt /// with every build of the screen. ProjectRows _rowsOf(InfiniteData data) => ProjectRows( pages: >[for (final slice in data.pages) slice.items], pageParams: data.pageParams, ); ``` The list reads it with an `InfiniteQueryBuilder`, whose builder receives the controller that carries `hasNextPage`, `isFetchingNextPage` and `fetchNextPage`. The scroll listener asks for a page only when the end is near, there is a page to ask for, none is in flight, and the list has grown since the last time it asked: [`examples/showcase/lib/features/load_more/load_more_screen.dart`, lines 196–202](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/load_more/load_more_screen.dart#L196-L202): ```dart if (position.extentAfter < loadMoreThreshold && position.maxScrollExtent != _askedAtExtent && projects.hasNextPage && !projects.isFetchingNextPage) { _askedAtExtent = position.maxScrollExtent; projects.fetchNextPage().ignore(); } ``` The About view observes nothing. `getInfiniteQueryData` reads the pages as the cache holds them, without adding an observer or starting a request: [`examples/showcase/lib/features/load_more/load_more_screen.dart`, lines 355–362](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/load_more/load_more_screen.dart#L355-L362): ```dart // A plain read of the cache: the pages as the cache holds them — slices, // not the lists the list's `select` projects them to — or null if the // entry is gone. Typed by the key's page and param types, and it throws // `QueryDataTypeError` rather than guess if they were wrong. final cached = QueryClientProvider.of(context) .getInfiniteQueryData(projectsInfiniteKey); final rows = cached?.pages.fold(0, (n, page) => n + page.items.length) ?? 0; ```
The whole screen [`examples/showcase/lib/features/load_more/load_more_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/load_more/load_more_screen.dart): ```dart /// Upstream's `load-more-infinite-scroll` example: one infinite query whose /// pages are cursor slices of the projects (`GET /api/projects?cursor=`), a /// `Load more` button that appends the next one, and the same append when /// the list is scrolled near its end — upstream watches the button with an /// intersection observer, this screen listens to the list's /// `ScrollController`. The rows are the pages flattened /// (`InfiniteData.flatten`); `hasNextPage` is `getNextPageParam` over the /// last page, so a `nextId` of `null` is the end of the list and the button /// goes dark. An `About` view, upstream's `/about` page, unmounts the list: /// the pages stay in the cache without an observer and are back at once on /// return, with no request — and the About view proves it by reading them /// straight from the cache with `client.getInfiniteQueryData`, no observer /// involved. /// /// The read is an `InfiniteQueryBuilder`, whose builder receives the /// controller — where `hasNextPage`, `isFetchingNextPage` and /// `fetchNextPage` live. A `select` keeps the page structure and drops the /// cursors, so the widget sees `InfiniteData, int>`. /// /// Proofs (widget tests in `test/features/load_more_test.dart`, end-to-end in /// `e2e/tests/load_more.spec.ts`): the first page arrives after one request /// with `cursor=0`; `Load more` appends the page at `cursor=10`, and while it /// loads `isFetchingNextPage` is true and the button disabled; scrolling to /// the bottom of the list appends the next page without the button; after /// the tenth page (`cursor=90`) `hasNextPage` is false, the button is disabled /// and nothing asks for `cursor=100`; going to `About` and back shows the /// same rows with no request, the entry's observers going 1 → 0 → 1, while /// About reads `cached pages=2`, `cached rows=20` off the cache with nobody /// observing; a refetch re-requests every held page, first to last. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature loadMoreFeature = Feature( id: 'load-more', title: 'Load more and infinite scroll', summary: 'An infinite query that appends pages as you scroll.', upstream: 'load-more-infinite-scroll', ); /// How many projects one page holds; the backend has a hundred, so ten pages. const int pageSize = 10; /// The list's viewport, so ten rows already overflow it and there is /// something to scroll on the first page. const double listHeight = 360; /// Every row the same height, so the list's extent is arithmetic. const double rowHeight = 48; /// How close to the end of the list a scroll has to get before the next page /// is fetched on its own — upstream's intersection observer, in pixels. const double loadMoreThreshold = 100; /// This screen owns its key: the paginated screens share the projects but /// not the entry, and a page-numbered cache must not be confused with a /// cursor-based one. QueryKey get projectsInfiniteKey => QueryKey(const ['projects', 'infinite']); /// What the widget sees: the pages as lists of projects, cursors dropped. typedef ProjectRows = InfiniteData, int>; /// The screen's one query. `initialPageParam` is the first cursor, /// `getNextPageParam` reads the cursor the backend sent with the last page — /// `null` means there is no more, and that is what `hasNextPage` reports. /// /// Fresh for five minutes: with the default stale time coming back from /// `About` would be a mount over stale data, and that refetches every held /// page — ten requests to show that the cache survived. Upstream's example /// does exactly that; here the survival is what the screen is about. InfiniteQuerySelectOptions projectsInfiniteQuery(ShowcaseApi api) => InfiniteQuerySelectOptions( queryKey: projectsInfiniteKey, initialPageParam: 0, pageFn: (context) => api.projectsFrom( context.pageParam, limit: pageSize, signal: context.signal, ), getNextPageParam: (page, pages, param, params) => page.nextId, select: _rowsOf, staleTime: const StaleTime.duration(Duration(minutes: 5)), ); /// A top-level function, not a closure: the observer runs `select` again /// whenever the function is a different one, and the options are rebuilt /// with every build of the screen. ProjectRows _rowsOf(InfiniteData data) => ProjectRows( pages: >[for (final slice in data.pages) slice.items], pageParams: data.pageParams, ); class LoadMoreScreen extends StatefulWidget { const LoadMoreScreen({super.key}); @override State createState() => _LoadMoreScreenState(); } class _LoadMoreScreenState extends State { /// Upstream's two routes, as one screen with two views. bool _about = false; @override Widget build(BuildContext context) => FeatureScaffold( feature: loadMoreFeature, children: [ // The strip first, on both views: the list card is tall, and a // strip pushed out of the scaffold's lazy viewport is one no test // can read. QueryDebugStrip(queryKey: projectsInfiniteKey, label: 'projects'), if (_about) _AboutView(onBack: () => setState(() => _about = false)) else _ProjectList(onAbout: () => setState(() => _about = true)), ], ); } class _ProjectList extends StatefulWidget { const _ProjectList({required this.onAbout}); final VoidCallback onAbout; @override State<_ProjectList> createState() => _ProjectListState(); } class _ProjectListState extends State<_ProjectList> { final ScrollController _scroll = ScrollController(); /// The builder's controller, kept for the scroll listener; the builder owns /// and disposes it. InfiniteQueryController? _projects; @override void initState() { super.initState(); _scroll.addListener(_onScroll); } @override void dispose() { _scroll.dispose(); super.dispose(); } /// How long the list was when a page was last asked for, so that one /// arrival at the end asks exactly once. double? _askedAtExtent; /// The same guard as upstream's effect — near the end, a page to fetch, /// none in flight — plus one more: the list has to have grown since the /// last time we asked. /// /// A scroll listener is level-triggered. It hears every notification, and /// one gesture produces several: a browser scroll steps through positions, /// and a list whose content grew notifies as well. So "am I near the end?" /// on its own asks again and again, and how many pages that produced /// depended on how fast the machine was — two here, three on CI. /// /// Neither of the two obvious guards survives. Remembering the pixel the /// last request was made at fails because the gesture keeps moving, so the /// next notification of the *same* arrival looks like a new one. Anything /// derived from the data fails on the other side of the same race: a page /// completes, the builder runs with the new rows, and a notification can /// arrive before those rows are laid out — new data, old extent, and /// `extentAfter` still reading as the end. /// /// `maxScrollExtent` is on the right side of both. It does not move while /// the gesture continues, and it does not move until the new rows are /// actually laid out — which is the moment the list genuinely became /// longer. One fetch per length of the list, and "waits for the next /// scroll" is what the code says rather than only the comment. void _onScroll() { final projects = _projects; if (projects == null || !_scroll.hasClients) { return; } final position = _scroll.position; if (!position.hasContentDimensions || !position.hasPixels) { return; } if (position.extentAfter < loadMoreThreshold && position.maxScrollExtent != _askedAtExtent && projects.hasNextPage && !projects.isFetchingNextPage) { _askedAtExtent = position.maxScrollExtent; projects.fetchNextPage().ignore(); } } @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); return InfiniteQueryBuilder( options: projectsInfiniteQuery(api), builder: (context, projects) { _projects = projects; final result = projects.value; final data = result.dataOrNull; final rows = data?.flatten().toList() ?? const []; final canLoadMore = projects.hasNextPage && !projects.isFetchingNextPage; return SectionCard( title: 'Projects', trailing: SemanticsGroup( child: Row( mainAxisSize: MainAxisSize.min, children: [ // A refetch over the pages already held — upstream's // "Background Updating..." — as opposed to a page being added. if (projects.isRefetching) const Pill('refreshing'), IconButton( tooltip: 'Refetch', onPressed: result.isFetching ? null : projects.refetch, icon: const Icon(Icons.refresh), ), FilledButton.tonal( onPressed: widget.onAbout, child: const Text('Go to about'), ), ], ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FactGroup( name: 'projects facts', dense: true, facts: [ 'pages=${data?.pages.length ?? 0}', 'rows=${rows.length}', 'hasNextPage=${projects.hasNextPage}', 'isFetchingNextPage=${projects.isFetchingNextPage}', ], ), const SizedBox(height: 8), if (result case QueryError(:final error, staleData: null)) Notice('$error', error: true) else ...[ if (result case QueryError(:final error)) ...[ Notice( projects.isFetchNextPageError ? 'Load more failed: $error' : 'Refetch failed: $error', error: true, ), const SizedBox(height: 8), ], SizedBox( height: listHeight, child: rows.isEmpty ? const _ListSkeleton() : ListView.builder( key: const ValueKey('projects-list'), controller: _scroll, itemExtent: rowHeight, itemCount: rows.length, itemBuilder: (context, index) => _ProjectRow(rows[index]), ), ), ], const SizedBox(height: 8), SemanticsGroup( child: Row( children: [ FilledButton( onPressed: canLoadMore ? projects.fetchNextPage : null, child: const Text('Load more'), ), const SizedBox(width: 12), if (projects.isFetchingNextPage) const Pill('loading') else if (data != null && !projects.hasNextPage) const Text('Nothing more to load'), ], ), ), ], ), ); }, ); } } class _ProjectRow extends StatelessWidget { const _ProjectRow(this.project); final Project project; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( // Upstream's `hsla(id * 30, 60%, 80%)`: a different pastel per // row, so a new page is visibly new. color: HSLColor.fromAHSL(1, (project.id * 30) % 360, 0.6, 0.8) .toColor(), borderRadius: BorderRadius.circular(6), ), child: Text( project.name, style: const TextStyle(color: Colors.black87), ), ), ); } class _ListSkeleton extends StatelessWidget { const _ListSkeleton(); @override Widget build(BuildContext context) => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: rowHeight - 4), SizedBox(height: 4), SkeletonBox(height: rowHeight - 4), SizedBox(height: 4), SkeletonBox(height: rowHeight - 4), ], ); } /// Upstream's `/about` page: nothing here *observes* the projects, so the /// entry has no observer while this is on screen — and it is still in the /// cache, which `client.getInfiniteQueryData` reads without adding one. class _AboutView extends StatelessWidget { const _AboutView({required this.onBack}); final VoidCallback onBack; @override Widget build(BuildContext context) { // A plain read of the cache: the pages as the cache holds them — slices, // not the lists the list's `select` projects them to — or null if the // entry is gone. Typed by the key's page and param types, and it throws // `QueryDataTypeError` rather than guess if they were wrong. final cached = QueryClientProvider.of(context) .getInfiniteQueryData(projectsInfiniteKey); final rows = cached?.pages.fold(0, (n, page) => n + page.items.length) ?? 0; return SectionCard( title: 'About', trailing: FilledButton.tonal( onPressed: onBack, child: const Text('Back to list'), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'The list is unmounted. Nothing on this view observes the ' 'projects query, so its entry has no observer — the strip above ' 'says observers=0 — and the pages it holds are untouched. This ' 'view reads them anyway, with client.getInfiniteQueryData: a ' 'read of the cache, no observer, no request.', ), const SizedBox(height: 8), FactGroup( name: 'about facts', dense: true, facts: [ 'cached pages=${cached?.pages.length ?? 0}', 'cached rows=$rows', ], ), const SizedBox(height: 8), const Text( 'Going back mounts a fresh reader on the same entry. The data ' 'is still fresh, so every page is on screen at once and no ' 'request is made.', ), ], ), ); } } ```
## Related - Guides: [Infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md), [Caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md) - Upstream: TanStack's React [`load-more-infinite-scroll`](https://github.com/TanStack/query/tree/main/examples/react/load-more-infinite-scroll) example - Tested by `test/features/load_more_test.dart` (widget) and `e2e/tests/load_more.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/load_more) --- # Infinite query with max pages > An infinite query that pages in both directions from the middle of the data and keeps a window of at most three pages. An infinite query that starts in the middle of a hundred projects, pages forward and backward, and holds at most three pages: `maxPages: 3` drops the page at the far end whenever a fourth comes in, so the window slides instead of growing. The page function is told which way each fetch extends the window. Use it where a list can be entered anywhere and scrolled both ways while memory stays bounded: a chat history opened at an unread message, a log viewer jumped to a timestamp, a long timeline of sensor readings. Live demo: [Infinite query with max pages](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/max-pages), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/max_pages)). Pages in both directions, with a window of three. ## What to try - The screen opens on the page at cursor 30: `pageParams=30`, and both `hasPreviousPage` and `hasNextPage` are true. - Press *Load next* twice for `pageParams=30,40,50`, then once more: the window slides to `40,50,60`, still `pages=3`, and *Project 30* is gone from the rows. `lastPage` names the cursor and `forward`. - Press *Load previous*: a *loading previous* pill shows while `isFetchingPreviousPage=true`, the window slides back to `30,40,50`, and `lastPage` says `backward`. - Press *Refetch*: the pages in the window are fetched again, first to last, and nothing outside it: `lastPage` ends on the window's last cursor with `forward`, and each row's *fetched* time is renewed. - Keep paging one way: at cursor 90 `hasNextPage=false` and *Load next* is disabled; at cursor 0 the same happens to *Load previous*. ## The code Both directions read their cursor off the page the backend sent, and `maxPages` caps the window. The page function's context carries `pageParam`, `signal` and `direction`: [`examples/showcase/lib/features/max_pages/max_pages_screen.dart`, lines 71–89](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/max_pages/max_pages_screen.dart#L71-L89): ```dart InfiniteQueryObserverOptions projectsWindowQuery( ShowcaseApi api, { void Function(int cursor, FetchDirection direction)? onPage, }) => InfiniteQueryObserverOptions( queryKey: projectsWindowKey, initialPageParam: startCursor, pageFn: (context) { onPage?.call(context.pageParam, context.direction); return api.projectsFrom( context.pageParam, limit: pageSize, signal: context.signal, ); }, getNextPageParam: (page, _, __, ___) => page.nextId, getPreviousPageParam: (page, _, __, ___) => page.previousId, maxPages: windowSize, ); ``` The screen holds the query as an `InfiniteQueryController`, created in `initState` and disposed with the state, and rebuilds through a `ListenableBuilder`: [`examples/showcase/lib/features/max_pages/max_pages_screen.dart`, lines 113–119](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/max_pages/max_pages_screen.dart#L113-L119): ```dart _window = InfiniteQueryController( QueryClientProvider.read(context), projectsWindowQuery( api, onPage: (cursor, direction) => _lastPage = '$cursor ${direction.name}', ), ); ``` Which end is loading is on the controller, not on the sealed result, so the card's pill switches over both: [`examples/showcase/lib/features/max_pages/max_pages_screen.dart`, lines 163–169](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/max_pages/max_pages_screen.dart#L163-L169): ```dart trailing: switch (result) { QueryResult(isLoading: true) => const Pill('loading'), _ when window.isFetchingPreviousPage => const Pill('loading previous'), _ when window.isFetchingNextPage => const Pill('loading next'), _ when window.isRefetching => const Pill('refreshing'), _ => const SizedBox.shrink(), }, ```
The whole screen [`examples/showcase/lib/features/max_pages/max_pages_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/max_pages/max_pages_screen.dart): ```dart /// Upstream's `infinite-query-with-max-pages` example: an infinite query that /// pages in both directions from a cursor in the middle of the data, holding /// a window of at most three pages. `maxPages: 3` drops the page at the far /// end whenever a fourth comes in, so paging forward slides the window and /// paging back slides it again; a refetch re-requests exactly the pages the /// window holds, first to last, and every row's `fetched` stamp moves. /// /// The query is an `InfiniteQueryController` created in `initState` and read /// through a `ListenableBuilder`; the paging half — `hasNextPage`, /// `isFetchingPreviousPage`, `fetchNextPage` — lives on the controller, not /// on the sealed result. The page function receives an `InfinitePageContext`, /// whose `direction` says which end of the window a fetch extends: the screen /// notes the last one as `lastPage= `. A refetch walks /// the window first to last, every page `forward`. /// /// Proofs (widget tests in `test/features/max_pages_test.dart`, end-to-end in /// `e2e/tests/max_pages.spec.ts`): the screen starts on the page at cursor 30 /// with both directions available; two `Load next` make `pageParams=30,40,50` /// and a third slides the window to `40,50,60` — still three pages, rows /// 30–39 gone, rows 60–69 there, one request per cursor; `Load previous` /// from there slides it back to `30,40,50` with one new request for 30; /// `Refetch` sends one request per page in the window, bumps `fetches` by one /// and renews every row's `fetched` stamp; cursor 90 ends the forward /// direction (`hasNextPage=false`, button disabled) and cursor 0 the backward /// one; and `lastPage` says `40 forward` after `Load next`, `20 backward` /// after `Load previous`, and the window's last cursor `forward` after a /// refetch. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature maxPagesFeature = Feature( id: 'max-pages', title: 'Infinite query with max pages', summary: 'Pages in both directions, with a window of three.', upstream: 'infinite-query-with-max-pages', ); /// The window's cache entry — its own key, so the pagination and load-more /// screens' project entries are untouched by what happens here. QueryKey get projectsWindowKey => QueryKey(const ['projects', 'window']); /// Ten rows per page; the backend has a hundred, ids 0 to 99. const int pageSize = 10; /// Where the window starts: in the middle, so there is a page on either side /// from the first frame on. const int startCursor = 30; /// How many pages the window keeps. const int windowSize = 3; typedef ProjectWindow = InfiniteData; /// The window's options. The cursors come back with every slice, so the /// paging functions read them off the page rather than counting. [onPage] /// hears each page fetch with what the page function was told about it: the /// cursor and the direction — `forward` for `fetchNextPage`, the first page /// and every page of a refetch, `backward` for `fetchPreviousPage`. InfiniteQueryObserverOptions projectsWindowQuery( ShowcaseApi api, { void Function(int cursor, FetchDirection direction)? onPage, }) => InfiniteQueryObserverOptions( queryKey: projectsWindowKey, initialPageParam: startCursor, pageFn: (context) { onPage?.call(context.pageParam, context.direction); return api.projectsFrom( context.pageParam, limit: pageSize, signal: context.signal, ); }, getNextPageParam: (page, _, __, ___) => page.nextId, getPreviousPageParam: (page, _, __, ___) => page.previousId, maxPages: windowSize, ); class MaxPagesScreen extends StatefulWidget { const MaxPagesScreen({super.key}); @override State createState() => _MaxPagesScreenState(); } class _MaxPagesScreenState extends State { late final InfiniteQueryController _window; /// The last page fetch the page function was asked for, as /// ` `. Written from inside the page function — before /// the request goes out — and read by the rebuild its answer causes, so no /// `setState` is needed for it. String _lastPage = 'none'; @override void initState() { super.initState(); // Neither lookup subscribes: the api and the client are fixed for the // life of the app, and a subscribing lookup is not allowed here anyway. final api = context.getInheritedWidgetOfExactType()!.api; _window = InfiniteQueryController( QueryClientProvider.read(context), projectsWindowQuery( api, onPage: (cursor, direction) => _lastPage = '$cursor ${direction.name}', ), ); } @override void dispose() { _window.dispose(); super.dispose(); } @override Widget build(BuildContext context) => FeatureScaffold( feature: maxPagesFeature, children: [ ListenableBuilder( listenable: _window, builder: (context, _) => _WindowCard(window: _window, lastPage: _lastPage), ), QueryDebugStrip(queryKey: projectsWindowKey, label: 'projects'), ], ); } /// The window: its facts, the three buttons, and the rows it holds. class _WindowCard extends StatelessWidget { const _WindowCard({required this.window, required this.lastPage}); final InfiniteQueryController window; /// ` ` of the last page fetch, off the page context. final String lastPage; @override Widget build(BuildContext context) { final result = window.value; final data = result.dataOrNull; final pages = data?.pages ?? const []; final pageParams = data?.pageParams ?? const []; final rows = [ for (final page in pages) ...page.items, ]; return SectionCard( title: '$pageSize projects per page, $windowSize pages at most', trailing: switch (result) { QueryResult(isLoading: true) => const Pill('loading'), _ when window.isFetchingPreviousPage => const Pill('loading previous'), _ when window.isFetchingNextPage => const Pill('loading next'), _ when window.isRefetching => const Pill('refreshing'), _ => const SizedBox.shrink(), }, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Explicit child nodes: a row of buttons and texts folds into one // semantics node otherwise, and every `key=value` here is read as // an exact text. SemanticsGroup( child: Wrap( spacing: 12, runSpacing: 4, children: [ _Fact('pages=${pages.length}'), _Fact('pageParams=${pageParams.join(',')}'), _Fact('hasPreviousPage=${window.hasPreviousPage}'), _Fact('hasNextPage=${window.hasNextPage}'), _Fact( 'isFetchingPreviousPage=${window.isFetchingPreviousPage}'), _Fact('isFetchingNextPage=${window.isFetchingNextPage}'), _Fact('lastPage=$lastPage'), ], ), ), const SizedBox(height: 12), SemanticsGroup( child: Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton.tonalIcon( onPressed: window.hasPreviousPage && !window.isFetchingPreviousPage ? () => window.fetchPreviousPage().ignore() : null, icon: const Icon(Icons.arrow_upward), label: const Text('Load previous'), ), FilledButton.tonalIcon( onPressed: window.hasNextPage && !window.isFetchingNextPage ? () => window.fetchNextPage().ignore() : null, icon: const Icon(Icons.arrow_downward), label: const Text('Load next'), ), OutlinedButton.icon( onPressed: result.isFetching ? null : () => window.refetch().ignore(), icon: const Icon(Icons.refresh), label: const Text('Refetch'), ), ], ), ), const SizedBox(height: 12), switch (result) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: 20), SizedBox(height: 4), SkeletonBox(height: 20), SizedBox(height: 4), SkeletonBox(height: 20), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), _ => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (result case QueryError(:final error)) ...[ Notice('Fetch failed: $error', error: true), const SizedBox(height: 8), ], _Rows(rows: rows), ], ), }, ], ), ); } } class _Fact extends StatelessWidget { const _Fact(this.text); final String text; @override Widget build(BuildContext context) => Text( text, style: const TextStyle(fontFamily: 'monospace', fontSize: 12), ); } /// The rows of every page in the window, in a scroller of their own: the /// screen's scaffold builds its children lazily, and the strip under thirty /// rows would otherwise never be built. /// /// Not a lazy list. Thirty rows are nothing, and a lazy list whose row count /// grows under a fixed row height keeps its old scroll extent until the next /// scroll — the rows the new page brought would be unreachable for one /// frame. With every row in the tree the far end of the window is always /// there to be found. class _Rows extends StatelessWidget { const _Rows({required this.rows}); final List rows; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Container( key: const ValueKey('project-rows'), height: 240, decoration: BoxDecoration( border: Border.all(color: scheme.outlineVariant), borderRadius: BorderRadius.circular(8), ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final project in rows) SizedBox( height: 40, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ Expanded(child: Text(project.name)), Text( 'fetched ${hhmmss(project.fetchedAt)}', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), ], ), ), ); } } ```
## Related - Guides: [Infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md) - Upstream: TanStack's React [`infinite-query-with-max-pages`](https://github.com/TanStack/query/tree/main/examples/react/infinite-query-with-max-pages) example - Tested by `test/features/max_pages_test.dart` (widget) and `e2e/tests/max_pages.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/max_pages) --- # Mutations > One write fired with mutate and with mutateAsync, its states and reset, the order its callbacks run in, scopes that queue writes, and a write that outlives its widget. A server-side counter and the writes that change it, walked through in four cards: one mutation fired both ways, the order its callbacks run in, two writes queued behind each other by a `MutationScope`, and a write that keeps running after the widget that fired it has gone. Every successful write invalidates the counter, so the number on screen always comes from the server. This is what sits behind any "save" button in an app: submitting a settings form, adding an item to a cart, renaming a device, where you want the pending and error states on screen and the affected queries refreshed afterwards. Live demo: [Mutations](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/mutations), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutations)). mutate, mutateAsync, reset, callbacks, and scopes. ## What to try - Press *Increment (mutate)*. The facts under card A go to `status=pending` and stay there while the invalidated counter refetches; then `status=success` with `data=1` arrives together with `counter=1` in the header, because `onSuccess` returns the invalidation and the mutation waits for it. *Reset* takes it back to `status=idle`. - Tick *Fail next* and increment again: `status=error` with `error=Requested: 500` and `failureCount=1`. Mutations are not retried unless asked, so the first failure is the error. The tick clears itself; tick it again and use *Increment (mutateAsync)*, and the awaiting caller gets the refusal as `mutateAsync threw=…`. - Press *Run with callbacks* in card B: six numbered lines show the order, `onMutate`, `mutationFn`, then the options' `onSuccess` and `onSettled`, then the ones passed to that `mutate` call. - In card C, *Run two scoped* shows `second=pending` with `secondPaused=true` while the first runs: its request waits. *Run two unscoped* sends both at once, and `client.isMutating()` counts both. - *Fire and leave* unmounts the reader in the same tap. The away view still shows `isMutating=1` until the write lands, and *Back to reader* shows the new count. ## The code The main mutation uses `MutationOptions.simple`, for a mutation with no `onMutate`. Its `onSuccess` returns the invalidation's future, and the mutation waits for it before it reports success: [`examples/showcase/lib/features/mutations/mutations_screen.dart`, lines 57–71](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutations/mutations_screen.dart#L57-L71): ```dart /// The main mutation. `RetryPolicy.never` is the default for mutations; /// it is written out because the screen makes a point of it. `onSuccess` /// returns the invalidation's future, which the library awaits before it /// reports success — upstream's "return the promise" idiom. MutationOptions incrementMutation( ShowcaseApi api, QueryClient client, ) => MutationOptions.simple( mutationFn: (request) => api.increment(by: request.by, fail: request.fail), retry: RetryPolicy.never, onSuccess: (_, __, ___) => client.invalidateQueries(filters: QueryFilters(queryKey: counterKey)), ); ``` The reader's state mixes in `QueryMixin` and reads the counter and the mutation while it builds. The `id` tells two mutations read by the same widget apart: [`examples/showcase/lib/features/mutations/mutations_screen.dart`, lines 284–293](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutations/mutations_screen.dart#L284-L293): ```dart final counter = watchQuery(counterQuery(_api)); final increment = watchMutation( incrementMutation(_api, _client), id: #increment, ); final logging = watchMutation( loggingMutation(_api, _client, _appendLog), id: #logging, ); final result = increment.value; ``` The slow increment takes an optional scope. Card C builds two `MutationController`s with the same `MutationScope('counter')` and two without, so the scoped pair runs one at a time and the unscoped pair together: [`examples/showcase/lib/features/mutations/mutations_screen.dart`, lines 102–113](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutations/mutations_screen.dart#L102-L113): ```dart MutationOptions slowIncrementMutation( ShowcaseApi api, QueryClient client, { MutationScope? scope, }) => MutationOptions.simple( mutationFn: (request) => api.increment(by: request.by, delay: const Duration(seconds: 1)), scope: scope, onSuccess: (_, __, ___) => client.invalidateQueries(filters: QueryFilters(queryKey: counterKey)), ); ```
The whole screen [`examples/showcase/lib/features/mutations/mutations_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutations/mutations_screen.dart): ```dart /// Mutations: `mutate` and `mutateAsync` on one mutation, `reset`, the /// sealed result's fields, the order the callbacks run in, `isMutating`, /// `MutationScope`, and a mutation that outlives the widget that fired it. /// Port-specific — the upstream docs page `guides/mutations.md` is the /// reference, and this screen walks through it section by section. /// /// The counter query and the main mutation are read through `QueryMixin` /// (`watchQuery`, `watchMutation`); the scoped and unscoped pairs are /// `MutationController`s created in `initState`. The main mutation uses /// `MutationOptions.simple` — no `onMutate`, so the third type argument is /// `void` — and says `retry: RetryPolicy.never` out loud, which is what a /// mutation defaults to anyway: repeating a write is rarely safe. /// /// Proofs (widget tests in `test/features/mutations_test.dart`, end-to-end in /// `e2e/tests/mutations.spec.ts`): `mutate` shows `status=pending` while the /// request is out, then `status=success` with `data=1`, and the invalidated /// counter refetches to `counter=1`; `Reset` goes back to `status=idle`; /// `mutateAsync` hands its value to the caller; a refused request ends in /// `status=error` after exactly one POST; the six callback lines land in the /// library's order; two scoped mutations send one request at a time while /// two unscoped ones send both at once; a mutation fired just before its /// reader unmounts still reaches the backend. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/scope.dart'; const Feature mutationsFeature = Feature( id: 'mutations', title: 'Mutations', summary: 'mutate, mutateAsync, reset, callbacks, and scopes.', ); /// The cache entry every mutation here invalidates. QueryKey get counterKey => QueryKey(const ['counter']); /// What one increment asks for. A record, so two requests with the same /// fields are equal — the observer compares `variables` by value. typedef Increment = ({int by, int? fail}); QueryObserverOptions counterQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: counterKey, queryFn: (context) => api.counter(signal: context.signal), ); /// The main mutation. `RetryPolicy.never` is the default for mutations; /// it is written out because the screen makes a point of it. `onSuccess` /// returns the invalidation's future, which the library awaits before it /// reports success — upstream's "return the promise" idiom. MutationOptions incrementMutation( ShowcaseApi api, QueryClient client, ) => MutationOptions.simple( mutationFn: (request) => api.increment(by: request.by, fail: request.fail), retry: RetryPolicy.never, onSuccess: (_, __, ___) => client.invalidateQueries(filters: QueryFilters(queryKey: counterKey)), ); /// A mutation whose every option callback writes to [log], including /// `onMutate` — so the full constructor, with a `String` as what `onMutate` /// hands to the later callbacks. MutationOptions loggingMutation( ShowcaseApi api, QueryClient client, void Function(String line) log, ) => MutationOptions( mutationFn: (request) { log('mutationFn'); return api.increment(by: request.by, fail: request.fail); }, onMutate: (_) { log('onMutate'); return 'from onMutate'; }, onSuccess: (_, __, ___) async { log('onSuccess (options)'); await client.invalidateQueries( filters: QueryFilters(queryKey: counterKey), ); }, onError: (_, __, ___, ____) => log('onError (options)'), onSettled: (_, __, ___, ____, _____) => log('onSettled (options)'), ); /// An increment that takes a second on the backend, with or without a /// scope. Two of these in the same scope run one after the other. MutationOptions slowIncrementMutation( ShowcaseApi api, QueryClient client, { MutationScope? scope, }) => MutationOptions.simple( mutationFn: (request) => api.increment(by: request.by, delay: const Duration(seconds: 1)), scope: scope, onSuccess: (_, __, ___) => client.invalidateQueries(filters: QueryFilters(queryKey: counterKey)), ); class MutationsScreen extends StatefulWidget { const MutationsScreen({super.key}); @override State createState() => _MutationsScreenState(); } class _MutationsScreenState extends State { bool _away = false; @override Widget build(BuildContext context) => FeatureScaffold( feature: mutationsFeature, children: [ // One child, not one per card: the reader's cards come and go // together, and a single column is built in full. if (_away) _AwayView(onBack: () => setState(() => _away = false)) else _Reader(onLeave: () => setState(() => _away = true)), ], ); } /// Where the reader went: nothing here observes the counter or the /// mutation, and the count still shows the run in flight. class _AwayView extends StatelessWidget { const _AwayView({required this.onBack}); final VoidCallback onBack; @override Widget build(BuildContext context) => SectionCard( title: 'The reader is gone', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'The widget that called mutate has been unmounted. The ' 'MutationCache owns the mutation, so it still runs, still calls ' 'its option callbacks, and still invalidates the counter.', ), const SizedBox(height: 8), const Text('view=away', style: monoStyle), const SizedBox(height: 8), const _IsMutatingCount(), const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, child: ActionButton(label: 'Back to reader', onPressed: onBack), ), ], ), ); } /// Cards A to D. Unmounted whole by `Fire and leave`, which is the point of /// card D. class _Reader extends StatefulWidget { const _Reader({required this.onLeave}); final VoidCallback onLeave; @override State<_Reader> createState() => _ReaderState(); } class _ReaderState extends State<_Reader> with QueryMixin { late final ShowcaseApi _api; late final QueryClient _client; late final MutationController _first; late final MutationController _second; late final MutationController _third; late final MutationController _fourth; late final Listenable _pairs; bool _failNext = false; String? _asyncResult; final List _log = []; @override void initState() { super.initState(); // Neither lookup subscribes: the api and the client are fixed for the // life of the app, and a subscribing lookup is not allowed here anyway. _api = context.getInheritedWidgetOfExactType()!.api; _client = QueryClientProvider.read(context); const scope = MutationScope('counter'); _first = MutationController( _client, slowIncrementMutation(_api, _client, scope: scope), ); _second = MutationController( _client, slowIncrementMutation(_api, _client, scope: scope), ); _third = MutationController( _client, slowIncrementMutation(_api, _client), ); _fourth = MutationController( _client, slowIncrementMutation(_api, _client), ); _pairs = Listenable.merge([_first, _second, _third, _fourth]); } @override void dispose() { _first.dispose(); _second.dispose(); _third.dispose(); _fourth.dispose(); super.dispose(); } /// The next request: `fail: 500` once when the checkbox is on, which is /// then spent. Increment _nextRequest() { final request = (by: 1, fail: _failNext ? 500 : null); if (_failNext) { setState(() => _failNext = false); } return request; } Future _incrementAsync( MutationController mutation, ) async { final request = _nextRequest(); setState(() => _asyncResult = null); try { final value = await mutation.mutateAsync(request); if (mounted) { setState(() => _asyncResult = 'mutateAsync result=$value'); } } on Object catch (error) { if (mounted) { setState(() => _asyncResult = 'mutateAsync threw=$error'); } } } void _appendLog(String line) { // The option callbacks keep running after this reader is gone — that is // card D's point — so the guard is not academic. They run from the // mutation's own futures, never inside a build, so a plain setState is // safe otherwise. if (mounted) { setState(() => _log.add('${_log.length + 1} $line')); } } void _runWithCallbacks( MutationController mutation, ) { setState(_log.clear); mutation.mutate( (by: 1, fail: null), callbacks: MutateCallbacks( onSuccess: (_, __, ___) => _appendLog('onSuccess (call)'), onError: (_, __, ___, ____) => _appendLog('onError (call)'), onSettled: (_, __, ___, ____, _____) => _appendLog('onSettled (call)'), ), ); } @override Widget build(BuildContext context) { final counter = watchQuery(counterQuery(_api)); final increment = watchMutation( incrementMutation(_api, _client), id: #increment, ); final logging = watchMutation( loggingMutation(_api, _client, _appendLog), id: #logging, ); final result = increment.value; final counterText = switch (counter) { QueryPending() => 'counter=…', QueryError() => 'counter=error', QuerySuccess(:final data) => 'counter=$data', }; final facts = [ 'status=${result.status.name}', 'isPending=${result.isPending}', if (result case MutationSuccess(:final data)) 'data=$data', if (result case MutationError(:final error)) 'error=$error', 'failureCount=${result.failureCount}', 'submittedAt=${result.submittedAt == null ? 'none' : 'set'}', ]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SectionCard( title: 'A. One mutation, both ways to fire it', trailing: Text(counterText, style: monoStyle), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'mutate fires and forgets; mutateAsync returns a future the ' 'caller awaits. Both run the same mutation, whose onSuccess ' 'returns the invalidateQueries future — so the mutation stays ' 'pending until the counter has refetched, and the screen ' 'never shows a success next to a stale number. retry is ' 'RetryPolicy.never, the default for mutations.', ), const SizedBox(height: 12), Toolbar( children: [ ActionButton( label: 'Increment (mutate)', filled: true, onPressed: () => increment.mutate(_nextRequest()), ), ActionButton( label: 'Increment (mutateAsync)', filled: true, onPressed: () => _incrementAsync(increment), ), ActionButton(label: 'Reset', onPressed: increment.reset), ], ), const SizedBox(height: 8), FactGroup(name: 'mutation increment', facts: facts), if (_asyncResult != null) ...[ const SizedBox(height: 8), Text(_asyncResult!, style: monoStyle), ], CheckboxListTile( dense: true, contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, title: const Text('Fail next'), value: _failNext, onChanged: (value) => setState(() => _failNext = value ?? false), ), const Text( 'Asks the backend to refuse the next request with a 500. ' 'With no retries, that first failure is the error.', ), ], ), ), QueryDebugStrip(queryKey: counterKey, label: 'counter'), SectionCard( title: 'B. Callback order', trailing: const _IsMutatingCount(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'The option callbacks run first, each awaited before the ' 'next; the callbacks passed to this one mutate call run once ' 'the result is in, and only while this widget still listens.', ), const SizedBox(height: 12), Toolbar( children: [ ActionButton( label: 'Run with callbacks', filled: true, onPressed: () => _runWithCallbacks(logging), ), ], ), const SizedBox(height: 8), _LogPanel(_log), ], ), ), SectionCard( title: 'C. Scopes', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Mutations in the same MutationScope run one at a time: the ' 'second is pending from the start, paused, and its request ' 'is not sent until the first has settled. Without a scope, ' 'both requests go out together. Each takes a second on the ' 'backend.', ), const SizedBox(height: 12), Toolbar( children: [ ActionButton( label: 'Run two scoped', filled: true, onPressed: () { _first.mutate((by: 1, fail: null)); _second.mutate((by: 1, fail: null)); }, ), ActionButton( label: 'Run two unscoped', filled: true, onPressed: () { _third.mutate((by: 1, fail: null)); _fourth.mutate((by: 1, fail: null)); }, ), ], ), const SizedBox(height: 8), ListenableBuilder( listenable: _pairs, builder: (context, _) => FactGroup( name: 'mutation pairs', facts: [ 'first=${_first.value.status.name}', 'second=${_second.value.status.name}', 'secondPaused=${_second.value.isPaused}', 'third=${_third.value.status.name}', 'fourth=${_fourth.value.status.name}', ], ), ), ], ), ), SectionCard( title: 'D. After the widget is gone', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Fires mutate and unmounts this reader in the same tap. The ' 'mutation belongs to the MutationCache, not to the widget: it ' 'runs to the end and invalidates the counter, which refetches ' 'when the reader comes back.', ), const SizedBox(height: 12), Toolbar( children: [ ActionButton( label: 'Fire and leave', filled: true, onPressed: () { increment.mutate(_nextRequest()); widget.onLeave(); }, ), ], ), ], ), ), ], ); } } /// The callback log, one line per text. class _LogPanel extends StatelessWidget { const _LogPanel(this.lines); final List lines; @override Widget build(BuildContext context) => Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), ), child: SemanticsGroup( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (lines.isEmpty) const Text('log=empty', style: monoStyle) else for (final line in lines) Text(line, style: monoStyle), ], ), ), ); } /// `isMutating=`: how many mutations in the whole cache are pending, read /// from the client on every mutation-cache event. /// /// Subscribed to the cache directly rather than through `CacheStats`, which /// only listens to the query cache. Rebuilt the way the debug strip is: an /// event can arrive from inside a frame, when a rebuild has to wait for it /// to end. class _IsMutatingCount extends StatefulWidget { const _IsMutatingCount(); @override State<_IsMutatingCount> createState() => _IsMutatingCountState(); } class _IsMutatingCountState extends State<_IsMutatingCount> with PhaseSafeRebuild<_IsMutatingCount> { late final QueryClient _client; late final void Function() _unsubscribe; @override void initState() { super.initState(); _client = QueryClientProvider.read(context); _unsubscribe = _client.mutationCache.subscribe((_) => scheduleRebuild()); } @override void dispose() { _unsubscribe(); super.dispose(); } @override Widget build(BuildContext context) => Row( mainAxisSize: MainAxisSize.min, children: [ Text( 'client.isMutating()', style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(width: 8), Text('isMutating=${_client.isMutating()}', style: monoStyle), ], ); } ```
## Related - Guides: [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md), [Invalidations from mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md), [Mutation scopes](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-scopes.md) - Tested by `test/features/mutations_test.dart` (widget) and `e2e/tests/mutations.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutations) --- # Optimistic updates > A new row on screen before the server confirms it, two ways — rendered from the pending mutation's variables, or written into the cache and rolled back on error. Adding a row to a list without waiting for the server, in the two shapes the pattern comes in. *Via variables* leaves the cache alone and renders the pending mutation's `variables` as one greyed row, turning it into an error row with *Retry* if the write is refused. *Via cache* writes the row into the cached list in `onMutate`, so every reader of that key sees it, and puts the snapshot back in `onError`. Both invalidate the list when the write settles. Reach for the first when only the screen that writes needs to show the row, a comment box under a post, say; the second when other screens read the same list, a cart badge and a cart page, or a device list and its detail. Live demo: [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/optimistic-updates), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/optimistic_updates)). Show the write before the server answers — two ways. ## What to try - With *Via variables* selected, type into *New todo* and press *Add*. The text appears at once as a greyed row with a *saving* pill, while the card still reads the old `todos=` count: the cache has not been touched. After the write and the refetch it becomes a real row with an id. - Tick *Refuse next write* and add again. The row turns into *Not saved: Requested: 500* with a *Retry* button, and the count stays where it was. *Retry* sends the same text again; the tick has cleared itself, so it goes through. - Switch to *Via cache* and add a todo: the `todos=` count goes up before the server answers, because the row is in the cache. - Tick *Refuse next write* and add under *Via cache*: the row appears, then disappears again, and *Rolled back: Requested: 500* is shown. The debug strip shows the refetch on settle still happening. ## The code The variables shape needs no `onMutate` and nothing to roll back; returning the invalidation's future from `onSettled` keeps the mutation pending, and the *saving* row on screen, until the refetched list has landed: [`examples/showcase/lib/features/optimistic_updates/optimistic_updates_screen.dart`, lines 80–89](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/optimistic_updates/optimistic_updates_screen.dart#L80-L89): ```dart MutationOptions addTodoViaVariables( QueryClient client, ShowcaseApi api, FailKnob fail, ) => MutationOptions.simple( mutationFn: (text) => api.createTodo(text, fail: fail()), onSettled: (_, __, ___, ____, _____) => client.invalidateQueries(filters: _todosFilter), ); ``` This variant reads with `context.query` and `context.mutation`, and switches over the mutation's sealed result to render the extra row from its `variables`, which survive an error: [`examples/showcase/lib/features/optimistic_updates/optimistic_updates_screen.dart`, lines 251–290](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/optimistic_updates/optimistic_updates_screen.dart#L251-L290): ```dart final todos = context.query(todosQuery(api)); final add = context.mutation(addTodoViaVariables(client, api, fail)); final result = add.value; return _TodoCard( title: 'Todos · via variables', todos: todos, pending: result.isPending, onAdd: result.isPending ? null : () => _submit(text, add.mutate), extraRows: switch (result) { MutationPending(:final variables?) => [ _TodoRow(text: variables, saving: true), ], MutationError(:final variables?, :final error) => [ Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(variables), const SizedBox(height: 4), Notice('Not saved: $error', error: true), ], ), ), const SizedBox(width: 8), // The variables survive the error, so the same text can be // sent again without the user retyping it. TextButton( onPressed: () => add.mutate(variables), child: const Text('Retry'), ), ], ), ], _ => const [], }, ); ``` The cache shape cancels any fetch of the list in flight (its late answer would overwrite the new row), snapshots the list, writes the row with a temporary negative id and hands the snapshot to `onError`. The third type argument is what `onMutate` returns: [`examples/showcase/lib/features/optimistic_updates/optimistic_updates_screen.dart`, lines 93–135](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/optimistic_updates/optimistic_updates_screen.dart#L93-L135): ```dart MutationOptions addTodoViaCache( QueryClient client, ShowcaseApi api, FailKnob fail, ) => MutationOptions( mutationFn: (text) => api.createTodo(text, fail: fail()), onMutate: (text) async { // A refetch already in flight would land *after* the write below and // put the backend's list — without this row — back over it. await client.cancelQueries(filters: _todosFilter); final previous = client.getQueryData>(ShowcaseKeys.todos); // Negative, so it can never collide with an id the backend hands out, // and one below the rows already there, so two writes in flight get // two different ids. final optimisticId = -((previous?.length ?? 0) + 1); client.updateQueryData>( ShowcaseKeys.todos, (old) => [ ...?old, Todo(id: optimisticId, text: text, done: false), ], ); return (previous: previous, optimisticId: optimisticId); }, onError: (_, __, ___, snapshot) { if (snapshot == null) { return; } if (snapshot.previous case final List previous) { client.setQueryData>(ShowcaseKeys.todos, previous); } else { // Nothing was cached before the write: drop only the row it added. client.updateQueryData>( ShowcaseKeys.todos, (old) => old?.where((todo) => todo.id != snapshot.optimisticId).toList(), ); } }, onSettled: (_, __, ___, ____, _____) => client.invalidateQueries(filters: _todosFilter), ); ```
The whole screen [`examples/showcase/lib/features/optimistic_updates/optimistic_updates_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/optimistic_updates/optimistic_updates_screen.dart): ```dart /// Upstream's `nextjs-app-optimistic-updates` example: a todo shown in the /// list before the backend has confirmed it, two ways. /// /// **Via variables** (upstream `TodoListUI`): the cache is never touched. /// While the mutation is pending, its `variables` — the text being saved — /// are rendered as one extra greyed row; when it fails, that row turns into /// an error with a `Retry` button that runs `mutate` again with the same /// variables. `onSettled` invalidates the list either way. This variant reads /// with `context.query` and `context.mutation`. /// /// **Via cache** (upstream `TodoListCache`): `onMutate` cancels the todos /// query, snapshots `getQueryData`, writes the optimistic row into the cache /// with a negative temporary id and returns the snapshot; `onError` puts the /// snapshot back; `onSettled` invalidates. Every reader of the key sees the /// row, and the rollback, without knowing about the mutation. This variant /// is a `QueryBuilder` around a `MutationBuilder`. /// /// `Refuse next write` asks the backend to answer the next `POST` with 500 /// (`?fail=500`), consumed by the request that carries it, so a retry of a /// refused write is a clean one. /// /// Proofs (widget tests in `test/features/optimistic_updates_test.dart`, /// end-to-end in `e2e/tests/optimistic_updates.spec.ts`): via variables, a /// held write shows the text as a `saving` row while the cache still holds /// three todos, and becomes a real row after one `POST` and one refetch; a /// refused write shows the error row with `Retry`, leaves the cache alone, /// and `Retry` makes it real. Via cache, a held write is in the cache — four /// entries, `todos=4` — before the backend answers, and the temporary row is /// replaced by the backend's after the refetch; a refused write appears, /// disappears again, and `Rolled back: Requested: 500` is shown while the /// refetch on settle still happens. And `onMutate`'s `cancelQueries` really /// cancels an in-flight refetch, whose late answer would otherwise overwrite /// the optimistic row. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature optimisticUpdatesFeature = Feature( id: 'optimistic-updates', title: 'Optimistic updates', summary: 'Show the write before the server answers — two ways.', upstream: 'nextjs-app-optimistic-updates', ); /// The list both variants read. One key, so a write through either variant /// is seen by whichever is on screen. QueryObserverOptions> todosQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), ); /// The status the next write asks the backend to refuse with, or `null` for /// a write that should succeed. Read when the request goes out — not when /// the button is pressed — so `Retry` re-sends the same variables without /// re-sending the refusal. typedef FailKnob = int? Function(); /// What the cache variant's `onMutate` hands to `onError`: the list as it /// was, and the id of the row it wrote — upstream's `MutationContext`. typedef TodosSnapshot = ({List? previous, int optimisticId}); QueryFilters get _todosFilter => QueryFilters(queryKey: ShowcaseKeys.todos); /// Upstream's `TodoListUI` mutation: no `onMutate`, so `MutationOptions.simple` /// and nothing to roll back. `onSettled` returns the invalidation's future, /// which keeps the mutation pending — and the `saving` row on screen — until /// the refetch has landed, so the row never flickers off before the real one /// is there. MutationOptions addTodoViaVariables( QueryClient client, ShowcaseApi api, FailKnob fail, ) => MutationOptions.simple( mutationFn: (text) => api.createTodo(text, fail: fail()), onSettled: (_, __, ___, ____, _____) => client.invalidateQueries(filters: _todosFilter), ); /// Upstream's `TodoListCache` mutation: cancel, snapshot, write, and on /// error put the snapshot back. MutationOptions addTodoViaCache( QueryClient client, ShowcaseApi api, FailKnob fail, ) => MutationOptions( mutationFn: (text) => api.createTodo(text, fail: fail()), onMutate: (text) async { // A refetch already in flight would land *after* the write below and // put the backend's list — without this row — back over it. await client.cancelQueries(filters: _todosFilter); final previous = client.getQueryData>(ShowcaseKeys.todos); // Negative, so it can never collide with an id the backend hands out, // and one below the rows already there, so two writes in flight get // two different ids. final optimisticId = -((previous?.length ?? 0) + 1); client.updateQueryData>( ShowcaseKeys.todos, (old) => [ ...?old, Todo(id: optimisticId, text: text, done: false), ], ); return (previous: previous, optimisticId: optimisticId); }, onError: (_, __, ___, snapshot) { if (snapshot == null) { return; } if (snapshot.previous case final List previous) { client.setQueryData>(ShowcaseKeys.todos, previous); } else { // Nothing was cached before the write: drop only the row it added. client.updateQueryData>( ShowcaseKeys.todos, (old) => old?.where((todo) => todo.id != snapshot.optimisticId).toList(), ); } }, onSettled: (_, __, ___, ____, _____) => client.invalidateQueries(filters: _todosFilter), ); enum _Variant { variables, cache } class OptimisticUpdatesScreen extends StatefulWidget { const OptimisticUpdatesScreen({super.key}); @override State createState() => _OptimisticUpdatesScreenState(); } class _OptimisticUpdatesScreenState extends State { final TextEditingController _text = TextEditingController(); _Variant _variant = _Variant.variables; bool _refuseNext = false; @override void dispose() { _text.dispose(); super.dispose(); } /// Consumed by the request that carries it, whichever variant sends it. int? _takeFail() { if (!_refuseNext) { return null; } setState(() => _refuseNext = false); return 500; } @override Widget build(BuildContext context) => FeatureScaffold( feature: optimisticUpdatesFeature, children: [ SectionCard( title: 'Add a todo', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _text, decoration: const InputDecoration( labelText: 'New todo', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), SegmentedButton<_Variant>( showSelectedIcon: false, segments: const >[ ButtonSegment<_Variant>( value: _Variant.variables, label: Text('Via variables'), ), ButtonSegment<_Variant>( value: _Variant.cache, label: Text('Via cache'), ), ], selected: <_Variant>{_variant}, onSelectionChanged: (selection) => setState(() => _variant = selection.first), ), // No subtitle: a tile folds it into the checkbox's accessible // name, and the tests find the box by its title alone. CheckboxListTile( title: const Text('Refuse next write'), contentPadding: EdgeInsets.zero, value: _refuseNext, onChanged: (value) => setState(() => _refuseNext = value ?? false), ), Text( 'Ticked, the next POST asks the backend for a 500 and the ' 'tick clears itself. Via variables the row is rendered from ' 'the pending mutation; via cache it is written into the ' 'cache and rolled back on error.', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'), switch (_variant) { _Variant.variables => _ViaVariables(text: _text, fail: _takeFail), _Variant.cache => _ViaCache(text: _text, fail: _takeFail), }, ], ); } /// Takes the field's text, if any, and hands it to [mutate]. void _submit(TextEditingController text, void Function(String) mutate) { final value = text.text.trim(); if (value.isEmpty) { return; } mutate(value); text.clear(); } /// Upstream's `TodoListUI`: the pending row comes from the mutation's /// `variables`, the error row too, and the cache holds only what the backend /// said. class _ViaVariables extends StatelessWidget { const _ViaVariables({required this.text, required this.fail}); final TextEditingController text; final FailKnob fail; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final client = QueryClientProvider.of(context); final todos = context.query(todosQuery(api)); final add = context.mutation(addTodoViaVariables(client, api, fail)); final result = add.value; return _TodoCard( title: 'Todos · via variables', todos: todos, pending: result.isPending, onAdd: result.isPending ? null : () => _submit(text, add.mutate), extraRows: switch (result) { MutationPending(:final variables?) => [ _TodoRow(text: variables, saving: true), ], MutationError(:final variables?, :final error) => [ Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(variables), const SizedBox(height: 4), Notice('Not saved: $error', error: true), ], ), ), const SizedBox(width: 8), // The variables survive the error, so the same text can be // sent again without the user retyping it. TextButton( onPressed: () => add.mutate(variables), child: const Text('Retry'), ), ], ), ], _ => const [], }, ); } } /// Upstream's `TodoListCache`: the list is whatever the cache holds, and the /// optimistic row is in it — told apart only by its negative id. class _ViaCache extends StatelessWidget { const _ViaCache({required this.text, required this.fail}); final TextEditingController text; final FailKnob fail; @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final client = QueryClientProvider.of(context); return QueryBuilder>( options: todosQuery(api), builder: (context, todos) => MutationBuilder( options: addTodoViaCache(client, api, fail), builder: (context, add) { final result = add.value; return _TodoCard( title: 'Todos · via cache', todos: todos, pending: result.isPending, onAdd: result.isPending ? null : () => _submit(text, add.mutate), notice: switch (result) { MutationError(:final error) => Notice('Rolled back: $error', error: true), _ => null, }, ); }, ), ); } } /// The list card both variants render: its facts, its rows, and whatever a /// variant appends below them. class _TodoCard extends StatelessWidget { const _TodoCard({ required this.title, required this.todos, required this.pending, required this.onAdd, this.notice, this.extraRows = const [], }); final String title; final QueryResult> todos; final bool pending; final VoidCallback? onAdd; final Widget? notice; final List extraRows; @override Widget build(BuildContext context) { final rows = todos.dataOrNull ?? const []; return SectionCard( title: title, // Explicit child nodes: two buttons in one row would otherwise fold // into a single semantics node, and the tests find each by name. trailing: SemanticsGroup( child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Refetch', onPressed: todos.isFetching ? null : todos.refetch, icon: const Icon(Icons.refresh), ), FilledButton(onPressed: onAdd, child: const Text('Add')), ], ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SemanticsGroup( child: Wrap( spacing: 12, children: [ Text('todos=${rows.length}'), Text('pending=$pending'), ], ), ), const SizedBox(height: 8), if (notice case final Widget notice) ...[ notice, const SizedBox(height: 8), ], switch (todos) { QueryPending() => const Column( children: [ SkeletonBox(), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 8), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), _ => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final todo in rows) _TodoRow( text: todo.text, done: todo.done, id: todo.id, saving: todo.id < 0, ), ...extraRows, ], ), }, ], ), ); } } /// One row. A [saving] row — the cache variant's negative id, or the /// variables variant's pending text — is greyed and carries the pill instead /// of an id. class _TodoRow extends StatelessWidget { const _TodoRow({ required this.text, this.done = false, this.id, this.saving = false, }); final String text; final bool done; final int? id; final bool saving; @override Widget build(BuildContext context) => Opacity( opacity: saving ? 0.5 : 1, child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ Icon( done ? Icons.check_box : Icons.check_box_outline_blank, size: 20, ), const SizedBox(width: 12), Expanded(child: Text(text)), if (saving) const Pill('saving') else if (id case final int id) Text('#$id', style: Theme.of(context).textTheme.labelSmall), ], ), ), ); } ```
## Related - Guides: [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md), [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md), [Query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md) - Upstream: TanStack's React [`nextjs-app-optimistic-updates`](https://github.com/TanStack/query/tree/main/examples/react/nextjs-app-optimistic-updates) example - Tested by `test/features/optimistic_updates_test.dart` (widget) and `e2e/tests/optimistic_updates.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/optimistic_updates) --- # Mutation context and cancel > An optimistic rename whose mutation function reads what onMutate kept and passes the cancel signal to its transport, and a Cancel button that fails the run so the rollback runs. An optimistic rename of the first todo that takes three seconds on the server, with a *Cancel* button to call it off. `onMutate` writes the new text into the cache and returns the old list; `mutationFnWithContext` reads that list back from its context to send the previous text along, and passes the context's cancel signal to the HTTP client. Cancelling is a failure: the run ends with a `CancelledError`, `onError` puts the old text back, and `onSettled` refetches the list, because nobody can know whether the server already took the write. This is the shape of a slow write a user may change their mind about: uploading a firmware setting to a device, renaming a shared folder, submitting a long form over a poor connection. Live demo: [Mutation context and cancel](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/mutation-cancel), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutation_cancel)). A write that reads what onMutate kept, and can be called off. ## What to try - Type into *New text* and press *Rename*. The first todo shows the new text at once and the facts read `status=pending`, `text=` the new text, and `from=` the old one, which is what the mutation function read from `onMutate`'s result. - Wait the three seconds: `status=success`, `error=none`, `settles=1`, and no rollback. - Rename again and press *Cancel* while it is pending. The text goes back at once, `signalCancels=1` and `rollbacks=1`, then `status=error` with `error=cancelled`, and the debug strip shows the list being refetched. A notice under the list says the refetch is what found out what the server holds. - Rename once more after a cancel: it goes through as usual. ## The code The mutation function takes a second argument, the run's context. Its `onMutateResult` is typed by the options' third type argument, and its `signal` goes to the transport, the same way a query's does: [`examples/showcase/lib/features/mutation_cancel/mutation_cancel_screen.dart`, lines 106–124](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_cancel/mutation_cancel_screen.dart#L106-L124): ```dart mutationFnWithContext: (rename, context) { // The cache says `rename.text` already. What it said before is what // `onMutate` kept. final from = context.onMutateResult ?.where((todo) => todo.id == rename.id) .firstOrNull ?.text; probe.onSent(from); // Beside the bridge's own `onCancel`, which is dio's: this one only // counts, as proof that `cancel()` reached the signal. context.signal.onCancel(probe.onSignalCancelled); return api.updateTodo( rename.id, text: rename.text, from: from, signal: context.signal, delay: slowWriteDelay, ); }, ``` A cancelled run reaches `onError` like any other failure, so the ordinary rollback covers it: [`examples/showcase/lib/features/mutation_cancel/mutation_cancel_screen.dart`, lines 125–132](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_cancel/mutation_cancel_screen.dart#L125-L132): ```dart // A cancelled run arrives here as any other failure does, with a // `CancelledError`: there is no second rollback to write. onError: (_, __, ___, previous) { if (previous != null) { client.setQueryData>(ShowcaseKeys.todos, previous); probe.onRolledBack(); } }, ``` The screen reads the mutation with `watchMutation` from `QueryMixin`, and the button calls `cancel` on what it returns, only while a run is pending: [`examples/showcase/lib/features/mutation_cancel/mutation_cancel_screen.dart`, lines 234–239](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_cancel/mutation_cancel_screen.dart#L234-L239): ```dart // Enabled only while there is a run to call off; with none // in flight `cancel()` does nothing anyway. ActionButton( label: 'Cancel', onPressed: result.isPending ? rename.cancel : null, ), ```
The whole screen [`examples/showcase/lib/features/mutation_cancel/mutation_cancel_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_cancel/mutation_cancel_screen.dart): ```dart /// A write that knows about its run, and can be called off: /// `mutationFnWithContext` and `cancel()`. /// /// Port-specific — upstream cannot cancel a mutation, and its /// `MutationFunctionContext` carries neither of the two fields this screen is /// about. The mutation is an optimistic rename of the first todo: /// /// * `onMutate` cancels the list's fetches, snapshots it, writes the new text /// into the cache and returns the snapshot. /// * `mutationFnWithContext` sends the write. The cache already says the new /// text by then, so "what was it before?" cannot be read from there any /// more: it comes from **`context.onMutateResult`** and goes out as `from`. /// And **`context.signal`** goes to dio through `ShowcaseApi.bridge`, the /// same bridge a query's signal takes. /// * `onError` puts the snapshot back, and `onSettled` invalidates the list. /// /// **Cancelling is failing.** `Cancel` calls `cancel()` on the controller the /// mixin handed out: the run fails with a `CancelledError`, so the rollback /// above rolls it back and the invalidation above asks the backend what it /// really holds — which nobody can know otherwise, because the request may /// have arrived. The write takes three seconds on the backend (`?delay`), long /// enough for a human to press `Cancel` in the middle of it. /// /// The list and the mutation are read through `QueryMixin` (`watchQuery`, /// `watchMutation`). /// /// Proofs (widget tests in `test/features/mutation_cancel_test.dart`, /// end-to-end in `e2e/tests/mutation_cancel.spec.ts`): a held rename is in the /// cache before the backend answers, `from=` is the text `onMutate` kept, and /// after the answer the backend's log shows that `from` in the request's /// query; cancelling a held rename ends in `status=error` with /// `error=cancelled`, one `signal.onCancel`, one rollback to the old text and /// one settle whose invalidation refetched the list — while the browser gave /// the request up and the backend never answered a `PATCH`; and a rename after /// a cancelled one goes through. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature mutationCancelFeature = Feature( id: 'mutation-cancel', title: 'Mutation context and cancel', summary: 'A write that reads what onMutate kept, and can be called off.', ); /// The todo every rename here is of. const int renamedTodoId = 1; /// Long enough that a human can hit `Cancel` in the middle of it. const Duration slowWriteDelay = Duration(seconds: 3); /// What one rename asks for. typedef Rename = ({int id, String text}); /// What the screen wants to be told about a run, so it can count it: the /// mutation itself knows nothing of the screen. typedef RenameProbe = ({ void Function(String? from) onSent, VoidCallback onSignalCancelled, VoidCallback onRolledBack, VoidCallback onSettled, }); QueryFilters get _todosFilter => QueryFilters(queryKey: ShowcaseKeys.todos); QueryObserverOptions> todosQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), ); /// The rename. What `onMutate` returns — the list as it was — is the third /// type argument, and so it is what `context.onMutateResult` is typed as. MutationOptions> renameMutation( ShowcaseApi api, QueryClient client, RenameProbe probe, ) => MutationOptions>( onMutate: (rename) async { // A refetch already in flight would land after the write below and // put the old text back over it. await client.cancelQueries(filters: _todosFilter); final previous = client.getQueryData>(ShowcaseKeys.todos); client.updateQueryData>( ShowcaseKeys.todos, (old) => [ for (final todo in old ?? const []) todo.id == rename.id ? todo.copyWith(text: rename.text) : todo, ], ); return previous; }, mutationFnWithContext: (rename, context) { // The cache says `rename.text` already. What it said before is what // `onMutate` kept. final from = context.onMutateResult ?.where((todo) => todo.id == rename.id) .firstOrNull ?.text; probe.onSent(from); // Beside the bridge's own `onCancel`, which is dio's: this one only // counts, as proof that `cancel()` reached the signal. context.signal.onCancel(probe.onSignalCancelled); return api.updateTodo( rename.id, text: rename.text, from: from, signal: context.signal, delay: slowWriteDelay, ); }, // A cancelled run arrives here as any other failure does, with a // `CancelledError`: there is no second rollback to write. onError: (_, __, ___, previous) { if (previous != null) { client.setQueryData>(ShowcaseKeys.todos, previous); probe.onRolledBack(); } }, onSettled: (_, __, ___, ____, _____) { probe.onSettled(); return client.invalidateQueries(filters: _todosFilter); }, ); class MutationCancelScreen extends StatefulWidget { const MutationCancelScreen({super.key}); @override State createState() => _MutationCancelScreenState(); } class _MutationCancelScreenState extends State with QueryMixin, PhaseSafeRebuild { final TextEditingController _text = TextEditingController(); late final ShowcaseApi _api; late final QueryClient _client; String? _from; int _signalCancels = 0; int _rollbacks = 0; int _settles = 0; @override void initState() { super.initState(); // Neither lookup subscribes: the api and the client are fixed for the // life of the app, and a subscribing lookup is not allowed here anyway. _api = context.getInheritedWidgetOfExactType()!.api; _client = QueryClientProvider.read(context); } @override void dispose() { _text.dispose(); super.dispose(); } /// The probe's callbacks run from the mutation's own futures — and /// `onSignalCancelled` synchronously inside `cancel()` — and a mutation /// outlives the widget that fired it, so: only while mounted, and whatever /// the scheduler phase. void _count(VoidCallback change) { if (!mounted) { return; } change(); scheduleRebuild(); } late final RenameProbe _probe = ( onSent: (from) => _count(() => _from = from), onSignalCancelled: () => _count(() => _signalCancels += 1), onRolledBack: () => _count(() => _rollbacks += 1), onSettled: () => _count(() => _settles += 1), ); void _submit(void Function(Rename) mutate) { final value = _text.text.trim(); if (value.isEmpty) { return; } mutate((id: renamedTodoId, text: value)); _text.clear(); } @override Widget build(BuildContext context) { final todos = watchQuery(todosQuery(_api)); final rename = watchMutation(renameMutation(_api, _client, _probe)); final result = rename.value; final rows = todos.dataOrNull ?? const []; final first = rows.where((todo) => todo.id == renamedTodoId).firstOrNull; return FeatureScaffold( feature: mutationCancelFeature, children: [ SectionCard( title: 'Rename the first todo', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _text, decoration: const InputDecoration( labelText: 'New text', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), Toolbar( children: [ ActionButton( label: 'Rename', filled: true, onPressed: result.isPending || first == null ? null : () => _submit(rename.mutate), ), // Enabled only while there is a run to call off; with none // in flight `cancel()` does nothing anyway. ActionButton( label: 'Cancel', onPressed: result.isPending ? rename.cancel : null, ), ], ), const SizedBox(height: 8), Text( 'The write takes three seconds on the backend. The new text ' 'is on screen at once, written into the cache by onMutate; ' 'Cancel fails the run with a CancelledError, so onError puts ' 'the old text back and onSettled refetches the list.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), FactGroup( name: 'rename facts', dense: true, facts: [ 'status=${result.status.name}', 'error=${switch (result) { MutationError(error: CancelledError()) => 'cancelled', MutationError(:final error) => '$error', _ => 'none', }}', 'text=${first?.text ?? 'none'}', 'from=${_from ?? 'none'}', 'signalCancels=$_signalCancels', 'rollbacks=$_rollbacks', 'settles=$_settles', ], ), ], ), ), QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'), SectionCard( title: 'Todos', trailing: todos.isFetching ? const Pill('fetching') : null, child: switch (todos) { QueryPending() => const Column( children: [ SkeletonBox(), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 8), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), _ => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final todo in rows) Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ Expanded(child: Text(todo.text)), Text( '#${todo.id}', style: Theme.of(context).textTheme.labelSmall, ), ], ), ), ], ), }, ), if (result case MutationError(error: CancelledError())) const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Notice( 'Cancelled. Whether the backend took the write is not known ' 'here — the refetch on settle is what found out.', ), ), ], ); } } ```
## Related - Guides: [Cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md), [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md), [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) - Tested by `test/features/mutation_cancel_test.dart` (widget) and `e2e/tests/mutation_cancel.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutation_cancel) --- # Mutation state > A badge that counts every running and failed write under one mutation key, read from the mutation cache by a widget that owns none of them. A "saving…" badge that knows about writes it did not start. The buttons fire mutations nobody on screen watches; the badge reads the mutation cache through a `MutationStateController`, which picks the mutations with `MutationFilters` and turns each into the value the badge needs with `select`. Two concurrent writes under one key stay two entries, so the badge can say `2`. Use it wherever the indicator and the writer live apart: a sync spinner in an app bar while list rows save themselves, an upload counter in a bottom bar, a "changes pending" hint on a settings screen whose fields each save on their own. Live demo: [Mutation state](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/mutation-state), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutation_state)). Every running mutation in the cache, read by a widget that owns none of them. ## What to try - Press *Add todo* twice in quick succession: the badge reads `saving=2` with a spinner, then drops back to `saving=0` as both land, and the list's `todos=` count goes up by two. - Press *Add, failing*: `saving=1` while it runs, then `saving=0` and `failed=1`. A failed write is counted as an error, not as pending. - Watch `tracked`: it counts every run the cache still holds under the key, settled ones included, until each is garbage-collected after its `gcTime`. - Watch `badge-builds`: it goes up only when the selected list of statuses changes, not on every cache event. ## The code Both buttons build the same options, with one `mutationKey` for the badge to filter on: [`examples/showcase/lib/features/mutation_state/mutation_state_screen.dart`, lines 70–78](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_state/mutation_state_screen.dart#L70-L78): ```dart MutationOptions _addOptions({bool fail = false}) => MutationOptions.simple( mutationKey: addTodoKey, mutationFn: (String text) => _api.createTodo(text, delay: _slow, fail: fail ? 500 : null), onSuccess: (_, __, ___) => _client.invalidateQueries( filters: QueryFilters(queryKey: ShowcaseKeys.todos), ), ); ``` Each press runs an owned `MutationController` that nothing reads and that disposes itself once the run has settled: [`examples/showcase/lib/features/mutation_state/mutation_state_screen.dart`, lines 80–96](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_state/mutation_state_screen.dart#L80-L96): ```dart /// Runs a mutation nobody watches: an owned controller that disposes itself /// when it settles. The badge still sees it, which is the whole point. void _fireAndForget({bool fail = false}) { final controller = MutationController( _client, _addOptions(fail: fail), ); final text = fail ? 'doomed write' : 'write ${++_added}'; controller .mutateAsync(text) .then((_) {}, onError: (Object _) {}) // Detach only after the badge has seen the settled state; disposing // sooner would drop the entry before its last event. .whenComplete(() => WidgetsBinding.instance .addPostFrameCallback((_) => controller.dispose())); setState(() {}); } ``` The badge's controller selects each matching mutation's status. The selection goes through structural sharing, so an event that leaves the list equal does not notify, and the badge rebuilds through a `ListenableBuilder`: [`examples/showcase/lib/features/mutation_state/mutation_state_screen.dart`, lines 176–180](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_state/mutation_state_screen.dart#L176-L180): ```dart _statuses = MutationStateController( widget.client, filters: MutationFilters(mutationKey: addTodoKey), select: (mutation) => mutation.state.status, ); ```
The whole screen [`examples/showcase/lib/features/mutation_state/mutation_state_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_state/mutation_state_screen.dart): ```dart /// Mutation state: what every mutation in the cache is doing, read from a /// widget that owns none of them. Port-specific — it is /// `MutationStateController`, the port's `useMutationState`. /// /// The problem it solves: a mutation is owned by the widget that asks for it, /// so the widget that wants to show "2 saving…" in an app bar cannot see it. /// A `MutationStateController` reads the *cache* instead — `MutationFilters` /// picks the mutations, a `select` turns each one into whatever the badge /// needs — so the writer and the indicator never have to know each other. /// /// Two things to notice. Concurrent runs under one key stay separate entries, /// which is why the badge can say `2` for two saves of the same kind. And the /// selection goes through structural sharing, so a cache event that leaves the /// selected list equal does not rebuild the badge at all. /// /// Proofs (widget tests in `test/features/mutation_state_test.dart`, /// end-to-end in `e2e/tests/mutation_state.spec.ts`): the badge counts two /// concurrent adds under one key as two, drops back to zero when they settle, /// counts a failing mutation as an error rather than as pending, and rebuilds /// only when the selection actually changed. The todos list under it is the /// thing being written to, so the invalidation the writers fire has a reader. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature mutationStateFeature = Feature( id: 'mutation-state', title: 'Mutation state', summary: 'Every running mutation in the cache, read by a widget that owns ' 'none of them.', ); /// The key both writers share, so the badge can filter on it and still see /// two concurrent runs as two entries. QueryKey get addTodoKey => QueryKey(const ['todos', 'add']); class MutationStateScreen extends StatefulWidget { const MutationStateScreen({super.key}); @override State createState() => _MutationStateScreenState(); } class _MutationStateScreenState extends State { /// Slow enough that two adds are in flight together without a stopwatch: /// the test presses twice, then lets the backend answer. static const Duration _slow = Duration(milliseconds: 600); late final ShowcaseApi _api; late final QueryClient _client; int _added = 0; @override void initState() { super.initState(); _api = context.getInheritedWidgetOfExactType()!.api; _client = QueryClientProvider.read(context); } MutationOptions _addOptions({bool fail = false}) => MutationOptions.simple( mutationKey: addTodoKey, mutationFn: (String text) => _api.createTodo(text, delay: _slow, fail: fail ? 500 : null), onSuccess: (_, __, ___) => _client.invalidateQueries( filters: QueryFilters(queryKey: ShowcaseKeys.todos), ), ); /// Runs a mutation nobody watches: an owned controller that disposes itself /// when it settles. The badge still sees it, which is the whole point. void _fireAndForget({bool fail = false}) { final controller = MutationController( _client, _addOptions(fail: fail), ); final text = fail ? 'doomed write' : 'write ${++_added}'; controller .mutateAsync(text) .then((_) {}, onError: (Object _) {}) // Detach only after the badge has seen the settled state; disposing // sooner would drop the entry before its last event. .whenComplete(() => WidgetsBinding.instance .addPostFrameCallback((_) => controller.dispose())); setState(() {}); } @override Widget build(BuildContext context) => FeatureScaffold( feature: mutationStateFeature, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton.tonal( onPressed: _fireAndForget, child: const Text('Add todo'), ), OutlinedButton( onPressed: () => _fireAndForget(fail: true), child: const Text('Add, failing'), ), ], ), ), _SavingBadge(client: _client), // The list the writers invalidate. Without a reader on screen the // invalidation would be a write into an empty cache, and the strip // below would say `status=absent` for good. Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: QueryBuilder>( options: QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => _api.todos(signal: context.signal), ), builder: (context, result) => SectionCard( title: 'Todos', trailing: switch (result) { QueryResult(isLoading: true) => const Pill('loading'), QueryResult(isRefetching: true) => const Pill('refreshing'), _ => const SizedBox.shrink(), }, child: switch (result) { QueryPending() => const SkeletonBox(height: 20), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => SemanticsGroup( child: Text( 'todos=${data.length}', style: const TextStyle(fontFamily: 'monospace'), ), ), }, ), ), ), QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'), ], ); } /// The indicator. It owns no mutation and is not rebuilt by the writers — /// only by its own controller, and only when the selection changed. class _SavingBadge extends StatefulWidget { const _SavingBadge({required this.client}); final QueryClient client; @override State<_SavingBadge> createState() => _SavingBadgeState(); } class _SavingBadgeState extends State<_SavingBadge> { late final MutationStateController _statuses; int _builds = 0; @override void initState() { super.initState(); _statuses = MutationStateController( widget.client, filters: MutationFilters(mutationKey: addTodoKey), select: (mutation) => mutation.state.status, ); } @override void dispose() { _statuses.dispose(); super.dispose(); } @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: SemanticsGroup( child: ListenableBuilder( listenable: _statuses, builder: (context, _) { _builds++; final statuses = _statuses.value; final pending = statuses .where((status) => status == MutationStatus.pending) .length; final failed = statuses .where((status) => status == MutationStatus.error) .length; return Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ if (pending > 0) const SizedBox( height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2), ), Text( 'saving=$pending', style: const TextStyle(fontFamily: 'monospace'), ), Text( 'failed=$failed', style: const TextStyle(fontFamily: 'monospace'), ), Text( 'tracked=${statuses.length}', style: const TextStyle(fontFamily: 'monospace'), ), // Proof that an unchanged selection does not rebuild. Text( 'badge-builds=$_builds', style: const TextStyle(fontFamily: 'monospace'), ), ], ); }, ), ), ); } ```
## Related - Guides: [Mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md), [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md), [Filters](https://dualmeta-gmbh.github.io/query_kit/docs/guides/filters.md) - Tested by `test/features/mutation_state_test.dart` (widget) and `e2e/tests/mutation_state.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutation_state) --- # Prefetching > Fetch a detail into the cache before its screen opens, so opening it costs no request, and the three imperative reads side by side. A list of posts where every row has a prefetch button: it fetches that post into the cache with nobody watching, so opening the detail within `staleTime` shows the post at once and sends nothing. Below the list, one key is read three ways with `client.query`, awaited, with `revalidateIfStale` and as a static read, and a last card prefetches the first page of an infinite query. Reach for this when the next screen is predictable: the product a user is about to tap in a catalogue, the device detail behind a device list, the next step of a checkout that can load while the user fills in the current one. Live demo: [Prefetching](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/prefetching), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/prefetching)). Warm the cache before the screen that needs it opens. ## What to try - Press *Prefetch post 1* (the download icon on the first row). A *prefetched* pill appears on the row, and the `post-1` debug strip shows the entry with `observers=0` and `fetches=1`: cached, held by no widget. - Press *Open post 1*. The title is there immediately and `fetches` stays at 1. Go *Back to list* and open a post you did not prefetch: that one shows a skeleton first and costs one request. - Prefetch the same post twice within ten seconds: the second press is a no-op and `fetches` stays put. Wait more than ten seconds and press again, and it fetches. - In *Imperative reads*, press *Read (await)*, then *Increment on the server*, then *Read (revalidateIfStale)*: `returned` shows the old counter on the spot while `cached` moves to the new one when the background fetch lands. *Read (static)* hands back what is cached and `requests` does not move. - Press *Prefetch the first page* in the last card: `pages=1`, `rows=10`, and a second press within five minutes sends nothing. ## The code The detail and the prefetch share a key and a `staleTime`, which is what lets the detail find the entry fresh. The prefetch uses the cache-layer `QueryOptions`, since nothing observes it, and says `RetryPolicy.never` explicitly. [`examples/showcase/lib/features/prefetching/prefetching_screen.dart`, lines 81–86](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/prefetching/prefetching_screen.dart#L81-L86): ```dart QueryObserverOptions postQuery(ShowcaseApi api, int id) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), staleTime: postStaleTime, ); ``` [`examples/showcase/lib/features/prefetching/prefetching_screen.dart`, lines 91–96](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/prefetching/prefetching_screen.dart#L91-L96): ```dart QueryOptions postPrefetch(ShowcaseApi api, int id) => QueryOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), staleTime: postStaleTime, retry: RetryPolicy.never, ); ``` A prefetch is `client.query(options)` with the future ignored. There is no separate prefetch method: when the entry is fresh the call returns the cached data without a request, and a failure lands in the cache, not in the widget. [`examples/showcase/lib/features/prefetching/prefetching_screen.dart`, lines 168–174](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/prefetching/prefetching_screen.dart#L168-L174): ```dart void _prefetch(int id) { final api = ShowcaseScope.apiOf(context); // The future is the prefetch's only handle, and nobody wants it: a // refused prefetch is the cache's business, not the screen's. QueryClientProvider.of(context).query(postPrefetch(api, id)).ignore(); setState(() => _watched = id); } ``` The same call awaited is an imperative read. With `revalidateIfStale: true` it returns what the cache holds at once and refreshes a stale entry behind it; with nothing cached it awaits the fetch, and only then can it fail. Under `StaleTime.static` the entry is never stale, so a cached value comes back with no request at all. [`examples/showcase/lib/features/prefetching/prefetching_screen.dart`, lines 113–120](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/prefetching/prefetching_screen.dart#L113-L120): ```dart /// The same read declared static. `StaleTime.static` is never stale, so a /// cached entry is handed straight back and no request is made — not even the /// background one `revalidateIfStale` would otherwise start. QueryOptions counterStaticRead(ShowcaseApi api) => QueryOptions( queryKey: counterKey, queryFn: (context) => api.counter(signal: context.signal), staleTime: StaleTime.static, ); ```
The whole screen [`examples/showcase/lib/features/prefetching/prefetching_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/prefetching/prefetching_screen.dart): ```dart /// Upstream's `prefetching` example: the posts list, each row with a /// prefetch button that warms the cache for the detail before it opens, and /// an open button that then reads it — within `staleTime` — without a /// request. The list and the detail are both `QueryBuilder`s. /// /// The prefetch is `client.query(options).ignore()`: upstream's /// `prefetchQuery` is folded into `query`, and ignoring the /// future is what makes it a prefetch. Upstream prefetches on hover; here it /// is a button, because hover never reaches a `MouseRegion` through Flutter /// web's semantics overlay. A row whose post is in the cache shows a /// `prefetched` pill, rebuilt on the cache's own events like the debug /// strips. The prefetch options say `retry: RetryPolicy.never` out loud: the /// imperative path makes one attempt unless a retry is configured, and a /// refused prefetch must be one request, not four, for a reader counting /// them. /// /// The last card contrasts the three imperative reads of one key, the /// backend's counter. `client.query(options)` awaits the fetch whenever the /// entry is stale and hands back the new value. `client.query(options, /// revalidateIfStale: true)` hands back what the cache holds on the spot and /// refreshes behind it, so the value it returns is the old one for as long as /// the fetch takes; it fails only when nothing at all is cached. The same /// call under `staleTime: StaleTime.static` returns the cached value and /// makes no request, background one included: a static entry is never stale. /// `Increment on the server` moves the counter without touching the cache, /// which is what makes a cached answer tell itself apart from a fresh one by /// its value alone. /// /// The fourth card is the infinite twin: `client.infiniteQuery(options)` is /// to an infinite query what `client.query` is to a plain one — the same /// rules, the same `.ignore()` for a prefetch — and it fetches the *first* /// page under the key, held by nobody. That is upstream's /// `prefetchInfiniteQuery`, folded in the same way. /// /// Proofs (widget tests in `test/features/prefetching_test.dart`, end-to-end /// in `e2e/tests/prefetching.spec.ts`): a prefetch is one request and marks /// the row with nobody observing the entry; opening the prefetched post costs /// no request and shows the title at once; opening an unprefetched post costs /// one; a second prefetch within `staleTime` is a no-op and a third after it /// fetches again; a refused prefetch throws nothing into the UI, leaves the /// row unmarked, and the post opens normally afterwards; a stale read with /// `revalidateIfStale` returns the old value on the frame of the tap while /// the entry is fetching and the cache holds the new one once the answer /// lands, the plain read returns the new value, and the static read makes no /// request at all; and an infinite prefetch is one request for the first /// page, cached with `observers=0`, and a second press within `staleTime` is /// a no-op. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature prefetchingFeature = Feature( id: 'prefetching', title: 'Prefetching', summary: 'Warm the cache before the screen that needs it opens.', upstream: 'prefetching', ); /// How long a post counts as fresh, for the prefetch and the detail alike: /// the whole point is that the open within this window costs nothing. const StaleTime postStaleTime = StaleTime.duration(Duration(seconds: 10)); QueryObserverOptions> postsQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal), ); /// The detail's options: an observer's, since a `QueryBuilder` reads them. QueryObserverOptions postQuery(ShowcaseApi api, int id) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), staleTime: postStaleTime, ); /// The prefetch's options: the plain cache-layer kind, since nothing observes /// this fetch. Same key and same `staleTime` as [postQuery], so the detail /// finds the entry fresh; no retries, so a refused prefetch is one request. QueryOptions postPrefetch(ShowcaseApi api, int id) => QueryOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) => api.post(id, signal: context.signal), staleTime: postStaleTime, retry: RetryPolicy.never, ); /// The key the imperative-read card reads. Its own, not one of /// [ShowcaseKeys]: the counter is a value a button can move on the server /// behind the cache's back, which is what makes "cached" and "fresh" tell /// themselves apart by the number alone. final QueryKey counterKey = QueryKey(const ['prefetching', 'counter']); /// The counter read: always stale, so the plain `client.query` fetches on /// every press and `revalidateIfStale` always has a refresh to run behind /// the cached answer it returns. QueryOptions counterRead(ShowcaseApi api) => QueryOptions( queryKey: counterKey, queryFn: (context) => api.counter(signal: context.signal), staleTime: StaleTime.zero, ); /// The same read declared static. `StaleTime.static` is never stale, so a /// cached entry is handed straight back and no request is made — not even the /// background one `revalidateIfStale` would otherwise start. QueryOptions counterStaticRead(ShowcaseApi api) => QueryOptions( queryKey: counterKey, queryFn: (context) => api.counter(signal: context.signal), staleTime: StaleTime.static, ); /// The infinite prefetch's key: this screen's own, so the paging screens' /// entries are untouched by it. final QueryKey projectsPrefetchKey = QueryKey(const ['prefetching', 'projects']); /// The first page of the projects, as an [InfiniteQueryOptions] — the paging /// fields plus the cache-layer ones a plain [QueryOptions] takes. `retry: /// never` for the reason [postPrefetch] gives; fresh for five minutes, so a /// second prefetch is a no-op. InfiniteQueryOptions projectsPrefetch(ShowcaseApi api) => InfiniteQueryOptions( queryKey: projectsPrefetchKey, initialPageParam: 0, pageFn: (context) => api.projectsFrom( context.pageParam, limit: 10, signal: context.signal, ), getNextPageParam: (page, _, __, ___) => page.nextId, retry: RetryPolicy.never, staleTime: const StaleTime.duration(Duration(minutes: 5)), ); class PrefetchingScreen extends StatefulWidget { const PrefetchingScreen({super.key}); @override State createState() => _PrefetchingScreenState(); } class _PrefetchingScreenState extends State { /// The post whose detail is open, if any. int? _selected; /// The post the second debug strip watches: the last one prefetched or /// opened. A prefetch has no screen of its own, so this is where a reader /// sees its entry land with `observers=0`. int? _watched; /// What the last imperative read was and what it handed back, plus how /// often the server's counter has been moved behind the cache's back. String _lastRead = 'none'; int? _returned; bool _readFailed = false; int _increments = 0; void _prefetch(int id) { final api = ShowcaseScope.apiOf(context); // The future is the prefetch's only handle, and nobody wants it: a // refused prefetch is the cache's business, not the screen's. QueryClientProvider.of(context).query(postPrefetch(api, id)).ignore(); setState(() => _watched = id); } void _open(int id) => setState(() { _selected = id; _watched = id; }); void _back() => setState(() => _selected = null); /// The infinite twin of [_prefetch]: the first page, fetched and cached /// with nobody observing it, the future ignored. void _prefetchProjects() { final api = ShowcaseScope.apiOf(context); QueryClientProvider.of(context) .infiniteQuery(projectsPrefetch(api)) .ignore(); } /// The infinite prefetch, and what the cache holds under its key — read on /// every cache event, like the imperative-read card, because nothing /// observes the entry. Widget _infinitePrefetchCard() => SectionCard( title: 'An infinite prefetch', child: SemanticsGroup( name: 'infinite prefetch', child: CacheListener( builder: (context) { final cached = QueryClientProvider.of(context) .getInfiniteQueryData(projectsPrefetchKey); final rows = cached?.pages .fold(0, (n, page) => n + page.items.length) ?? 0; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'client.infiniteQuery(options).ignore() is to an ' 'infinite query what client.query is to a plain one: ' 'the first page, fetched and cached with nobody ' 'observing it — upstream\'s prefetchInfiniteQuery. A ' 'second press within staleTime is a no-op.', ), const SizedBox(height: 8), FilledButton.tonal( onPressed: _prefetchProjects, child: const Text('Prefetch the first page'), ), const SizedBox(height: 8), Wrap( spacing: 12, children: [ for (final fact in [ 'pages=${cached?.pages.length ?? 0}', 'rows=$rows', ]) Text( fact, style: const TextStyle( fontFamily: 'monospace', fontSize: 12, ), ), ], ), ], ); }, ), ), ); Future _read( String label, { required bool revalidateIfStale, required bool neverStale, }) async { final api = ShowcaseScope.apiOf(context); final client = QueryClientProvider.of(context); final options = neverStale ? counterStaticRead(api) : counterRead(api); setState(() { _lastRead = label; _returned = null; _readFailed = false; }); try { final value = await client.query(options, revalidateIfStale: revalidateIfStale); if (mounted) { setState(() => _returned = value); } } on Object { // The plain call fails whenever its fetch does; with // `revalidateIfStale` only an empty cache can fail. Either way the // card says so rather than leaving an error to the zone. if (mounted) { setState(() => _readFailed = true); } } } /// Moves the counter on the server and leaves the cache alone, so the /// cached value is provably out of date and a read's answer says which of /// the two it is. Future _incrementOnServer() async { final api = ShowcaseScope.apiOf(context); try { await api.increment(); if (mounted) { setState(() => _increments += 1); } } on Object { // A refused increment is not this card's subject; the reads are. } } /// The three calls side by side, with what came back and whether a request /// was made. `cached` and `requests` are read on every cache event, the way /// the strips are: the background refresh has no observer to announce it. Widget _readsCard() => SectionCard( title: 'Imperative reads', child: SemanticsGroup( name: 'reads', child: CacheListener( builder: (context) { final client = QueryClientProvider.of(context); final cached = client.getQueryData(counterKey); final requests = ShowcaseScope.of(context).stats.fetchesOf(counterKey); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ FilledButton( onPressed: () => _read( 'await', revalidateIfStale: false, neverStale: false, ), child: const Text('Read (await)'), ), FilledButton.tonal( onPressed: () => _read( 'revalidate', revalidateIfStale: true, neverStale: false, ), child: const Text('Read (revalidateIfStale)'), ), OutlinedButton( onPressed: () => _read( 'static', revalidateIfStale: true, neverStale: true, ), child: const Text('Read (static)'), ), OutlinedButton( onPressed: _incrementOnServer, child: const Text('Increment on the server'), ), ], ), const SizedBox(height: 8), Wrap( spacing: 12, runSpacing: 2, children: [ for (final fact in [ 'read=$_lastRead', if (_readFailed) 'returned=failed' else 'returned=${_returned ?? '–'}', 'cached=${cached ?? '–'}', 'requests=$requests', 'increments=$_increments', ]) Text( fact, style: const TextStyle( fontFamily: 'monospace', fontSize: 12, ), ), ], ), ], ); }, ), ), ); @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final selected = _selected; final watched = _watched; return FeatureScaffold( feature: prefetchingFeature, children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Notice( 'Prefetching warms the cache for a screen that has not opened ' 'yet. Within staleTime (10 s) opening the post costs no request.', ), ), if (selected == null) _PostList( options: postsQuery(api), onPrefetch: _prefetch, onOpen: _open, ) else _PostDetail( id: selected, options: postQuery(api, selected), onBack: _back, ), QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'), if (watched != null) QueryDebugStrip( queryKey: ShowcaseKeys.post(watched), label: 'post-$watched', ), _readsCard(), QueryDebugStrip(queryKey: counterKey, label: 'counter'), _infinitePrefetchCard(), QueryDebugStrip(queryKey: projectsPrefetchKey, label: 'projects'), ], ); } } class _PostList extends StatelessWidget { const _PostList({ required this.options, required this.onPrefetch, required this.onOpen, }); final QueryObserverOptions> options; final void Function(int id) onPrefetch; final void Function(int id) onOpen; @override Widget build(BuildContext context) => QueryBuilder>( options: options, builder: (context, posts) => SectionCard( title: 'Posts', trailing: posts.isFetching ? const Pill('refreshing') : null, child: switch (posts) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 8), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => CacheListener( builder: (context) { final client = QueryClientProvider.of(context); // Bounded and scrolling on its own, so the debug strips // under the card stay in view whatever the list's length: // a card of thirty rows would push them off the screen, // and a test only reads what is on it. return SizedBox( height: 200, child: ListView.builder( itemCount: data.length, itemBuilder: (context, index) { final post = data[index]; return _PostRow( post: post, // What upstream's bold marker reads too: the cache, // not a flag the screen keeps — the entry may also // have come from an open, or be gone by gcTime. prefetched: client.getQueryData( ShowcaseKeys.post(post.id), ) != null, onPrefetch: () => onPrefetch(post.id), onOpen: () => onOpen(post.id), ); }, ), ); }, ), }, ), ); } class _PostRow extends StatelessWidget { const _PostRow({ required this.post, required this.prefetched, required this.onPrefetch, required this.onOpen, }); final Post post; final bool prefetched; final VoidCallback onPrefetch; final VoidCallback onOpen; @override Widget build(BuildContext context) => SemanticsGroup( // A group per row, so a test can tie the pill to its post; explicit // children keep the texts and buttons findable on their own. name: 'post ${post.id}', child: Row( children: [ Expanded(child: Text('${post.id} · ${post.title}')), if (prefetched) ...[ const Pill('prefetched'), const SizedBox(width: 4), ], IconButton( tooltip: 'Prefetch post ${post.id}', onPressed: onPrefetch, visualDensity: VisualDensity.compact, icon: const Icon(Icons.download_outlined), ), IconButton( tooltip: 'Open post ${post.id}', onPressed: onOpen, visualDensity: VisualDensity.compact, icon: const Icon(Icons.chevron_right), ), ], ), ); } class _PostDetail extends StatelessWidget { const _PostDetail({ required this.id, required this.options, required this.onBack, }); final int id; final QueryObserverOptions options; final VoidCallback onBack; @override Widget build(BuildContext context) => QueryBuilder( options: options, builder: (context, post) => SectionCard( title: 'Post #$id', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (post.isFetching) const Pill('refreshing'), IconButton( tooltip: 'Back to list', onPressed: onBack, icon: const Icon(Icons.arrow_back), ), ], ), child: switch (post) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(height: 20, width: 240), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 4), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (post case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Text( data.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text(data.body), ], ), }, ), ); } ```
## Related - Guides: [Prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md), [Caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md) - Upstream: TanStack's React [`prefetching`](https://github.com/TanStack/query/tree/main/examples/react/prefetching) example - Tested by `test/features/prefetching_test.dart` (widget) and `e2e/tests/prefetching.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/prefetching) --- # Stale time and garbage collection > Every StaleTime and GcTime value on one cache entry, with a reader you can detach and attach to see when data refetches and when an unused entry is dropped. One cache entry, the server's clock, whose `serial` grows by one with every request, so each fetch shows as a new number. Two knobs set its `staleTime` (zero, 5 s, infinite, static, or a dynamic value computed from the data) and its `gcTime` (5 s or never). The reader is a `QueryController` the screen can detach and attach: attaching is a mount, which refetches only if the data is stale, and detaching leaves the entry with no observer, which starts its garbage-collection timer. These two settings are how you tune a real app: a country list or a product catalogue that barely changes can be fresh for a long time, a live device status wants `zero`, and a detail screen the user keeps coming back to wants a `gcTime` long enough to still be cached when they return. Live demo: [Stale time and garbage collection](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/stale-and-gc), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/stale_and_gc)). When data goes stale, and when an unused entry is dropped. ## What to try - With *Stale time* on *zero*, the reader shows `isStale=true` the moment the data arrives. Press *Detach reader*, then *Attach reader*: the mount refetches and `serial` goes up by one. - Pick *5 s* and press *Refetch*. The reader shows `isStale=false`, and detaching and attaching within five seconds sends nothing. After five seconds it flips to `isStale=true` on its own, and the next attach refetches. - Pick *static* and press *Invalidate*: nothing is fetched. Only the *Refetch* button, the reader's own `refetch`, still fetches. With *infinite*, an invalidation does refetch. - Set *GC time* to *5 s* and detach the reader. The `time` debug strip shows `observers=0`, and five seconds later `status=absent`: the entry was collected. Attaching again starts from a skeleton with no data. - Set *GC time* to *never*, detach, and the entry stays. An entry keeps the longest gc time any reader gave it, so going back to *5 s* has no effect until you press *Remove entry* (enabled while detached). ## The code The query takes both values as options. `StaleTime.dynamic` computes the stale time from the query itself: here, fresh for five seconds after an odd serial and stale at once after an even one. [`examples/showcase/lib/features/stale_and_gc/stale_and_gc_screen.dart`, lines 49–76](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/stale_and_gc/stale_and_gc_screen.dart#L49-L76): ```dart const StaleTime _fiveSeconds = StaleTime.duration(Duration(seconds: 5)); /// `StaleTime.dynamic` is compared by the identity of its function, so the /// function is a top-level one and the value a `const`: the segmented button /// finds it selected again on every build. const StaleTime _dynamic = StaleTime.dynamic(_freshWhileOdd); /// Fresh for five seconds after an odd serial, stale at once after an even /// one — a stale time that reads the data it is deciding about. StaleTime _freshWhileOdd(Query query) { final data = query.state.data; return data is ServerTime && data.serial.isOdd ? _fiveSeconds : StaleTime.zero; } /// The screen's one query, with the two knobs the screen turns. QueryObserverOptions serverTimeQuery( ShowcaseApi api, { required StaleTime staleTime, required GcTime gcTime, }) => QueryObserverOptions( queryKey: ShowcaseKeys.time, queryFn: (context) => api.time(signal: context.signal), staleTime: staleTime, gcTime: gcTime, ); ``` Detaching disposes the controller. With no observer left, the entry's gc timer starts. [`examples/showcase/lib/features/stale_and_gc/stale_and_gc_screen.dart`, lines 137–144](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/stale_and_gc/stale_and_gc_screen.dart#L137-L144): ```dart void _detach() { setState(() { // Disposing destroys the observer; the entry has none left and its gc // timer starts. _reader?.dispose(); _reader = null; }); } ``` A knob change reaches an attached reader through `setOptions`; a detached one picks it up when the next controller is created. [`examples/showcase/lib/features/stale_and_gc/stale_and_gc_screen.dart`, lines 158–162](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/stale_and_gc/stale_and_gc_screen.dart#L158-L162): ```dart void _applyOptions() { setState(() { _reader?.setOptions(_options); }); } ```
The whole screen [`examples/showcase/lib/features/stale_and_gc/stale_and_gc_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/stale_and_gc/stale_and_gc_screen.dart): ```dart /// Every `StaleTime` and every `GcTime` value, on one cache entry: the server /// time (`GET /api/time`, whose `serial` grows by one per call, so a refetch /// shows as a number and not as a clock). The reader is a `QueryController` /// the screen creates and disposes on demand — detaching it is what leaves /// the entry without an observer, which is when garbage collection starts, /// and attaching a fresh one is a mount, which is when `refetchOnMount` /// (`RefetchOn.ifStale` by default) decides whether to refetch. /// /// Port-specific; it illustrates upstream's *Important Defaults* and *Caching* /// guides. Upstream's `staleTime: Infinity` is two values here: /// `StaleTime.infinite` (never stale by time, but an invalidation or a refetch /// still fetches) and `StaleTime.static` (never refetched by any trigger — /// mount, focus, reconnect, invalidation and `refetchQueries` all skip it). /// What `static` does *not* block is the observer's own `refetch()` — the /// `Refetch` button — and that is asserted here rather than the opposite. /// /// Proofs (widget tests in `test/features/stale_and_gc_test.dart`, end-to-end /// in `e2e/tests/stale_and_gc.spec.ts`): with `zero` the data is stale the /// moment it arrives and re-attaching the reader refetches; with `5 s` it is /// fresh, stale five seconds later, and re-attaching refetches only once it /// is; with `static` neither re-attaching nor invalidating fetches, only /// `Refetch` does, while with `infinite` invalidating does too; an /// invalidation while a reader is attached refetches at once, and one while /// detached is honoured on the next attach; a detached entry with `5 s` gc is /// gone after five seconds and one with `never` is still there ten minutes /// on, and attaching after a collection starts from scratch; the `dynamic` /// stale time is fresh after an odd serial and stale after an even one. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature staleAndGcFeature = Feature( id: 'stale-and-gc', title: 'Stale time and garbage collection', summary: 'When data goes stale, and when an unused entry is dropped.', ); const StaleTime _fiveSeconds = StaleTime.duration(Duration(seconds: 5)); /// `StaleTime.dynamic` is compared by the identity of its function, so the /// function is a top-level one and the value a `const`: the segmented button /// finds it selected again on every build. const StaleTime _dynamic = StaleTime.dynamic(_freshWhileOdd); /// Fresh for five seconds after an odd serial, stale at once after an even /// one — a stale time that reads the data it is deciding about. StaleTime _freshWhileOdd(Query query) { final data = query.state.data; return data is ServerTime && data.serial.isOdd ? _fiveSeconds : StaleTime.zero; } /// The screen's one query, with the two knobs the screen turns. QueryObserverOptions serverTimeQuery( ShowcaseApi api, { required StaleTime staleTime, required GcTime gcTime, }) => QueryObserverOptions( queryKey: ShowcaseKeys.time, queryFn: (context) => api.time(signal: context.signal), staleTime: staleTime, gcTime: gcTime, ); class StaleAndGcScreen extends StatefulWidget { const StaleAndGcScreen({super.key}); @override State createState() => _StaleAndGcScreenState(); } class _StaleAndGcScreenState extends State { static const List<(String, StaleTime)> _staleTimes = <(String, StaleTime)>[ ('zero', StaleTime.zero), ('5 s', _fiveSeconds), ('infinite', StaleTime.infinite), ('static', StaleTime.static), ('dynamic', _dynamic), ]; static const List<(String, GcTime)> _gcTimes = <(String, GcTime)>[ ('5 s', GcTime.duration(Duration(seconds: 5))), ('never', GcTime.never), ]; StaleTime _staleTime = StaleTime.zero; GcTime _gcTime = _gcTimes.first.$2; late final ShowcaseApi _api; late final QueryClient _client; bool _initialised = false; /// The reader, or null while detached. Created here rather than in /// `initState` because the api and the client are inherited widgets. QueryController? _reader; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _api = ShowcaseScope.apiOf(context); _client = QueryClientProvider.of(context); _reader = QueryController.create(_client, _options); } } @override void dispose() { _reader?.dispose(); super.dispose(); } QueryObserverOptions get _options => serverTimeQuery(_api, staleTime: _staleTime, gcTime: _gcTime); void _attach() { setState(() { // A new controller is a new observer: subscribing it is a mount. _reader = QueryController.create(_client, _options); }); } void _detach() { setState(() { // Disposing destroys the observer; the entry has none left and its gc // timer starts. _reader?.dispose(); _reader = null; }); } void _invalidate() { _client .invalidateQueries(filters: QueryFilters(queryKey: ShowcaseKeys.time)) .ignore(); } void _remove() { _client.removeQueries(filters: QueryFilters(queryKey: ShowcaseKeys.time)); } /// A changed option reaches a live reader through `setOptions`; a detached /// one picks it up on the next attach. void _applyOptions() { setState(() { _reader?.setOptions(_options); }); } @override Widget build(BuildContext context) { final reader = _reader; final small = Theme.of(context).textTheme.bodySmall; // One card, and the strip right under it: the widget tests run in a // 600 px window and a `ListView` only builds what is near the viewport. return FeatureScaffold( feature: staleAndGcFeature, children: [ SectionCard( title: 'Server time', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Refetch', onPressed: reader?.refetch, icon: const Icon(Icons.refresh), ), IconButton( tooltip: 'Detach reader', onPressed: reader == null ? null : _detach, icon: const Icon(Icons.visibility_off), ), IconButton( tooltip: 'Attach reader', onPressed: reader == null ? _attach : null, icon: const Icon(Icons.visibility), ), IconButton( tooltip: 'Invalidate', onPressed: _invalidate, icon: const Icon(Icons.restart_alt), ), IconButton( tooltip: 'Remove entry', onPressed: reader == null ? _remove : null, icon: const Icon(Icons.delete_outline), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (reader == null) const _Reading( facts: ['reader=detached'], child: Text( 'No reader: the entry stays cached until its gc time ' 'runs out — watch the strip.', ), ) else ListenableBuilder( listenable: reader, builder: (context, _) { final time = reader.value; final data = time.dataOrNull; return _Reading( facts: [ 'reader=attached', if (data != null) 'serial=${data.serial}', 'isStale=${time.isStale}', ], child: switch (time) { QueryPending() => const SkeletonBox(width: 200), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Row( children: [ Expanded( child: Text( 'Server clock ${hhmmss(data.now)}', style: Theme.of(context).textTheme.titleMedium, ), ), if (time.isFetching) const Pill('refreshing'), ], ), }, ); }, ), const Divider(height: 24), knob( context, title: 'Stale time', name: 'stale-time', choices: _staleTimes, selected: _staleTime, onChanged: (value) { _staleTime = value; _applyOptions(); }, ), const SizedBox(height: 4), Text( 'dynamic: fresh for 5 s after an odd serial, stale at once ' 'after an even one. static blocks every trigger, invalidation ' 'included; infinite still honours an invalidation.', style: small, ), const SizedBox(height: 8), knob( context, title: 'GC time', name: 'gc-time', choices: _gcTimes, selected: _gcTime, onChanged: (value) { _gcTime = value; _applyOptions(); }, ), const SizedBox(height: 4), Text( 'Counted from the moment the last reader leaves. An entry ' 'keeps the longest gc time a reader ever gave it, so after ' 'never only removing the entry brings 5 s back.', style: small, ), ], ), ), QueryDebugStrip(queryKey: ShowcaseKeys.time, label: 'time'), ], ); } } /// What the reader shows, and its own facts as `key=value` texts in a /// semantics group of their own, so a test tells the reader's `isStale` apart /// from the strip's. class _Reading extends StatelessWidget { const _Reading({required this.facts, required this.child}); final List facts; final Widget child; @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ child, const SizedBox(height: 8), FactGroup(name: 'reader', facts: facts, dense: true), ], ); } ```
## Related - Guides: [Caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md), [Query options](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md) - Tested by `test/features/stale_and_gc_test.dart` (widget) and `e2e/tests/stale_and_gc.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/stale_and_gc) --- # Invalidation and filters > The client's bulk operations — invalidate, refetch, reset, remove, cancel and update — run over one small cache with a key prefix, exact, type, stale and predicate filters. One small cache — a posts list, two post details with live readers, a third detail nobody watches, and a todo list that stays fresh for thirty seconds — and a row of buttons, one per operation the client runs over many entries at once. Each button uses a different filter, so you can see which entries it reaches and which of those actually fetch. This is the toolkit behind every "after this write, these screens are out of date" decision in an app: saving a product refreshes the product list and its detail but not the cart; logging out removes everything under the account's keys; a pull to refresh on a device list touches only that list. Live demo: [Invalidation and filters](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/invalidation-and-filters), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/invalidation_and_filters)). Invalidate, refetch, reset and remove, by prefix, type or predicate. ## What to try - Press **Invalidate posts prefix**: the list, post 1 and post 2 refetch (`fetches` goes up on their strips), post 3 only turns `isStale=true` because nothing observes it, and the todos are left alone. Then press **Invalidate inactive too** and post 3 refetches as well. - Press **Invalidate posts exactly**: `exact: true` reaches the list and none of the details under it. - Tick **Fail post 2 next**, press **Refetch post 2** so it ends in an error, then **Predicate: errored**: only post 2 refetches. - Press **Remove post 2**: its strip reads `status=absent`, but its reader keeps showing the last post. **Re-attach post 2** resolves the key again and the new entry fetches. - Press **Refetch posts slowly**, then **Cancel posts** before the two seconds are up: the list goes back to idle with its old data, and the late answer never lands. ## The code A filter picks the entries, and `refetchType` decides which of them fetch. With `RefetchType.all`, inactive entries such as post 3 refetch too; by default only the ones someone observes do. [`examples/showcase/lib/features/invalidation_and_filters/invalidation_and_filters_screen.dart`, lines 194–200](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/invalidation_and_filters/invalidation_and_filters_screen.dart#L194-L200): ```dart /// The same prefix, and the inactive post 3 refetches as well. void _invalidateInactiveToo() => _client .invalidateQueries( filters: QueryFilters(queryKey: ShowcaseKeys.posts), refetchType: RefetchType.all, ) .ignore(); ``` The other bulk operations take the same `QueryFilters`. `stale: true` refetches only what is stale now, and `resetQueries` and `removeQueries` aim at a single entry with `exact`. [`examples/showcase/lib/features/invalidation_and_filters/invalidation_and_filters_screen.dart`, lines 213–231](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/invalidation_and_filters/invalidation_and_filters_screen.dart#L213-L231): ```dart /// `stale: true` across the whole cache: the posts, never the todos while /// they are fresh — and not post 3, which nobody observes and which /// counts as fresh until it is invalidated. void _refetchStale() => _client.refetchQueries(filters: const QueryFilters(stale: true)).ignore(); void _resetPost1() => _client .resetQueries( filters: QueryFilters(queryKey: ShowcaseKeys.post(1), exact: true), ) .ignore(); void _removePost2() => _client.removeQueries( filters: QueryFilters(queryKey: ShowcaseKeys.post(2), exact: true), ); /// A reader stays on the entry it was removed with; giving it its options /// again makes it resolve the key afresh, and the new entry fetches. void _reattachPost2() => _post2.setOptions(_post2Options); ``` Filters also work for reads and writes that fetch nothing. Here a key prefix, `type: QueryTypeFilter.active` and a predicate on the key's length select the two watched details, and `updateQueriesData` rewrites their titles in the cache. [`examples/showcase/lib/features/invalidation_and_filters/invalidation_and_filters_screen.dart`, lines 250–274](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/invalidation_and_filters/invalidation_and_filters_screen.dart#L250-L274): ```dart /// The two active details and nothing else: `updateQueriesData` /// type-checks every match before writing, and the list under the same /// prefix holds a `List`, so the key's shape (`['posts', id]`) picks /// the details and `type` leaves the unobserved post 3 out. static final QueryFilters _activeDetails = QueryFilters( queryKey: ShowcaseKeys.posts, type: QueryTypeFilter.active, predicate: (query) => query.queryKey.parts.length == 2, ); void _uppercaseTitles() { final matched = _client.getQueriesData(filters: _activeDetails).length; _client.updateQueriesData( (previous) => previous == null ? null : Post( id: previous.id, title: previous.title.toUpperCase(), body: previous.body, ), filters: _activeDetails, ); setState(() => _matched = matched); } ```
The whole screen [`examples/showcase/lib/features/invalidation_and_filters/invalidation_and_filters_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/invalidation_and_filters/invalidation_and_filters_screen.dart): ```dart /// Invalidation and the query filters: one small cache — the posts list, /// post 1, post 2 and the todos, each with a live `QueryController` reader, /// and post 3, filled once by `client.query(...)` with no reader at all — and /// the client's bulk operations run over it with every kind of filter: a key /// prefix, `exact`, `type`, `stale`, `predicate`. Port-specific; it /// illustrates upstream's *Query Invalidation*, *Filters* and *Query Keys* /// guides. /// /// The readers are `QueryController`s created in `initState` and read through /// `ListenableBuilder`. The posts have no stale time (the default, stale at /// once), the todos thirty seconds, so `stale: true` tells them apart; post 2 /// never retries, so a scripted failure is an error at once and the predicate /// has something to match. /// /// Proofs (widget tests in `test/features/invalidation_and_filters_test.dart`, /// end-to-end in `e2e/tests/invalidation_and_filters.spec.ts`): invalidating /// the `posts` prefix refetches the list, post 1 and post 2 and only marks /// the unobserved post 3 (`isStale=true`, no fetch), leaving the todos alone; /// `exact: true` refetches the list alone; `RefetchType.all` refetches post 3 /// as well; `stale: true` refetches the posts and not the fresh todos, until /// the todos age past their stale time; resetting post 1 puts it back to /// pending and refetches it because it has a reader; removing post 2 leaves /// its strip `status=absent` while its reader keeps the last result until it /// re-resolves the key; cancelling a slow refetch of the list leaves the /// entry idle with its old data and no answer ever lands; the errored /// predicate refetches post 2 alone after a scripted failure; uppercasing /// the titles matches the two active detail entries and is a cache write, not /// a fetch. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature invalidationAndFiltersFeature = Feature( id: 'invalidation-and-filters', title: 'Invalidation and filters', summary: 'Invalidate, refetch, reset and remove, by prefix, type or predicate.', ); /// How long the todos stay fresh; the posts use the default and are stale /// the moment they arrive. const StaleTime todosStaleTime = StaleTime.duration(Duration(seconds: 30)); /// The list. [nextDelay] is asked on every fetch, so the screen can make one /// fetch slow enough to cancel without changing the options. QueryObserverOptions> postsQuery( ShowcaseApi api, { Duration? Function()? nextDelay, }) => QueryObserverOptions>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal, delay: nextDelay?.call()), ); /// One post. [prepare] runs before each request — how the screen scripts the /// backend's next answer for post 2 — and [retry] is what post 2 sets to /// `never`, so that answer is an error at once. QueryObserverOptions postQuery( ShowcaseApi api, int id, { RetryPolicy? retry, Future Function()? prepare, }) => QueryObserverOptions( queryKey: ShowcaseKeys.post(id), queryFn: (context) async { await prepare?.call(); return api.post(id, signal: context.signal); }, retry: retry, ); /// The todos, fresh for [todosStaleTime]. QueryObserverOptions> todosQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), staleTime: todosStaleTime, ); class InvalidationAndFiltersScreen extends StatefulWidget { const InvalidationAndFiltersScreen({super.key}); @override State createState() => _InvalidationAndFiltersScreenState(); } class _InvalidationAndFiltersScreenState extends State with PhaseSafeRebuild { static const Duration _slowDelay = Duration(seconds: 2); late final ShowcaseApi _api; late final QueryClient _client; late final QueryController, List> _posts; late final QueryController _post1; late final QueryController _post2; late final QueryController, List> _todos; /// Consumed by the next fetch of the list. bool _slowNextPosts = false; /// Consumed by the next fetch of post 2. bool _failPost2Next = false; int? _matched; @override void initState() { super.initState(); // Neither lookup subscribes: the api and the client are fixed for the // life of the app, and a subscribing lookup is not allowed here anyway. _api = context.getInheritedWidgetOfExactType()!.api; _client = QueryClientProvider.read(context); _posts = QueryController.create( _client, postsQuery(_api, nextDelay: _takeDelay)); _post1 = QueryController.create(_client, postQuery(_api, 1)); _post2 = QueryController.create(_client, _post2Options); _todos = QueryController.create(_client, todosQuery(_api)); // Post 3 has no reader: fetched once, imperatively, it sits in the cache // as an inactive entry — what `type: inactive` and `RefetchType.all` // are about. _client.query(postQuery(_api, 3)).ignore(); } @override void dispose() { _posts.dispose(); _post1.dispose(); _post2.dispose(); _todos.dispose(); super.dispose(); } QueryObserverOptions get _post2Options => postQuery( _api, 2, retry: RetryPolicy.never, prepare: _scriptPost2Failure, ); Duration? _takeDelay() { if (!_slowNextPosts) { return null; } _slowNextPosts = false; return _slowDelay; } /// Ticked, the backend is told to refuse the next `GET /api/posts/2` /// before the request goes out; the tick is spent by that one fetch. Future _scriptPost2Failure() async { if (!_failPost2Next) { return; } _failPost2Next = false; scheduleRebuild(); await _api.configureScenario( failNext: const [ FailNext(method: 'GET', path: '/api/posts/2', status: 500), ], ); } // --- invalidateQueries --------------------------------------------------- /// A prefix: every key starting with `['posts']` — the list and the three /// details. Only the active ones refetch; post 3 is merely marked. void _invalidatePrefix() => _client .invalidateQueries(filters: QueryFilters(queryKey: ShowcaseKeys.posts)) .ignore(); /// `exact`: the list alone. void _invalidateExactly() => _client .invalidateQueries( filters: QueryFilters(queryKey: ShowcaseKeys.posts, exact: true), ) .ignore(); /// The same prefix, and the inactive post 3 refetches as well. void _invalidateInactiveToo() => _client .invalidateQueries( filters: QueryFilters(queryKey: ShowcaseKeys.posts), refetchType: RefetchType.all, ) .ignore(); /// A predicate over the state: whatever is in error right now. void _invalidateErrored() => _client .invalidateQueries( filters: QueryFilters( predicate: (query) => query.state.status == QueryStatus.error, ), ) .ignore(); // --- refetchQueries, resetQueries, removeQueries ------------------------- /// `stale: true` across the whole cache: the posts, never the todos while /// they are fresh — and not post 3, which nobody observes and which /// counts as fresh until it is invalidated. void _refetchStale() => _client.refetchQueries(filters: const QueryFilters(stale: true)).ignore(); void _resetPost1() => _client .resetQueries( filters: QueryFilters(queryKey: ShowcaseKeys.post(1), exact: true), ) .ignore(); void _removePost2() => _client.removeQueries( filters: QueryFilters(queryKey: ShowcaseKeys.post(2), exact: true), ); /// A reader stays on the entry it was removed with; giving it its options /// again makes it resolve the key afresh, and the new entry fetches. void _reattachPost2() => _post2.setOptions(_post2Options); void _refetchPost2() => _post2.refetch().ignore(); // --- cancelQueries ------------------------------------------------------- void _refetchPostsSlowly() { _slowNextPosts = true; _posts.refetch().ignore(); } void _cancelPosts() => _client .cancelQueries( filters: QueryFilters(queryKey: ShowcaseKeys.posts, exact: true), ) .ignore(); // --- getQueriesData, updateQueriesData ----------------------------------- /// The two active details and nothing else: `updateQueriesData` /// type-checks every match before writing, and the list under the same /// prefix holds a `List`, so the key's shape (`['posts', id]`) picks /// the details and `type` leaves the unobserved post 3 out. static final QueryFilters _activeDetails = QueryFilters( queryKey: ShowcaseKeys.posts, type: QueryTypeFilter.active, predicate: (query) => query.queryKey.parts.length == 2, ); void _uppercaseTitles() { final matched = _client.getQueriesData(filters: _activeDetails).length; _client.updateQueriesData( (previous) => previous == null ? null : Post( id: previous.id, title: previous.title.toUpperCase(), body: previous.body, ), filters: _activeDetails, ); setState(() => _matched = matched); } @override Widget build(BuildContext context) { final small = Theme.of(context).textTheme.bodySmall; return FeatureScaffold( feature: invalidationAndFiltersFeature, children: [ SectionCard( title: 'Operations', // Explicit child nodes: a row folds every plain text inside it into // one label, and `matched=` is read as an exact text. child: SemanticsGroup( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _Group( name: 'invalidateQueries', children: [ ActionButton( label: 'Invalidate posts prefix', onPressed: _invalidatePrefix, dense: true, ), ActionButton( label: 'Invalidate posts exactly', onPressed: _invalidateExactly, dense: true, ), ActionButton( label: 'Invalidate inactive too', onPressed: _invalidateInactiveToo, dense: true, ), ActionButton( label: 'Predicate: errored', onPressed: _invalidateErrored, dense: true, ), ], ), _Group( name: 'refetchQueries · resetQueries · removeQueries', children: [ ActionButton( label: 'Refetch stale only', onPressed: _refetchStale, dense: true, ), ActionButton( label: 'Reset post 1', onPressed: _resetPost1, dense: true, ), ActionButton( label: 'Remove post 2', onPressed: _removePost2, dense: true, ), ActionButton( label: 'Re-attach post 2', onPressed: _reattachPost2, dense: true, ), ], ), _Group( name: 'cancelQueries', children: [ ActionButton( label: 'Refetch posts slowly', onPressed: _refetchPostsSlowly, dense: true, ), ActionButton( label: 'Cancel posts', onPressed: _cancelPosts, dense: true, ), ], ), _Group( name: 'getQueriesData · updateQueriesData', children: [ ActionButton( label: 'Uppercase all post titles', onPressed: _uppercaseTitles, dense: true, ), Text( 'matched=${_matched ?? '–'}', style: const TextStyle(fontFamily: 'monospace'), ), ], ), // No subtitle: a tile folds it into the checkbox's accessible // name, and the tests find the box by its title alone. CheckboxListTile( title: const Text('Fail post 2 next'), contentPadding: EdgeInsets.zero, value: _failPost2Next, onChanged: (value) => setState(() => _failPost2Next = value ?? false), ), ActionButton( label: 'Refetch post 2', onPressed: _refetchPost2, dense: true, ), ], ), ), ), SectionCard( title: 'The cache', child: SemanticsGroup( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _ReaderRow>( label: 'posts', controller: _posts, describe: (posts) => '${posts.length} posts', ), _ReaderRow( label: 'post 1', controller: _post1, describe: (post) => post.title, ), _ReaderRow( label: 'post 2', controller: _post2, describe: (post) => post.title, ), _ReaderRow>( label: 'todos', controller: _todos, describe: (todos) => '${todos.length} todos', ), const SizedBox(height: 8), Text( 'Post 3 has no reader: fetched once by client.query, it is ' 'an inactive entry. The posts are stale at once, the todos ' 'fresh for 30 s; post 2 never retries.', style: small, ), ], ), ), ), QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'), QueryDebugStrip(queryKey: ShowcaseKeys.post(1), label: 'post-1'), QueryDebugStrip(queryKey: ShowcaseKeys.post(2), label: 'post-2'), QueryDebugStrip(queryKey: ShowcaseKeys.post(3), label: 'post-3'), QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'), const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Notice( 'Invalidate posts prefix: every key under [posts] is marked ' 'stale; the active ones refetch, post 3 only shows isStale=true.\n' 'Invalidate posts exactly: exact: true, the list alone.\n' 'Invalidate inactive too: refetchType: all, post 3 refetches as ' 'well.\n' 'Predicate: errored: invalidates whatever is in error — tick ' '"Fail post 2 next" and refetch post 2 first.\n' 'Refetch stale only: stale: true — the posts, not the fresh ' 'todos, and not the unobserved post 3 until it is invalidated.\n' 'Reset post 1: back to its initial state, then refetched because ' 'it has a reader.\n' 'Remove post 2: the entry is gone (status=absent) while the ' 'reader keeps its last result; Re-attach post 2 resolves the key ' 'again and fetches.\n' 'Refetch posts slowly, then Cancel posts: the fetch is reverted, ' 'the entry idle with its old data, and the late answer is ' 'ignored.\n' 'Uppercase all post titles: getQueriesData counts the active ' 'detail entries (matched=), updateQueriesData rewrites them — a ' 'cache write, not a fetch.', ), ), ], ); } } /// One row of the operations: the method's name, and its buttons. class _Group extends StatelessWidget { const _Group({required this.name, required this.children}); final String name; final List children; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 4), Wrap( spacing: 8, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: children, ), ], ), ); } /// One entry, read from its controller: what it holds, one text; its error, /// another; and a pill while it fetches. class _ReaderRow extends StatelessWidget { const _ReaderRow({ required this.label, required this.controller, required this.describe, }); final String label; final QueryController controller; final String Function(T data) describe; @override Widget build(BuildContext context) => ListenableBuilder( listenable: controller, builder: (context, _) { final result = controller.value; final data = result.dataOrNull; final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 72, child: Text( label, style: Theme.of(context).textTheme.labelLarge, ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (data != null) Text(describe(data)) else if (result is! QueryError) const SkeletonBox(width: 200), if (result case QueryError(:final error)) Text('$error', style: TextStyle(color: scheme.error)), ], ), ), if (result.isFetching) Pill(result.isPending ? 'loading' : 'refreshing'), ], ), ); }, ); } ```
## Related - Guides: [Query invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md), [Filters](https://dualmeta-gmbh.github.io/query_kit/docs/guides/filters.md), [Query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md), [Query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md) - Tested by `test/features/invalidation_and_filters_test.dart` (widget) and `e2e/tests/invalidation_and_filters.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/invalidation_and_filters) --- # Playground > A todo list and an editor with four live knobs — stale time, gc time, latency and error rate — turned while the queries are on screen. A list, an editor for one of its rows, and four knobs you turn while both are live. Stale time and gc time go into the client's defaults with `setDefaultOptions`, so every reader on the screen picks them up on its next build; latency and error rate go to the backend. It is the place to get a feel for the numbers before you pick them for a real screen: how long a device list may stay fresh before a revisit refetches it, how long a product detail should stay cached after the user leaves it, and what a flaky connection looks like while the retries run. Live demo: [Playground](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/playground), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/playground)). Todos with live knobs for stale time, gc time, latency and errors. ## What to try - Set **Stale time** to `30 s`: the `todos-reader` facts flip to `isStale=false` without a request. Set it back to `0` and they read `isStale=true` again. The live reader picked up the new default on its next build. - Set **Error rate** to `100 %` and press the **Refetch** icon on the list: `failureCount` climbs through the three retries, 300 ms apart, and the list stays on screen under a *Refetch failed* notice. Set the rate back to `0` and refetch to recover. - Set **GC time** to `5 s`, open a todo by tapping its text, then press **Close editor**. Five seconds later the `todo-` debug strip reads `status=absent`: nothing observes the entry any more, so it is collected. With `5 min` it stays. - Rename a todo in the editor, or tick **Done**: the answer is written to the editor's entry, and only the list is refetched. - Press **Invalidate everything**: the list and the open editor's entry both refetch, and `fetches` goes up by one on each strip. ## The code The list sets neither stale time nor gc time, on purpose: both come from the client's defaults, which is what the knobs change. [`examples/showcase/lib/features/playground/playground_screen.dart`, lines 86–93](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/playground/playground_screen.dart#L86-L93): ```dart /// The list. Stale time and gc time are left unset on purpose: they come /// from the client's defaults, which is what the knobs change. QueryObserverOptions> todosQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), retryDelay: playgroundRetryDelay, ); ``` A knob merges its value into the defaults the app already had, so the other defaults survive. [`examples/showcase/lib/features/playground/playground_screen.dart`, lines 268–277](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/playground/playground_screen.dart#L268-L277): ```dart /// The two knobs over the app's defaults, the rest of them untouched. void _applyDefaults() { final current = _client.getDefaultOptions(); _client.setDefaultOptions(DefaultOptions( queries: (current.queries ?? const QueryDefaults()).mergedWith( QueryDefaults(staleTime: _staleTime, gcTime: _gcTime), ), mutations: current.mutations, )); } ``` The editor's query seeds itself from the cached list with `InitialData.compute`, dated with the list's own `dataUpdatedAt`, so under a non-zero stale time opening the editor costs no request. [`examples/showcase/lib/features/playground/playground_screen.dart`, lines 95–115](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/playground/playground_screen.dart#L95-L115): ```dart /// One todo, for the editor. [seed] reads it from the cached list — `null` /// when the list is not there, which means "no seed" — and [seededAt] is /// the list's own `dataUpdatedAt`, so the seed is exactly as old as the /// list it came from. QueryObserverOptions todoQuery( ShowcaseApi api, int id, { required Todo? Function() seed, required DateTime? seededAt, }) => QueryObserverOptions( queryKey: todoKey(id), queryFn: (context) async { final todos = await api.todos(signal: context.signal); return todos.where((todo) => todo.id == id).firstOrNull ?? (throw const BackendException('Todo not found', status: 404)); }, retryDelay: playgroundRetryDelay, initialData: InitialData.compute(seed), initialDataUpdatedAt: seededAt, ); ```
The whole screen [`examples/showcase/lib/features/playground/playground_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/playground/playground_screen.dart): ```dart /// Upstream's `playground` example on the showcase backend: a todo list, an /// editor for one todo, and four knobs a reader turns while the queries are /// live — stale time, gc time, latency and error rate. Everything on the /// screen is read through `QueryMixin`: `watchQuery` for the list and the /// editor's todo, `watchMutation` for the add and the patch. /// /// Where the knobs go. Stale time and gc time go into the **client's** /// defaults with `setDefaultOptions`, the way upstream's playground sets /// them, on top of whatever the app had (`getDefaultOptions()`, merged, so /// the other defaults survive). A live observer keeps its defaulted options /// until its next `setOptions`, and the mixin re-applies options on every /// build, so a `setState` after the change is what carries the new defaults /// to every reader on the screen — verified by the widget tests, no /// re-keying needed. The screen snapshots the defaults on entry and restores /// them in `dispose`. Latency and error rate go to the **backend's** scenario /// through `configureScenario`. Every screen of one run shares that /// scenario, so the screen reads both knobs on entry and puts them back as it /// found them in `dispose`. The /// `backend` facts show the values the backend has acknowledged, which is /// what a test waits for before it relies on them. /// /// The editor's query is `['todos', ]`. The api has no single-todo GET, /// so its `queryFn` fetches the list and picks the todo — a fetch of its own, /// so the entry has its own lifecycle: it is seeded from the cached list with /// `InitialData.compute` and dated with the list's `dataUpdatedAt` (so under /// a non-zero stale time opening the editor costs no request), it is /// refetched by `Invalidate everything` like the list, and once the editor is /// closed its observer is gone and the gc time decides when it is dropped. /// After a rename or a completion the PATCH's answer is written to that entry /// with `setQueryData`, and only the list itself is invalidated (`exact`), /// so a mutation costs exactly one `GET /api/todos`. /// /// Both queries run with `retryDelay: RetryDelay.fixed(300 ms)` instead of /// the client's 1/2/4 s backoff, so the three default retries under /// `Error rate 100 %` are over in a second, in a test and in a browser. /// /// Proofs (widget tests in `test/features/playground_test.dart`, end-to-end /// in `e2e/tests/playground.spec.ts`): under `Error rate 100 %` a refetch's /// `failureCount` climbs through the retries and ends in /// `Failed at random (errorRate)` with the stale list still on screen, and /// `0` plus a refetch brings it back; `Stale time 30 s` flips the live /// list's `isStale` to `false` without a fetch, `0` flips it back — the /// "defaults changed under a live observer" proof; with `GC time 5 s` an /// editor's entry is gone five seconds after the editor closes, with `5 min` /// it stays; `Invalidate everything` refetches the list and the open editor's /// entry, `fetches` +1 on both; adding, renaming and completing a todo /// reaches the list at one `GET /api/todos` per invalidation with one `POST` /// or `PATCH` each; and leaving the screen restores the client's defaults /// and the latency and error rate the scenario had before it. library; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature playgroundFeature = Feature( id: 'playground', title: 'Playground', summary: 'Todos with live knobs for stale time, gc time, latency and errors.', upstream: 'playground', ); /// The editor's key, `['todos', id]`: under the list's prefix, as upstream /// keys a detail, so a prefix invalidation of `['todos']` would reach it. QueryKey todoKey(int id) => ShowcaseKeys.todos.append([id]); /// Three retries 300 ms apart instead of one, two and four seconds: the /// error-rate knob is meant to be watched, not waited for. const RetryDelay playgroundRetryDelay = RetryDelay.fixed(Duration(milliseconds: 300)); /// A rename, a completion, or both, for one todo. typedef TodoPatch = ({int id, String? text, bool? done}); /// The list. Stale time and gc time are left unset on purpose: they come /// from the client's defaults, which is what the knobs change. QueryObserverOptions> todosQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), retryDelay: playgroundRetryDelay, ); /// One todo, for the editor. [seed] reads it from the cached list — `null` /// when the list is not there, which means "no seed" — and [seededAt] is /// the list's own `dataUpdatedAt`, so the seed is exactly as old as the /// list it came from. QueryObserverOptions todoQuery( ShowcaseApi api, int id, { required Todo? Function() seed, required DateTime? seededAt, }) => QueryObserverOptions( queryKey: todoKey(id), queryFn: (context) async { final todos = await api.todos(signal: context.signal); return todos.where((todo) => todo.id == id).firstOrNull ?? (throw const BackendException('Todo not found', status: 404)); }, retryDelay: playgroundRetryDelay, initialData: InitialData.compute(seed), initialDataUpdatedAt: seededAt, ); /// `POST /api/todos`, then the list is invalidated so it shows the new row. MutationOptions addTodoMutation( ShowcaseApi api, QueryClient client, ) => MutationOptions.simple( mutationFn: (text) => api.createTodo(text), onSuccess: (_, __, ___) => client.invalidateQueries( filters: QueryFilters(queryKey: ShowcaseKeys.todos, exact: true), ), ); /// `PATCH /api/todos/:id`. The answer is the todo as the backend now has it, /// so it goes straight into the editor's entry; the list is the only thing /// that still needs a request, hence `exact`. MutationOptions patchTodoMutation( ShowcaseApi api, QueryClient client, ) => MutationOptions.simple( mutationFn: (patch) => api.updateTodo(patch.id, text: patch.text, done: patch.done), onSuccess: (todo, _, __) { client.setQueryData(todoKey(todo.id), todo); return client.invalidateQueries( filters: QueryFilters(queryKey: ShowcaseKeys.todos, exact: true), ); }, ); class PlaygroundScreen extends StatefulWidget { const PlaygroundScreen({super.key}); @override State createState() => _PlaygroundScreenState(); } class _PlaygroundScreenState extends State with QueryMixin { static const List<(String, StaleTime)> _staleTimes = <(String, StaleTime)>[ ('0', StaleTime.zero), ('5 s', StaleTime.duration(Duration(seconds: 5))), ('30 s', StaleTime.duration(Duration(seconds: 30))), ]; static const List<(String, GcTime)> _gcTimes = <(String, GcTime)>[ ('5 s', GcTime.duration(Duration(seconds: 5))), ('5 min', GcTime.duration(Duration(minutes: 5))), ]; static const List<(String, Duration)> _latencies = <(String, Duration)>[ ('0', Duration.zero), ('300 ms', Duration(milliseconds: 300)), ('2 s', Duration(seconds: 2)), ]; static const List<(String, double)> _errorRates = <(String, double)>[ ('0', 0), ('50 %', 0.5), ('100 %', 1), ]; // The library's own defaults, so the knobs start out telling the truth. StaleTime _staleTime = StaleTime.zero; GcTime _gcTime = _gcTimes.last.$2; Duration _latency = Duration.zero; double _errorRate = 0; /// What the backend has acknowledged, or null before the first answer. ({Duration latency, double errorRate})? _applied; String? _scenarioError; int _configVersion = 0; /// The todo the editor is open on, and the last one it was open on — the /// strip keeps showing that entry after the editor closes, which is how a /// test watches it being collected. int? _editingId; int? _stripId; final TextEditingController _newTodo = TextEditingController(); late final ShowcaseApi _api; /// Held from `didChangeDependencies`: `dispose` restores the defaults, and /// an inherited widget cannot be looked up from a State that is going. late final QueryClient _client; DefaultOptions? _snapshot; /// The backend's two knobs as they were before this screen turned them, /// which `dispose` puts back: the screen is one of many in the app, and the /// others run at the backend's own latency. ({Duration latency, double errorRate})? _before; /// Whether the read of [_before] has answered, one way or the other. bool _read = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (_snapshot == null) { _api = ShowcaseScope.apiOf(context); _client = queryClient; _snapshot = _client.getDefaultOptions(); _applyDefaults(); unawaited(_start()); } } /// Reads the knobs as they are — a config request with nothing in it /// changes nothing and answers the config — then applies this screen's. Future _start() async { try { final config = await _api.configureScenario(); _before = ( latency: Duration(milliseconds: (config['latency']! as num).toInt()), errorRate: (config['errorRate']! as num).toDouble(), ); } on Object { // Unknown, so nothing to restore beyond zeros. } _read = true; if (!mounted) { // Left before the read answered: `dispose` sent nothing, and this // screen's own knobs were never pushed, so put back what was read. _restoreScenario(); return; } await _pushScenario(); } void _restoreScenario() { final before = _before; _api .configureScenario( latency: before?.latency ?? Duration.zero, errorRate: before?.errorRate ?? 0, ) .ignore(); } @override void dispose() { final snapshot = _snapshot; if (snapshot != null) { _client.setDefaultOptions(snapshot); // Still reading: `_start` restores once the read answers. if (_read) _restoreScenario(); } _newTodo.dispose(); super.dispose(); } /// The two knobs over the app's defaults, the rest of them untouched. void _applyDefaults() { final current = _client.getDefaultOptions(); _client.setDefaultOptions(DefaultOptions( queries: (current.queries ?? const QueryDefaults()).mergedWith( QueryDefaults(staleTime: _staleTime, gcTime: _gcTime), ), mutations: current.mutations, )); } /// New defaults reach a live observer on its next `setOptions`, and the /// mixin runs that on every build — so the `setState` is the delivery. void _changeDefaults(void Function() change) { setState(() { change(); _applyDefaults(); }); } void _changeScenario(void Function() change) { setState(change); unawaited(_pushScenario()); } /// Sends the two backend knobs. Answers can cross when the knobs are turned /// quickly, so only the latest request's answer is shown as applied. Future _pushScenario() async { final version = ++_configVersion; final latency = _latency; final errorRate = _errorRate; try { await _api.configureScenario(latency: latency, errorRate: errorRate); if (mounted && version == _configVersion) { setState(() { _applied = (latency: latency, errorRate: errorRate); _scenarioError = null; }); } } on Object catch (error) { if (mounted && version == _configVersion) { setState(() => _scenarioError = '$error'); } } } void _invalidateEverything() { _client.invalidateQueries().ignore(); } void _openEditor(int id) { setState(() { _editingId = id; _stripId = id; }); } void _closeEditor() { setState(() => _editingId = null); } /// One knob: its name, and a segmented button in a named semantics group, /// so a test can pick the `0` of one knob apart from the others'. /// Not the shared [knob]: these four sit in a `Wrap`, so each one has to /// shrink-wrap and cannot carry the shared one's sideways scroller, which /// would be unbounded in a `Wrap` cell. The knob itself is [knobButton], /// the same widget in both. Widget _knob( BuildContext context, { required String title, required String name, required List<(String, T)> choices, required T selected, required ValueChanged onChanged, }) => Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(title, style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 4), knobButton( name: name, choices: choices, selected: selected, onChanged: onChanged, ), ], ); Widget _knobs(BuildContext context) { final small = Theme.of(context).textTheme.bodySmall; final applied = _applied; return SectionCard( title: 'Knobs', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Wrap( spacing: 24, runSpacing: 12, children: [ _knob( context, title: 'Stale time', name: 'stale-time', choices: _staleTimes, selected: _staleTime, onChanged: (value) => _changeDefaults(() => _staleTime = value), ), _knob( context, title: 'GC time', name: 'gc-time', choices: _gcTimes, selected: _gcTime, onChanged: (value) => _changeDefaults(() => _gcTime = value), ), _knob( context, title: 'Latency', name: 'latency', choices: _latencies, selected: _latency, onChanged: (value) => _changeScenario(() => _latency = value), ), _knob( context, title: 'Error rate', name: 'error-rate', choices: _errorRates, selected: _errorRate, onChanged: (value) => _changeScenario(() => _errorRate = value), ), ], ), const SizedBox(height: 8), Text( "Stale time and GC time go into the client's defaults " '(setDefaultOptions); every reader on this screen picks them up ' 'on its next build. An entry keeps the longest gc time a reader ' "ever gave it. Latency and error rate go to the backend's " 'scenario, and the retries are 300 ms apart here.', style: small, ), const SizedBox(height: 8), FactGroup( name: 'backend', dense: true, facts: [ if (applied == null) 'backend=pending' else ...[ 'latency=${applied.latency.inMilliseconds}ms', 'errorRate=${(applied.errorRate * 100).round()}%', ], ], ), if (_scenarioError != null) ...[ const SizedBox(height: 8), Notice('Scenario not applied: $_scenarioError', error: true), ], const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, child: FilledButton.tonalIcon( onPressed: _invalidateEverything, icon: const Icon(Icons.restart_alt), label: const Text('Invalidate everything'), ), ), ], ), ); } Widget _todos(BuildContext context) { final todos = watchQuery(todosQuery(_api)); final add = watchMutation(addTodoMutation(_api, _client)); final adding = add.value; return SectionCard( title: 'Todos', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (todos.isFetching) const Pill('fetching'), IconButton( tooltip: 'Refetch', onPressed: todos.isFetching ? null : todos.refetch, icon: const Icon(Icons.refresh), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FactGroup( name: 'todos-reader', dense: true, facts: [ 'status=${todos.status.name}', 'failureCount=${todos.failureCount}', 'isStale=${todos.isStale}', ], ), const SizedBox(height: 8), switch (todos) { QueryPending() => const Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SkeletonBox(), SizedBox(height: 8), SkeletonBox(), SizedBox(height: 8), SkeletonBox(), ], ), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (todos case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], // Bounded and scrolling on its own, so the editor and the // strips below it are built whatever the list's length. SizedBox( height: 200, child: ListView( children: [ for (final todo in data) _TodoRow( todo: todo, selected: todo.id == _editingId, onOpen: () => _openEditor(todo.id), ), ], ), ), ], ), }, const Divider(height: 24), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: TextField( controller: _newTodo, enabled: !adding.isPending, decoration: const InputDecoration( labelText: 'New todo', isDense: true, ), onSubmitted: (_) => _submitNewTodo(add), ), ), const SizedBox(width: 12), FilledButton( onPressed: adding.isPending ? null : () => _submitNewTodo(add), child: const Text('Add todo'), ), ], ), const SizedBox(height: 8), FactGroup( name: 'add', dense: true, facts: ['adding=${adding.status.name}'], ), if (adding case MutationError(:final error)) ...[ const SizedBox(height: 8), Notice('Add failed: $error', error: true), ], ], ), ); } void _submitNewTodo(MutationController add) { add.mutate( _newTodo.text, // Per-call, not on the options: clearing the field is this widget's // business, invalidating the list is the mutation's. callbacks: MutateCallbacks( onSuccess: (_, __, ___) => _newTodo.clear(), ), ); } @override Widget build(BuildContext context) { final editingId = _editingId; final stripId = _stripId; return FeatureScaffold( feature: playgroundFeature, children: [ _knobs(context), _todos(context), if (editingId != null) _TodoEditor( key: ValueKey(editingId), id: editingId, onClose: _closeEditor, ), QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'), if (stripId != null) QueryDebugStrip(queryKey: todoKey(stripId), label: 'todo-$stripId'), ], ); } } /// One row of the list: a button named by the todo's text, so a test opens /// the editor by that text, and a mark for the one the editor is on. class _TodoRow extends StatelessWidget { const _TodoRow({ required this.todo, required this.selected, required this.onOpen, }); final Todo todo; final bool selected; final VoidCallback onOpen; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return SemanticsGroup( name: 'todo ${todo.id}', child: Row( children: [ Icon( todo.done ? Icons.check_circle : Icons.radio_button_unchecked, size: 18, color: todo.done ? scheme.primary : scheme.outline, ), const SizedBox(width: 8), Expanded( child: MergeSemantics( child: Semantics( button: true, child: InkWell( onTap: onOpen, borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Text( todo.text, style: TextStyle( decoration: todo.done ? TextDecoration.lineThrough : null, fontWeight: selected ? FontWeight.bold : null, ), ), ), ), ), ), ), if (selected) const Pill('editing'), ], ), ); } } /// The editor for one todo. Its own `State` with the mixin, so closing it /// removes the widget and with it the observer on `['todos', id]` — the /// moment the entry's gc timer starts. class _TodoEditor extends StatefulWidget { const _TodoEditor({super.key, required this.id, required this.onClose}); final int id; final VoidCallback onClose; @override State<_TodoEditor> createState() => _TodoEditorState(); } class _TodoEditorState extends State<_TodoEditor> with QueryMixin { final TextEditingController _text = TextEditingController(); /// Whether the field has been filled from the data once. Later data /// (a refetch, the PATCH's answer) does not overwrite what is typed. bool _filled = false; @override void dispose() { _text.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final client = queryClient; final id = widget.id; final todo = watchQuery(todoQuery( api, id, seed: () => client .getQueryData>(ShowcaseKeys.todos) ?.where((todo) => todo.id == id) .firstOrNull, seededAt: client.getQueryState>(ShowcaseKeys.todos)?.dataUpdatedAt, )); final patch = watchMutation(patchTodoMutation(api, client)); final saving = patch.value; final data = todo.dataOrNull; if (data != null && !_filled) { _filled = true; _text.text = data.text; } return SectionCard( title: 'Edit todo #$id', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (todo.isFetching) const Pill('fetching'), IconButton( tooltip: 'Close editor', onPressed: widget.onClose, icon: const Icon(Icons.close), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FactGroup( name: 'editor', dense: true, facts: [ 'editing=$id', 'status=${todo.status.name}', 'failureCount=${todo.failureCount}', 'isStale=${todo.isStale}', 'saving=${saving.status.name}', ], ), const SizedBox(height: 8), if (data == null) switch (todo) { QueryError(:final error) => Notice('$error', error: true), _ => const SkeletonBox(width: 240), } else ...[ if (todo case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Row( children: [ Expanded( child: TextField( controller: _text, enabled: !saving.isPending, decoration: const InputDecoration( labelText: 'Text', isDense: true, ), ), ), const SizedBox(width: 12), FilledButton( onPressed: saving.isPending ? null : () => patch.mutate( (id: id, text: _text.text, done: null), ), child: const Text('Rename'), ), ], ), CheckboxListTile( title: const Text('Done'), value: data.done, controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, onChanged: saving.isPending ? null : (done) => patch.mutate((id: id, text: null, done: done)), ), if (saving case MutationError(:final error)) Notice('Save failed: $error', error: true), ], ], ), ); } } ```
## Related - Guides: [Caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md), [Query options](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md), [Initial query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md), [Query retries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md) - Upstream: TanStack's React [`playground`](https://github.com/TanStack/query/tree/main/examples/react/playground) example - Tested by `test/features/playground_test.dart` (widget) and `e2e/tests/playground.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/playground) --- # Cache inspector > Every entry and every event of the query and mutation caches, live, with buttons that load, refetch, invalidate and remove entries. A small devtools panel built from the public API. The screen subscribes to `client.queryCache` and `client.mutationCache` directly, with no observer and no call style, lists every entry with its status, staleness, observer count and update count, and logs each cache event as it happens. Buttons generate traffic and act on single rows. The same few lines make a debug overlay for a real app: a hidden screen in a debug build that shows what the device list or the order history holds right now, and why a screen did or did not refetch. Live demo: [Cache inspector](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/cache-inspector), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/cache_inspector)). Every entry and every event, live. ## What to try - Press *Load posts*: the *Event log* shows `QueryAdded`, a `QueryUpdated(QueryFetchAction)` and a `QueryUpdated(QuerySuccessAction)`, and the *Entries* table gains a `["posts"]` row with `observers=0`. Nobody reads it, and everything this screen creates has a five-second `gcTime`, so a few seconds later the row goes and the log shows `QueryRemoved`. - Turn on *Keep readers*: a `QueryBuilder` mounts on each of the three keys, the rows show `observers=1` and stay. *Invalidate* on a row marks it stale and refetches it (`isStale=true` shows only while the refetch runs); *Refetch* fetches it again; both raise `updates`. *Remove* with a reader mounted is undone at once, because the reader builds the entry again. - Press *Load a missing post*: the row lands with `status=error`. - Press *Add a todo*: the *Mutations* table shows a row going from `status=pending` to `status=success`, and the log names it by its mutation key. - The bin icon (*Clear log*) empties the log and leaves both caches alone. ## The code Both subscriptions are opened once, when the client can first be looked up, and closed in `dispose`: [`examples/showcase/lib/features/cache_inspector/cache_inspector_screen.dart`, lines 148–163](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/cache_inspector/cache_inspector_screen.dart#L148-L163): ```dart /// Both subscriptions are opened here rather than in `initState`: the client /// and the api are inherited widgets, and `initState` may not look one up. /// The guard makes this run exactly once per mount, which is what /// `initState` would have given. @override void didChangeDependencies() { super.didChangeDependencies(); if (_wired) { return; } _wired = true; _api = ShowcaseScope.apiOf(context); _client = QueryClientProvider.of(context); _unsubscribeQueries = _client.queryCache.subscribe(_onQueryEvent); _unsubscribeMutations = _client.mutationCache.subscribe(_onMutationEvent); } ``` Cache events are a sealed family, so the log is a `switch`. The two events about readers rather than about the cache are dropped: options are applied again on every build of every reader, and logging that would grow the log with nobody touching the screen. [`examples/showcase/lib/features/cache_inspector/cache_inspector_screen.dart`, lines 177–191](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/cache_inspector/cache_inspector_screen.dart#L177-L191): ```dart void _onQueryEvent(QueryCacheEvent event) { final name = switch (event) { QueryAdded() => 'QueryAdded', QueryRemoved() => 'QueryRemoved', QueryUpdated(:final action) => 'QueryUpdated(${_queryActionName(action)})', QueryObserverAdded() => 'QueryObserverAdded', QueryObserverRemoved() => 'QueryObserverRemoved', QueryObserverOptionsUpdated() || QueryObserverResultsUpdated() => null, }; if (name == null) { return; } _append('$name ${event.query.queryKey.debugString}'); } ``` The buttons are plain client calls. `client.query` fetches with no observer at all, and the invalidation asks for `RefetchType.all`, because most rows here have no observer and the default refetches only active queries. [`examples/showcase/lib/features/cache_inspector/cache_inspector_screen.dart`, lines 243–274](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/cache_inspector/cache_inspector_screen.dart#L243-L274): ```dart /// The imperative read: no observer, no refetch triggers, and nobody /// waiting on the future — a refused one (the missing post) is meant to /// land in the cache as an error, not to be thrown at the widget tree. void _load(QueryObserverOptions options) => _client.query(options).ignore(); void _addATodo() { _todoSerial += 1; final observer = _addTodo ??= MutationObserver(_client, addTodoMutation(_api)); observer.mutate('Inspected #$_todoSerial'); } /// `refetchType: RefetchType.all`, not the default `active`: most rows here /// have no observer at all, and an invalidation that only marks them would /// never show the fetch the button promises. void _invalidate(Query query) => _client .invalidateQueries( filters: QueryFilters(queryKey: query.queryKey, exact: true), refetchType: RefetchType.all, ) .ignore(); void _refetch(Query query) => _client .refetchQueries( filters: QueryFilters(queryKey: query.queryKey, exact: true), ) .ignore(); void _remove(Query query) => _client.removeQueries( filters: QueryFilters(queryKey: query.queryKey, exact: true), ); ```
The whole screen [`examples/showcase/lib/features/cache_inspector/cache_inspector_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/cache_inspector/cache_inspector_screen.dart): ```dart /// Port-specific: TanStack's devtools are a separate package (`@tanstack/ /// query-devtools`) and are not ported, so this screen stands in for them — /// the app's whole cache, live, plus the traffic to make something happen in /// it. /// /// It is the one screen built on none of the four call styles. It subscribes /// to `client.queryCache` and `client.mutationCache` directly, which is what a /// devtools panel does: no observer, no result, just the sealed /// `QueryCacheEvent` and `MutationCacheEvent` streams and whatever the caches /// hold when the frame is built. /// /// Two events are deliberately watched and never logged, because both are /// about the *readers* rather than the cache. `QueryObserverOptionsUpdated` /// fires once per rebuild of every reader — options are re-applied on every /// build and an inline `queryFn` closure is never equal to the last one — so a /// log that included it would grow forever with nobody touching the screen, /// and since this screen rebuilds on cache events it would feed itself. /// `QueryObserverResultsUpdated` is one line per observer per delivery: it /// says what the UI saw, not what the cache holds. Both are dropped where the /// events are named, and neither rebuilds the screen either. /// /// Proofs (widget tests in `test/features/cache_inspector_test.dart`, /// end-to-end in `e2e/tests/cache_inspector.spec.ts`): loading posts with the /// readers mounted logs `QueryAdded`, `QueryObserverAdded`, a fetch and a /// success in that order and leaves a `["posts"]` row with one observer; /// invalidating a row makes it stale and refetches it, and so does `Refetch`, /// both bumping `updates`; `Remove` drops the row and logs `QueryRemoved`; /// dropping the readers takes `observers` to zero and the entry is collected /// five seconds later; the missing post lands as `status=error`; a todo shows /// up in the mutations table going `pending` then `success`; and leaving the /// screen unsubscribes without leaving a timer or an exception behind. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature cacheInspectorFeature = Feature( id: 'cache-inspector', title: 'Cache inspector', summary: 'Every entry and every event, live.', ); /// Short enough that a collection is something a reader can sit and watch — /// five minutes, the default, is not. const GcTime inspectorGcTime = GcTime.duration(Duration(seconds: 5)); /// What the mounted readers ask for. Long enough that `isStale` in the table /// means something: with the default zero every entry would read /// `isStale=true` the instant its data arrived, and an invalidation would /// change nothing visible. const StaleTime readerStaleTime = StaleTime.duration(Duration(minutes: 1)); /// The post that does not exist, so its fetch is a sure 404. const int missingPostId = 999; /// The key the todo-creating mutation is tagged with, so the log can name it /// the way it names a query. QueryKey get addTodoKey => QueryKey(const ['todos', 'create']); /// The three entries this screen generates, one options function per key. /// /// The stale time is the caller's: a mounted reader asks for /// [readerStaleTime], while a `Load` button asks for zero so that /// `QueryClient.query` always fetches instead of deciding the cached data is /// still fresh. QueryObserverOptions> inspectorPostsQuery( ShowcaseApi api, { required StaleTime staleTime, }) => QueryObserverOptions>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal), staleTime: staleTime, gcTime: inspectorGcTime, ); QueryObserverOptions> inspectorTodosQuery( ShowcaseApi api, { required StaleTime staleTime, }) => QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), staleTime: staleTime, gcTime: inspectorGcTime, ); /// Post 999. No retries: the point is to watch an error land in the cache, /// and a reader counting requests should see exactly one. QueryObserverOptions inspectorMissingPostQuery( ShowcaseApi api, { required StaleTime staleTime, }) => QueryObserverOptions( queryKey: ShowcaseKeys.post(missingPostId), queryFn: (context) => api.post(missingPostId, signal: context.signal), staleTime: staleTime, gcTime: inspectorGcTime, retry: RetryPolicy.never, ); MutationOptions addTodoMutation(ShowcaseApi api) => MutationOptions.simple( mutationKey: addTodoKey, mutationFn: (text) => api.createTodo(text), gcTime: inspectorGcTime, ); class CacheInspectorScreen extends StatefulWidget { const CacheInspectorScreen({super.key}); @override State createState() => _CacheInspectorScreenState(); } class _CacheInspectorScreenState extends State with PhaseSafeRebuild { /// The log keeps the last of these, newest last. A devtools log is a tail, /// not a transcript. static const int logLimit = 30; final List _log = []; bool _keepReaders = false; bool _wired = false; int _todoSerial = 0; late final ShowcaseApi _api; late final QueryClient _client; void Function()? _unsubscribeQueries; void Function()? _unsubscribeMutations; /// The observer behind `Add a todo`, built on the first press. A /// `MutationObserver` rather than one of the widget call styles: this screen /// watches the cache, and a mutation that appears in the table has to come /// from somewhere that is not a builder. MutationObserver? _addTodo; /// Both subscriptions are opened here rather than in `initState`: the client /// and the api are inherited widgets, and `initState` may not look one up. /// The guard makes this run exactly once per mount, which is what /// `initState` would have given. @override void didChangeDependencies() { super.didChangeDependencies(); if (_wired) { return; } _wired = true; _api = ShowcaseScope.apiOf(context); _client = QueryClientProvider.of(context); _unsubscribeQueries = _client.queryCache.subscribe(_onQueryEvent); _unsubscribeMutations = _client.mutationCache.subscribe(_onMutationEvent); } @override void dispose() { _unsubscribeQueries?.call(); _unsubscribeMutations?.call(); // The observer outlives the widget otherwise: a mutation whose only // observer is never removed can never be collected. _addTodo?.destroy(); super.dispose(); } // --- the two subscriptions ------------------------------------------- void _onQueryEvent(QueryCacheEvent event) { final name = switch (event) { QueryAdded() => 'QueryAdded', QueryRemoved() => 'QueryRemoved', QueryUpdated(:final action) => 'QueryUpdated(${_queryActionName(action)})', QueryObserverAdded() => 'QueryObserverAdded', QueryObserverRemoved() => 'QueryObserverRemoved', QueryObserverOptionsUpdated() || QueryObserverResultsUpdated() => null, }; if (name == null) { return; } _append('$name ${event.query.queryKey.debugString}'); } void _onMutationEvent(MutationCacheEvent event) { final name = switch (event) { MutationAdded() => 'MutationAdded', MutationRemoved() => 'MutationRemoved', MutationUpdated(:final action) => 'MutationUpdated(${_mutationActionName(action)})', MutationObserverAdded() => 'MutationObserverAdded', MutationObserverRemoved() => 'MutationObserverRemoved', MutationObserverOptionsUpdated() => null, }; if (name == null) { return; } final key = event.mutation.options.mutationKey; _append('$name ${key == null ? '(no key)' : key.debugString}'); } /// The action's own name, spelled out rather than taken from /// `runtimeType`: the two generic actions would otherwise print their type /// argument, which differs per key and per compiler. static String _queryActionName(QueryAction action) => switch (action) { QueryFetchAction() => 'QueryFetchAction', QueryFailedAction() => 'QueryFailedAction', QuerySuccessAction() => 'QuerySuccessAction', QueryErrorAction() => 'QueryErrorAction', QueryPauseAction() => 'QueryPauseAction', QueryContinueAction() => 'QueryContinueAction', QueryInvalidateAction() => 'QueryInvalidateAction', QuerySetStateAction() => 'QuerySetStateAction', }; static String _mutationActionName(MutationAction action) => switch (action) { MutationPendingAction() => 'MutationPendingAction', MutationSuccessAction() => 'MutationSuccessAction', MutationErrorAction() => 'MutationErrorAction', MutationFailedAction() => 'MutationFailedAction', MutationPauseAction() => 'MutationPauseAction', MutationContinueAction() => 'MutationContinueAction', }; void _append(String line) { _log.add(line); if (_log.length > logLimit) { _log.removeRange(0, _log.length - logLimit); } scheduleRebuild(); } // --- the traffic generators ------------------------------------------ /// The imperative read: no observer, no refetch triggers, and nobody /// waiting on the future — a refused one (the missing post) is meant to /// land in the cache as an error, not to be thrown at the widget tree. void _load(QueryObserverOptions options) => _client.query(options).ignore(); void _addATodo() { _todoSerial += 1; final observer = _addTodo ??= MutationObserver(_client, addTodoMutation(_api)); observer.mutate('Inspected #$_todoSerial'); } /// `refetchType: RefetchType.all`, not the default `active`: most rows here /// have no observer at all, and an invalidation that only marks them would /// never show the fetch the button promises. void _invalidate(Query query) => _client .invalidateQueries( filters: QueryFilters(queryKey: query.queryKey, exact: true), refetchType: RefetchType.all, ) .ignore(); void _refetch(Query query) => _client .refetchQueries( filters: QueryFilters(queryKey: query.queryKey, exact: true), ) .ignore(); void _remove(Query query) => _client.removeQueries( filters: QueryFilters(queryKey: query.queryKey, exact: true), ); @override Widget build(BuildContext context) { final queries = _client.queryCache.queries; final mutations = _client.mutationCache.mutations; return FeatureScaffold( feature: cacheInspectorFeature, children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Notice( 'Built on none of the four call styles: this screen subscribes to ' 'client.queryCache and client.mutationCache directly, the way ' 'devtools do, and reads whatever the caches hold when the frame ' 'is built. Everything it generates carries gcTime 5 s, so an ' 'entry nobody reads is collected five seconds after its last ' 'fetch settles.', ), ), const Padding( padding: EdgeInsets.fromLTRB(16, 8, 16, 0), child: Notice( 'Two events are watched and never logged, because both are about ' 'the readers rather than the cache. QueryObserverOptionsUpdated ' 'fires once per rebuild of every reader — options are re-applied ' 'on every build and an inline queryFn closure is never equal — so ' 'logging it would grow the log with nobody touching the screen, ' 'and this screen rebuilds on cache events, so it would feed ' 'itself. QueryObserverResultsUpdated is one line per observer per ' 'delivery: what the UI saw, not what the cache holds.', ), ), SectionCard( title: 'Traffic', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SemanticsGroup( child: Wrap( spacing: 12, runSpacing: 8, children: [ ActionButton( filled: true, label: 'Load posts', onPressed: () => _load>( inspectorPostsQuery(_api, staleTime: StaleTime.zero), ), ), ActionButton( filled: true, label: 'Load todos', onPressed: () => _load>( inspectorTodosQuery(_api, staleTime: StaleTime.zero), ), ), ActionButton( filled: true, label: 'Load a missing post', onPressed: () => _load( inspectorMissingPostQuery( _api, staleTime: StaleTime.zero, ), ), ), ActionButton( filled: true, label: 'Add a todo', onPressed: _addATodo, ), ], ), ), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Keep readers'), value: _keepReaders, onChanged: (value) => setState(() => _keepReaders = value), ), const Text( 'Mounts a QueryBuilder on each of the three keys, so the ' 'table shows observers and an entry is pinned against ' 'collection. The readers ask for a one-minute stale time, so ' 'isStale says something other than "always". With a reader ' 'mounted, Remove is undone at once: the observer rebuilds and ' 'builds the entry again.', ), if (_keepReaders) ...[ const SizedBox(height: 8), _Reader>( options: inspectorPostsQuery( _api, staleTime: readerStaleTime, ), describe: (data) => '${data.length} posts', ), _Reader>( options: inspectorTodosQuery( _api, staleTime: readerStaleTime, ), describe: (data) => '${data.length} todos', ), _Reader( options: inspectorMissingPostQuery( _api, staleTime: readerStaleTime, ), describe: (data) => data.title, ), ], ], ), ), SectionCard( title: 'Entries', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('entries=${queries.length}'), const SizedBox(height: 8), if (queries.isEmpty) const Text('The query cache is empty.') else for (final query in queries) _EntryRow( query: query, onRefetch: () => _refetch(query), onInvalidate: () => _invalidate(query), onRemove: () => _remove(query), ), ], ), ), SectionCard( title: 'Mutations', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('mutations=${mutations.length}'), const SizedBox(height: 8), if (mutations.isEmpty) const Text('The mutation cache is empty.') else for (final mutation in mutations) _MutationRow(mutation: mutation), ], ), ), SectionCard( title: 'Event log', trailing: IconButton( tooltip: 'Clear log', onPressed: _log.isEmpty ? null : () => setState(_log.clear), icon: const Icon(Icons.delete_outline), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('log=${_log.length}'), const SizedBox(height: 4), // Its own semantics group, like a debug strip: a test finds the // group and each line as an exact text inside it. SemanticsGroup( name: 'event log', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_log.isEmpty) const Text('Nothing yet.') else for (final line in _log) Text(line, style: monoStyleSmall), ], ), ), ], ), ), ], ); } } /// `null` reads as `never` rather than as a blank: a test asserts on it. String _clock(DateTime? at) => at == null ? 'never' : hhmmss(at); /// A mounted reader: it holds an observer on its key and shows what the entry /// currently is. Nothing here drives the table — the table reads the cache. class _Reader extends StatelessWidget { const _Reader({required this.options, required this.describe}); final QueryObserverOptions options; final String Function(T data) describe; @override Widget build(BuildContext context) => QueryBuilder( options: options, builder: (context, result) => Text( 'reader ${options.queryKey.debugString}=${switch (result) { QueryPending() => 'pending', QueryError(:final error) => 'error: $error', QuerySuccess(:final data) => describe(data), }}', style: monoStyleSmall, ), ); } /// One row of the entries table: the key, its facts as texts of their own, /// and the three buttons that act on it. /// /// A named group per row, so a test can ask for one row's `status=` and not /// another's — two rows carry the same fact names, and unscoped they would /// collide. class _EntryRow extends StatelessWidget { const _EntryRow({ required this.query, required this.onRefetch, required this.onInvalidate, required this.onRemove, }); final Query query; final VoidCallback onRefetch; final VoidCallback onInvalidate; final VoidCallback onRemove; @override Widget build(BuildContext context) { final key = query.queryKey.debugString; final state = query.state; final facts = [ 'status=${state.status.name}', 'fetchStatus=${state.fetchStatus.name}', 'isStale=${query.isStale()}', 'observers=${query.observersCount}', 'updates=${state.dataUpdateCount}', 'dataUpdatedAt=${_clock(state.dataUpdatedAt)}', ]; return SemanticsGroup( name: 'entry $key', child: Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(key, style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 4), FactList(facts, dense: true), const SizedBox(height: 4), Wrap( spacing: 8, runSpacing: 4, children: [ TextButton(onPressed: onRefetch, child: Text('Refetch $key')), TextButton( onPressed: onInvalidate, child: Text('Invalidate $key'), ), TextButton(onPressed: onRemove, child: Text('Remove $key')), ], ), ], ), ), ); } } /// One row of the mutations table. Mutations are never shared by key, so the /// row is named by the cache's own `mutationId`. class _MutationRow extends StatelessWidget { const _MutationRow({required this.mutation}); final Mutation mutation; @override Widget build(BuildContext context) { final state = mutation.state; final key = mutation.options.mutationKey; final facts = [ 'status=${state.status.name}', 'isPaused=${state.isPaused}', 'failureCount=${state.failureCount}', ]; return SemanticsGroup( name: 'mutation #${mutation.mutationId}', child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '#${mutation.mutationId} ${key == null ? '(no key)' : key.debugString}', style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 4), FactList(facts, dense: true), ], ), ), ); } } ```
## Related - Guides: [Debugging](https://dualmeta-gmbh.github.io/query_kit/docs/guides/debugging.md), [Caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md), [Query invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md) - Upstream: TanStack ships devtools as a separate package; its React [`devtools-panel`](https://github.com/TanStack/query/tree/main/examples/react/devtools-panel) example is the nearest counterpart - Tested by `test/features/cache_inspector_test.dart` (widget) and `e2e/tests/cache_inspector.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/cache_inspector) --- # Auto refetching > A list polled on an interval that can be switched live between off, two fixed values and one computed from the data, with and without polling in the background. A list of ticks, polled on an interval you switch while the query is live: `off`, 500 ms, 2 s, or a `dynamic` interval that looks at the data and stops polling once the list has three entries. Two writes, **Add tick** and **Clear ticks**, invalidate the list, so polling is not the only way it changes. Use this pattern for anything the server changes without being asked: a device's live status on a dashboard, an order moving through fulfilment, a job queue that stops needing a poll once every job has finished. Live demo: [Auto refetching](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/auto-refetching), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/auto_refetching)). Polling on an interval, in the foreground or not. ## What to try - Pick `500 ms` under **Interval**: the *polling* pill flickers and the `fetches` count on the debug strip keeps growing. Pick `off` and it stops. - With polling on, press **Add tick**: the new row is on screen by the next poll at the latest. The button stays disabled until the invalidation's refetch has landed. - Pick `dynamic` after pressing **Clear ticks**: it polls while the list has fewer than three ticks. Add three and it stops by itself; clear them and it starts again. - Press **Unfocus** with an interval on: the interval still fires, but nothing is fetched until you switch on **Poll in the background**. The binding normally moves focus from the app lifecycle; these buttons do it by hand. Pressing **Focus** also refetches the stale list, which is `refetchOnWindowFocus`, not the interval. Clicking outside the demo is a real loss of focus too, so polling pauses while you read this page and resumes when you click back in. ## The code The query takes both knobs as plain options. A `dynamic` interval is a function of the query that returns a duration, or `null` to stop polling. It is a top-level function, because `RefetchInterval.dynamic` compares by the function's identity and an inline closure would be a new value on every build. [`examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart`, lines 56–79](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart#L56-L79): ```dart /// The `dynamic` interval's rule, as a top-level function so the value below /// can be a `const`: `RefetchInterval.dynamic` compares by the identity of its /// function, and an inline closure would be a different value on every build. Duration? _whileTheListIsShort(Query query) { final data = query.state.data; final count = data is List ? data.length : 0; return count < _pollUntil ? const Duration(milliseconds: 500) : null; } const RefetchInterval _dynamicInterval = RefetchInterval.dynamic(_whileTheListIsShort); /// The screen's one query, with the two knobs it turns. QueryObserverOptions> ticksQuery( ShowcaseApi api, { required RefetchInterval refetchInterval, required bool refetchIntervalInBackground, }) => QueryObserverOptions>( queryKey: ticksKey, queryFn: (context) => api.ticks(signal: context.signal), refetchInterval: refetchInterval, refetchIntervalInBackground: refetchIntervalInBackground, ); ``` Both writes invalidate the list from `onSuccess` and return the invalidation's future, so each mutation stays pending until the refetch has landed. [`examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart`, lines 81–93](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart#L81-L93): ```dart /// Both writes invalidate the list. `onSuccess` returns the invalidation's /// future, upstream's idiom: the mutation stays pending until the refetch has /// landed, so the button comes back enabled only once the list on screen is /// the list the backend holds. MutationOptions addTickMutation( QueryClient client, ShowcaseApi api, ) => MutationOptions.simple( mutationFn: (_) => api.addTick(), onSuccess: (_, __, ___) => client.invalidateQueries(filters: _ticksFilter), ); ``` [`examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart`, lines 95–103](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart#L95-L103): ```dart MutationOptions clearTicksMutation( QueryClient client, ShowcaseApi api, ) => MutationOptions.simple( mutationFn: (_) => api.clearTicks(), onSuccess: (_, __, ___) => client.invalidateQueries(filters: _ticksFilter), ); ``` The list is read with a `QueryBuilder`. A changed knob is a new `refetchInterval` on the next build, and the live observer picks it up. [`examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart`, lines 214–226](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart#L214-L226): ```dart QueryBuilder>( options: ticksQuery( api, refetchInterval: _interval, refetchIntervalInBackground: _inBackground, ), builder: (context, ticks) => _TicksCard( ticks: ticks, writing: writing, onAdd: writing ? null : () => add.mutate(null), onClear: writing ? null : () => clear.mutate(null), ), ), ```
The whole screen [`examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/auto_refetching/auto_refetching_screen.dart): ```dart /// Upstream's `auto-refetching` example: a list polled on an interval, with /// the interval turned live between `off`, two fixed values and a `dynamic` /// one that reads the data it is deciding about. Two writes — `Add tick` and /// `Clear ticks` — invalidate the list, so a poll is not the only way it /// changes. /// /// The list is read with a `QueryBuilder` and the writes go through /// `context.mutation`. /// /// `refetchIntervalInBackground` is the second knob: an armed interval fires /// while the app is in the background only when it is on. The Flutter binding /// feeds the client's focus from `AppLifecycleListener`, and a headless /// browser never reports a lifecycle change — so the screen drives /// `client.focusManager.setFocused` itself, which is exactly what the binding /// does on a lifecycle event, and the flag becomes provable in a browser. /// /// Proofs (widget tests in `test/features/auto_refetching_test.dart`, /// end-to-end in `e2e/tests/auto_refetching.spec.ts`): with `off` the list is /// fetched once and stays; with `500 ms` the strip's `fetches` grows and going /// back to `off` freezes it; a tick added while polling is on screen by the /// next poll at the latest and clearing empties the list; unfocused, the /// interval fires without fetching until `Poll in the background` is on; and /// the `dynamic` interval polls while the list is short, stops itself at the /// third tick and starts again once the list is cleared. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature autoRefetchingFeature = Feature( id: 'auto-refetching', title: 'Auto refetching', summary: 'Polling on an interval, in the foreground or not.', upstream: 'auto-refetching', ); /// The polled list. Owned by this screen, so it lives here rather than in /// `ShowcaseKeys`. final QueryKey ticksKey = QueryKey(const ['ticks']); QueryFilters get _ticksFilter => QueryFilters(queryKey: ticksKey); /// How many ticks the `dynamic` interval keeps polling for. const int _pollUntil = 3; /// The `dynamic` interval's rule, as a top-level function so the value below /// can be a `const`: `RefetchInterval.dynamic` compares by the identity of its /// function, and an inline closure would be a different value on every build. Duration? _whileTheListIsShort(Query query) { final data = query.state.data; final count = data is List ? data.length : 0; return count < _pollUntil ? const Duration(milliseconds: 500) : null; } const RefetchInterval _dynamicInterval = RefetchInterval.dynamic(_whileTheListIsShort); /// The screen's one query, with the two knobs it turns. QueryObserverOptions> ticksQuery( ShowcaseApi api, { required RefetchInterval refetchInterval, required bool refetchIntervalInBackground, }) => QueryObserverOptions>( queryKey: ticksKey, queryFn: (context) => api.ticks(signal: context.signal), refetchInterval: refetchInterval, refetchIntervalInBackground: refetchIntervalInBackground, ); /// Both writes invalidate the list. `onSuccess` returns the invalidation's /// future, upstream's idiom: the mutation stays pending until the refetch has /// landed, so the button comes back enabled only once the list on screen is /// the list the backend holds. MutationOptions addTickMutation( QueryClient client, ShowcaseApi api, ) => MutationOptions.simple( mutationFn: (_) => api.addTick(), onSuccess: (_, __, ___) => client.invalidateQueries(filters: _ticksFilter), ); MutationOptions clearTicksMutation( QueryClient client, ShowcaseApi api, ) => MutationOptions.simple( mutationFn: (_) => api.clearTicks(), onSuccess: (_, __, ___) => client.invalidateQueries(filters: _ticksFilter), ); class AutoRefetchingScreen extends StatefulWidget { const AutoRefetchingScreen({super.key}); @override State createState() => _AutoRefetchingScreenState(); } class _AutoRefetchingScreenState extends State { static const List<(String, RefetchInterval)> _intervals = <(String, RefetchInterval)>[ ('off', RefetchInterval.off), ('500 ms', RefetchInterval.every(Duration(milliseconds: 500))), ('2 s', RefetchInterval.every(Duration(seconds: 2))), ('dynamic', _dynamicInterval), ]; RefetchInterval _interval = RefetchInterval.off; bool _inBackground = false; /// Mirrors the client's focus, which this screen is the only one to move. bool _focused = true; String get _intervalLabel => _intervals.firstWhere((entry) => entry.$2 == _interval).$1; void _setFocused(bool focused) { QueryClientProvider.read(context).focusManager.setFocused(focused); setState(() => _focused = focused); } @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); final client = QueryClientProvider.of(context); final small = Theme.of(context).textTheme.bodySmall; final add = context.mutation(addTickMutation(client, api), id: 'add'); final clear = context.mutation(clearTicksMutation(client, api), id: 'clear'); final writing = add.value.isPending || clear.value.isPending; return FeatureScaffold( feature: autoRefetchingFeature, children: [ SectionCard( title: 'Polling', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Interval', style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 4), // Scrolls sideways rather than overflowing on a narrow phone. SingleChildScrollView( scrollDirection: Axis.horizontal, child: knobButton( name: 'interval', choices: _intervals, selected: _interval, onChanged: (value) => setState(() => _interval = value), ), ), const SizedBox(height: 4), Text( 'dynamic polls every 500 ms while the list has fewer than ' '$_pollUntil ticks and returns null after that, so the poll ' 'stops itself — and starts again when the list is cleared.', style: small, ), const Divider(height: 24), SwitchListTile( key: const ValueKey('in-background'), contentPadding: EdgeInsets.zero, title: const Text('Poll in the background'), value: _inBackground, onChanged: (value) => setState(() => _inBackground = value), ), Text( 'An armed interval fires either way; without this it only ' 'fetches while the app is focused. The binding feeds focus ' 'from the app lifecycle, and a headless browser reports none ' '— so these two buttons move it by hand, exactly as the ' 'binding does. Focusing again also refetches the stale list, ' 'which is refetchOnWindowFocus, not the interval.', style: small, ), const SizedBox(height: 8), SemanticsGroup( child: Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ OutlinedButton( onPressed: _focused ? () => _setFocused(false) : null, child: const Text('Unfocus'), ), OutlinedButton( onPressed: _focused ? null : () => _setFocused(true), child: const Text('Focus'), ), _Fact('interval=$_intervalLabel'), _Fact('background=$_inBackground'), _Fact('focused=$_focused'), ], ), ), ], ), ), QueryBuilder>( options: ticksQuery( api, refetchInterval: _interval, refetchIntervalInBackground: _inBackground, ), builder: (context, ticks) => _TicksCard( ticks: ticks, writing: writing, onAdd: writing ? null : () => add.mutate(null), onClear: writing ? null : () => clear.mutate(null), ), ), QueryDebugStrip(queryKey: ticksKey, label: 'ticks'), ], ); } } /// The list, its count and the two writes. class _TicksCard extends StatelessWidget { const _TicksCard({ required this.ticks, required this.writing, required this.onAdd, required this.onClear, }); final QueryResult> ticks; final bool writing; final VoidCallback? onAdd; final VoidCallback? onClear; @override Widget build(BuildContext context) { final rows = ticks.dataOrNull; return SectionCard( title: 'Ticks', trailing: ticks.isFetching ? const Pill('polling') : null, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SemanticsGroup( child: Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ FilledButton( onPressed: onAdd, child: const Text('Add tick'), ), OutlinedButton( onPressed: onClear, child: const Text('Clear ticks'), ), _Fact('ticks=${rows?.length ?? 0}'), if (writing) const _Fact('writing=true'), ], ), ), const SizedBox(height: 12), switch (ticks) { QueryPending() => const SkeletonBox(width: 200), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => // Bounded and eager: the strip below must stay reachable in the // lazy `ListView` the scaffold lays its children out in, and a // row a poll appended has to be findable without scrolling // logic in the tests. SizedBox( height: 180, child: data.isEmpty ? const Align( alignment: Alignment.topLeft, child: Text('No ticks yet.'), ) : SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final tick in data) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text('tick ${tick.id}'), ), ], ), ), ), }, ], ), ); } } /// One `key=value` fact, in the monospace the debug strip uses. class _Fact extends StatelessWidget { const _Fact(this.text); final String text; @override Widget build(BuildContext context) => Text( text, style: const TextStyle(fontFamily: 'monospace', fontSize: 12), ); } ```
## Related - Guides: [Polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md), [App focus refetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md), [Invalidations from mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md) - Upstream: TanStack's React [`auto-refetching`](https://github.com/TanStack/query/tree/main/examples/react/auto-refetching) example - Tested by `test/features/auto_refetching_test.dart` (widget) and `e2e/tests/auto_refetching.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/auto_refetching) --- # Retry > Every retry policy and delay on one query, with failures scripted on the backend, and what the result reports while the retries run. One query for the server's time, three knobs for how it retries, and a panel that tells the backend to refuse the next few requests. Choose a policy (`never`, `2 times`, `always`, `when 5xx`) and a delay (a fixed 300 ms, the default exponential backoff, or one computed from the error), then watch `failureCount` and `failureReason` climb while the retries run. Real apps need this as soon as they talk to anything that is sometimes unavailable: retry a gateway that answers 503 while a device reboots, give up at once on a 404 for a product that was deleted, wait longer before asking a rate-limited API again. Live demo: [Retry](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/retry), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/retry)). Retry policies and delays, and what the result shows meanwhile. ## What to try - Pick **Fail the next** `2`, press **Arm**, set **Retry** to `2 times` and press the **Refetch** icon: `failureCount` goes 1, then 2, while `failureReason` shows the refusal, and the third attempt succeeds. With `10` armed the same policy gives up at `failureCount=3`, and the old server time stays on screen as `isRefetchError=true` with `hasStaleData=true`. - Set **Retry** to `when 5xx`, **Status** to `404`, arm and refetch: one request, and the error stands, because the policy retries only server errors. Arm again with `503` and the same refetch retries. - Set **Retry** to `2 times`, **Delay** to `exponential`, arm two failures and refetch: the first retry waits a second, the second two. With `dynamic`, a 404 is retried after four seconds and anything else after 200 ms. - After a refetch has failed for good, press **Retry now** under the error: it is the result's own `refetch`, and it spends whatever is still armed. ## The code A policy that looks at the error, and a delay computed from it. Both are top-level functions, because `RetryPolicy.when` and `RetryDelay.dynamic` compare by the function's identity. [`examples/showcase/lib/features/retry/retry_screen.dart`, lines 59–84](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/retry/retry_screen.dart#L59-L84): ```dart /// Retry a server error up to three times, and never a 404 — the guide's /// `retry: (failureCount, error) => …`, which is where a policy gets to look /// at what was thrown. /// /// A top-level function, not a closure: [RetryWhen] is compared by the /// identity of its predicate, and an inline one would be a different value on /// every build, so the segmented button would never find its own choice /// selected. bool _retryServerErrors(int failureCount, Object error, StackTrace _) => failureCount < 3 && error is BackendException && (error.status ?? 0) >= 500; const RetryPolicy _when5xx = RetryPolicy.when(_retryServerErrors); const RetryDelay _fixed300 = RetryDelay.fixed(Duration(milliseconds: 300)); /// A wait computed from what the attempt threw — the guide's /// `retryDelay: (attempt, error) => …`: a 404 is not going to change its mind /// soon, so it waits four seconds; anything else is retried after 200 ms. /// /// A top-level function for the same reason as [_retryServerErrors]: /// [RetryDelayDynamic] compares by the identity of its function. Duration _delayByStatus(int failureCount, Object error) => error is BackendException && error.status == 404 ? const Duration(seconds: 4) : const Duration(milliseconds: 200); const RetryDelay _dynamic = RetryDelay.dynamic(_delayByStatus); ``` The three knobs are ordinary options on the query. The screen reads it through a `QueryController`, so a changed knob reaches the live observer through `setOptions`. [`examples/showcase/lib/features/retry/retry_screen.dart`, lines 86–99](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/retry/retry_screen.dart#L86-L99): ```dart /// The screen's one query, with the three knobs the screen turns. QueryObserverOptions retryTimeQuery( ShowcaseApi api, { required RetryPolicy retry, required RetryDelay retryDelay, required bool retryOnMount, }) => QueryObserverOptions( queryKey: retryTimeKey, queryFn: (context) => api.time(signal: context.signal), retry: retry, retryDelay: retryDelay, retryOnMount: retryOnMount, ); ``` While the retries run, the result stays pending or keeps its data, and it carries the count and the last error. Once the retries are spent, a `QueryError` says whether the first load failed or a refetch did, and a failed refetch keeps the last good data as `staleData`. [`examples/showcase/lib/features/retry/retry_screen.dart`, lines 392–452](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/retry/retry_screen.dart#L392-L452): ```dart Widget _reading(BuildContext context, QueryResult time) { final data = time.dataOrNull; final failed = time is QueryError ? time : null; return _Reading( facts: [ 'reader=attached', 'status=${time.status.name}', 'fetchStatus=${time.fetchStatus.name}', 'failureCount=${time.failureCount}', 'failureReason=${time.failureReason ?? 'none'}', 'isLoadingError=${failed?.isLoadingError ?? false}', 'isRefetchError=${failed?.isRefetchError ?? false}', 'hasStaleData=${failed?.hasStaleData ?? false}', 'serial=${data?.serial ?? 'none'}', ], child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ switch (time) { QueryPending() => const SkeletonBox(width: 200), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (time case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Row( children: [ Expanded( child: Text( 'Server time #${data.serial}', style: Theme.of(context).textTheme.titleMedium, ), ), if (time.isFetching) const Pill('refreshing'), ], ), ], ), }, if (time.isError) ...[ const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, // The result's own `refetch`, which is what an error state is // expected to offer; it ignores `enabled` and `staleTime`. child: TextButton( onPressed: time.refetch, child: const Text('Retry now'), ), ), ], ], ), ); } ```
The whole screen [`examples/showcase/lib/features/retry/retry_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/retry/retry_screen.dart): ```dart /// `RetryPolicy` and `RetryDelay` on one query, with the failures scripted on /// the backend: how many attempts a refused fetch costs, what the result says /// between them, and how a first-load error differs from a refused refetch. /// /// Port-specific; it illustrates upstream's *Query Retries* guide /// (`docs/framework/react/guides/query-retries.md`). Upstream's /// `retry: false | number | true | fn` is the sealed [RetryPolicy] here — /// `never`, `times`, `always`, `when` — and `retryDelay: ms | fn` is /// [RetryDelay]: `fixed`, `exponential`, and `dynamic` for a wait computed /// from the attempt and what it threw; the guide's note that the error is the /// result's `failureReason` until the last attempt is what the /// `failureReason=` fact shows. /// /// The reader is a `QueryController` created next to the api and read through /// a `ListenableBuilder`, so a knob change reaches the live observer through /// `setOptions` — and detaching it, then attaching a fresh one, is the mount /// that `retryOnMount` decides about. /// /// Proofs (widget tests in `test/features/retry_test.dart`, end-to-end in /// `e2e/tests/retry.spec.ts`): with `never` one refused request is the error, /// `failureCount=1` and `isLoadingError=true`; with `2 times` and two refusals /// the failure count climbs 1 → 2 while the retries run and the third attempt /// succeeds, three requests in all; with ten refusals the same policy ends in /// `failureCount=3`, still three requests; `always` goes on past the third /// failure and past a 404 until the eleventh attempt succeeds; a refused /// *refetch* keeps the old serial on screen as `isRefetchError` with /// `hasStaleData=true`; `when 5xx` retries a 503 and gives up on a 404 after /// one request; an errored entry is refetched when a fresh reader mounts with /// `retryOnMount` on and left alone when it is off; the exponential delay has /// not reached its first retry at 400 ms, where the fixed 300 ms one already /// has; and the dynamic delay waits four seconds after a 404 and 200 ms after /// a 503, because it is computed from the error. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature retryFeature = Feature( id: 'retry', title: 'Retry', summary: 'Retry policies and delays, and what the result shows meanwhile.', ); /// This screen's own cache entry. Deliberately not `ShowcaseKeys.time`: the /// scripted failures armed here would otherwise reach the entry the /// `stale-and-gc` screen keeps. QueryKey get retryTimeKey => QueryKey(const ['retry', 'time']); /// Retry a server error up to three times, and never a 404 — the guide's /// `retry: (failureCount, error) => …`, which is where a policy gets to look /// at what was thrown. /// /// A top-level function, not a closure: [RetryWhen] is compared by the /// identity of its predicate, and an inline one would be a different value on /// every build, so the segmented button would never find its own choice /// selected. bool _retryServerErrors(int failureCount, Object error, StackTrace _) => failureCount < 3 && error is BackendException && (error.status ?? 0) >= 500; const RetryPolicy _when5xx = RetryPolicy.when(_retryServerErrors); const RetryDelay _fixed300 = RetryDelay.fixed(Duration(milliseconds: 300)); /// A wait computed from what the attempt threw — the guide's /// `retryDelay: (attempt, error) => …`: a 404 is not going to change its mind /// soon, so it waits four seconds; anything else is retried after 200 ms. /// /// A top-level function for the same reason as [_retryServerErrors]: /// [RetryDelayDynamic] compares by the identity of its function. Duration _delayByStatus(int failureCount, Object error) => error is BackendException && error.status == 404 ? const Duration(seconds: 4) : const Duration(milliseconds: 200); const RetryDelay _dynamic = RetryDelay.dynamic(_delayByStatus); /// The screen's one query, with the three knobs the screen turns. QueryObserverOptions retryTimeQuery( ShowcaseApi api, { required RetryPolicy retry, required RetryDelay retryDelay, required bool retryOnMount, }) => QueryObserverOptions( queryKey: retryTimeKey, queryFn: (context) => api.time(signal: context.signal), retry: retry, retryDelay: retryDelay, retryOnMount: retryOnMount, ); class RetryScreen extends StatefulWidget { const RetryScreen({super.key}); @override State createState() => _RetryScreenState(); } class _RetryScreenState extends State { static const List<(String, RetryPolicy)> _retries = <(String, RetryPolicy)>[ ('never', RetryPolicy.never), ('2 times', RetryPolicy.times(2)), ('always', RetryPolicy.always), ('when 5xx', _when5xx), ]; static const List<(String, RetryDelay)> _delays = <(String, RetryDelay)>[ ('300 ms', _fixed300), ('exponential', RetryDelay.defaultValue), ('dynamic', _dynamic), ]; static const List<(String, int)> _statuses = <(String, int)>[ ('503', 503), ('404', 404), ]; static const List<(String, int)> _counts = <(String, int)>[ ('0', 0), ('2', 2), ('10', 10), ]; RetryPolicy _retry = RetryPolicy.never; RetryDelay _delay = _fixed300; int _status = 503; int _failNext = 0; bool _retryOnMount = true; /// What the backend was last told to refuse, as the `armed=` fact. String _armed = 'none'; late final ShowcaseApi _api; late final QueryClient _client; bool _initialised = false; /// The reader, or null while detached. Built in [didChangeDependencies] /// rather than `initState` because the api and the client are inherited. QueryController? _reader; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _api = ShowcaseScope.apiOf(context); _client = QueryClientProvider.of(context); _reader = QueryController.create(_client, _options); } } @override void dispose() { _reader?.dispose(); super.dispose(); } QueryObserverOptions get _options => retryTimeQuery( _api, retry: _retry, retryDelay: _delay, retryOnMount: _retryOnMount, ); /// A changed knob reaches the live reader through `setOptions`; a detached /// one picks it up on the next attach. void _applyOptions() { setState(() { _reader?.setOptions(_options); }); } void _attach() { setState(() { // A new controller is a new observer, and subscribing it is a mount — // which is the moment `retryOnMount` decides. _reader = QueryController.create(_client, _options); }); } void _detach() { setState(() { _reader?.dispose(); _reader = null; }); } /// Tells the backend to refuse the next `_failNext` reads of `/api/time`. /// /// A count of zero still sends the script: it replaces whatever was armed /// before, which is how the screen disarms. Future _arm() async { final count = _failNext; final status = _status; String armed; try { await _api.configureScenario( failNext: [ FailNext( method: 'GET', path: '/api/time', count: count, status: status, ), ], ); armed = count == 0 ? 'none' : '$count@$status'; } on Object { armed = 'failed'; } if (mounted) { setState(() => _armed = armed); } } @override Widget build(BuildContext context) { final reader = _reader; final small = Theme.of(context).textTheme.bodySmall; return FeatureScaffold( feature: retryFeature, children: [ SectionCard( title: 'Server time', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Refetch', onPressed: reader?.refetch, icon: const Icon(Icons.refresh), ), IconButton( tooltip: 'Detach reader', onPressed: reader == null ? null : _detach, icon: const Icon(Icons.visibility_off), ), IconButton( tooltip: 'Attach reader', onPressed: reader == null ? _attach : null, icon: const Icon(Icons.visibility), ), ], ), child: reader == null ? const _Reading( facts: ['reader=detached'], child: Text( 'No reader. Attaching a fresh one is a mount: with ' 'Retry on mount off an errored entry is left alone.', ), ) : ListenableBuilder( listenable: reader, builder: (context, _) => _reading(context, reader.value), ), ), QueryDebugStrip(queryKey: retryTimeKey, label: 'time'), SectionCard( title: 'Policy', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ knob( context, title: 'Retry', name: 'retry', choices: _retries, selected: _retry, onChanged: (value) { _retry = value; _applyOptions(); }, ), const SizedBox(height: 4), Text( '2 times means one attempt plus two retries — the count a ' 'policy is asked about is how many attempts had already ' 'failed, so it starts at 0. always retries until an attempt ' 'succeeds, whatever the error. when 5xx retries a server ' 'error up to three times and gives up on a 404 at once.', style: small, ), const SizedBox(height: 8), knob( context, title: 'Delay', name: 'delay', choices: _delays, selected: _delay, onChanged: (value) { _delay = value; _applyOptions(); }, ), const SizedBox(height: 4), Text( 'exponential is the default: one second, two, four, capped at ' 'thirty. The delay is computed before the failure is counted, ' 'so the first retry waits one second. dynamic is computed ' 'from the attempt and its error: four seconds after a 404, ' '200 ms after anything else.', style: small, ), ], ), ), SectionCard( title: 'Scripted failures', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Text( 'armed=$_armed', style: const TextStyle(fontFamily: 'monospace', fontSize: 12), ), const SizedBox(width: 8), FilledButton.tonal( onPressed: () => _arm().ignore(), child: const Text('Arm'), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ knob( context, title: 'Fail the next', name: 'fail-next', choices: _counts, selected: _failNext, onChanged: (value) => setState(() => _failNext = value), ), const SizedBox(height: 8), knob( context, title: 'Status', name: 'status', choices: _statuses, selected: _status, onChanged: (value) => setState(() => _status = value), ), const SizedBox(height: 4), Text( 'Arm hands the script to the backend; Refetch then spends it. ' 'Zero disarms whatever was armed before.', style: small, ), ], ), ), SectionCard( // Not "Retry on mount": that is the switch's own name, and two // texts alike would be two nodes a test cannot tell apart. title: 'Mounting an errored entry', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SwitchListTile( // No subtitle: it would become part of the switch's // accessible name, and the explanation is below instead. title: const Text('Retry on mount'), value: _retryOnMount, onChanged: (value) { _retryOnMount = value; _applyOptions(); }, ), Text( 'Only an entry that ended in an error with no data is ' 'affected: with the switch on a fresh reader fetches again, ' 'with it off the error stands until something asks for it.', style: small, ), ], ), ), ], ); } Widget _reading(BuildContext context, QueryResult time) { final data = time.dataOrNull; final failed = time is QueryError ? time : null; return _Reading( facts: [ 'reader=attached', 'status=${time.status.name}', 'fetchStatus=${time.fetchStatus.name}', 'failureCount=${time.failureCount}', 'failureReason=${time.failureReason ?? 'none'}', 'isLoadingError=${failed?.isLoadingError ?? false}', 'isRefetchError=${failed?.isRefetchError ?? false}', 'hasStaleData=${failed?.hasStaleData ?? false}', 'serial=${data?.serial ?? 'none'}', ], child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ switch (time) { QueryPending() => const SkeletonBox(width: 200), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (time case QueryError(:final error)) ...[ Notice('Refetch failed: $error', error: true), const SizedBox(height: 8), ], Row( children: [ Expanded( child: Text( 'Server time #${data.serial}', style: Theme.of(context).textTheme.titleMedium, ), ), if (time.isFetching) const Pill('refreshing'), ], ), ], ), }, if (time.isError) ...[ const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, // The result's own `refetch`, which is what an error state is // expected to offer; it ignores `enabled` and `staleTime`. child: TextButton( onPressed: time.refetch, child: const Text('Retry now'), ), ), ], ], ), ); } } /// What the reader shows, and its own facts as `key=value` texts in a /// semantics group of their own, so a test tells the reader's `status=` apart /// from the strip's. class _Reading extends StatelessWidget { const _Reading({required this.facts, required this.child}); final List facts; final Widget child; @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ child, const SizedBox(height: 8), FactGroup(name: 'reader', facts: facts, dense: true), ], ); } ```
## Related - Guides: [Query retries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md), [Queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md) - Tested by `test/features/retry_test.dart` (widget) and `e2e/tests/retry.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/retry) --- # Cancellation > A slow fetch cancelled by hand, with and without the signal reaching the transport, and search-as-you-type that cancels the needle in flight. Two cards. The first is a three-second fetch you cancel by hand with `cancelQueries`: the entry goes back to what it was before the fetch began, and the answer is dropped. A switch decides whether the query function reads `context.signal`, which is what lets the HTTP request itself be aborted. The second card is search-as-you-type, where every needle is a key of its own and a new needle cancels the one still in flight. You need this wherever a request can outlive the reason it was sent: a product search that fires on every keystroke, a large export the user gives up on, a detail screen left before its data arrived. Live demo: [Cancellation](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/cancellation), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/cancellation)). A query cancelled is a request aborted. ## What to try - The slow fetch starts when the screen opens. Press **Cancel** while the *fetching* pill is up: `fetchStatus=idle`, `status=pending` (the entry is back to how it was before its first fetch), and `cancels=1`, because the signal's `onCancel` ran. Press **Start slow fetch** and let it finish: `posts=30`. - Press **Cancel silently** during a fetch: the same revert, and no error reaches the query's state. - Switch on **Ignore the signal**, start the fetch and cancel it: the query is cancelled on the spot all the same, but `cancels` does not move. The request was never told to stop, so it runs to completion and its answer is thrown away. - Type into **Search posts**. Two keystrokes within 300 ms send one request, for the last needle. Type again while a search is out and the old needle is cancelled: `searchCancels` goes up, and the `previous` debug strip shows its entry pending and idle. ## The code The query function hands `context.signal` to the api client, which bridges it to dio's cancel token. The `onCancel` callback here only counts; the bridge is what aborts the request. [`examples/showcase/lib/features/cancellation/cancellation_screen.dart`, lines 133–149](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/cancellation/cancellation_screen.dart#L133-L149): ```dart QueryObserverOptions> get _slowQuery => QueryObserverOptions>( queryKey: slowKey, queryFn: (context) { if (_ignoreSignal) { // Deliberately never reads `context.signal`: the query then has no // way to stop dio, and the core knows it. return _api.posts(delay: slowFetchDelay); } final signal = context.signal; signal.onCancel(() => _bumpDuringAnyPhase(() => _cancels += 1)); return _api.posts(signal: signal, delay: slowFetchDelay); }, // Nothing here fails on purpose, and a retry chain would only blur // what the cancel did. retry: RetryPolicy.never, ); ``` Cancelling is a client call with a filter. `revert` defaults to `true`, so the entry returns to its state from before the fetch. `silent` cancels without dispatching an error, which is meant for the case where a new fetch takes over; a silently cancelled fetch that nothing replaces still goes back to `idle`. [`examples/showcase/lib/features/cancellation/cancellation_screen.dart`, lines 153–162](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/cancellation/cancellation_screen.dart#L153-L162): ```dart /// A silent cancel dispatches no error into the query's state; the revert /// still happens, so the reader sees the entry as it was before the fetch. void _cancelSlowSilently() => _cancel(silent: true); void _cancel({required bool silent}) => _client .cancelQueries( filters: QueryFilters(queryKey: slowKey, exact: true), silent: silent, ) .ignore(); ``` Each needle is a key, and an empty box is disabled with `Enabled.no` so it asks the backend nothing. When the debounce window closes, the screen calls `cancelQueries` on the previous needle's key before it switches to the new one. [`examples/showcase/lib/features/cancellation/cancellation_screen.dart`, lines 166–177](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/cancellation/cancellation_screen.dart#L166-L177): ```dart QueryObserverOptions> _searchQuery(String needle) => QueryObserverOptions>( queryKey: searchKey(needle), // An empty box asks the backend nothing. enabled: needle.isEmpty ? Enabled.no : Enabled.yes, queryFn: (context) { final signal = context.signal; signal.onCancel(() => _bumpDuringAnyPhase(() => _searchCancels += 1)); return _api.search(needle, signal: signal, delay: searchDelay); }, retry: RetryPolicy.never, ); ```
The whole screen [`examples/showcase/lib/features/cancellation/cancellation_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/cancellation/cancellation_screen.dart): ```dart /// Cancellation: the `signal` a query function is handed, what /// `cancelQueries` does with it, and search-as-you-type as the everyday case. /// /// Mirrors upstream's [Query Cancellation /// guide](https://tanstack.com/query/latest/docs/framework/react/guides/query-cancellation) /// rather than an example app; there is no `react/cancellation` example. /// `ShowcaseApi.bridge` is the interop point the guide describes for axios: /// `context.signal.onCancel(dioToken.cancel)`. /// /// **What cancelling really does here** — checked against the core, not /// assumed: /// /// * `cancelQueries` always ends the fetch. The retryer is rejected with a /// `CancelledError`, `revert: true` (the default) puts the state back to what /// it was when the fetch started with `fetchStatus: idle`, and the fetch's /// result — whenever it arrives — is dropped. A first fetch therefore goes /// back to `status=pending`, not to an error. /// * What the `signal` changes is whether the **transport** stops. Read it and /// the HTTP request is aborted, so the backend never answers it and nothing /// is logged there. Ignore it (the `Ignore the signal` switch) and the query /// is still cancelled on the spot, but the request runs to completion at the /// backend and only its answer is thrown away. /// * The guide's "the data will still be available in the cache" is about the /// *other* cancel: the one the core does by itself when the last observer /// leaves. There, a query function that never read the signal has its retry /// loop stopped and its in-flight request left alone, so its answer is /// written; one that read the signal is cancelled with a revert. That is why /// re-keying the search below already cancels the previous needle's fetch: /// its last observer leaves and the signal was consumed. The explicit /// `cancelQueries` in the debounce callback is what makes it a decision /// rather than a side effect — and the only thing that would cancel a needle /// another reader still holds. /// /// Proofs (widget tests in `test/features/cancellation_test.dart`, end-to-end /// in `e2e/tests/cancellation.spec.ts`): cancelling the slow fetch puts it /// back to idle, counts one `signal.onCancel`, aborts the request and never /// lets its answer land; a silent cancel does the same and reports no error; /// restarting after a cancel loads all thirty posts; two keystrokes inside the /// debounce window send one request, for the last needle; a keystroke after /// the window cancels the needle in flight, leaving its entry pending and /// idle; with `Ignore the signal` on, the request is not aborted — the backend /// answers it — while the query is cancelled all the same; and leaving the /// screen mid-fetch writes that answer to the cache when the signal was /// ignored, but cancels and reverts when it was read. library; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature cancellationFeature = Feature( id: 'cancellation', title: 'Cancellation', summary: 'A query cancelled is a request aborted.', ); /// Long enough that a human can hit `Cancel` in the middle of it. const Duration slowFetchDelay = Duration(seconds: 3); /// Long enough that the next keystroke lands while the request is still out. const Duration searchDelay = Duration(seconds: 1); /// The window a keystroke waits before it becomes a needle. const Duration searchDebounce = Duration(milliseconds: 300); QueryKey get slowKey => QueryKey(const ['cancellation', 'slow']); QueryKey searchKey(String needle) => QueryKey(['cancellation', 'search', needle]); class CancellationScreen extends StatefulWidget { const CancellationScreen({super.key}); @override State createState() => _CancellationScreenState(); } class _CancellationScreenState extends State with PhaseSafeRebuild { final TextEditingController _text = TextEditingController(); late QueryClient _client; late ShowcaseApi _api; /// How often the slow query's `signal.onCancel` ran — the proof that the /// token reached dio, since it is dio's `cancel` that sits next to it. int _cancels = 0; int _searchCancels = 0; /// With this on, the query function never touches `context.signal`, which is /// what makes a fetch uncancellable at the transport. bool _ignoreSignal = false; Timer? _debounce; String _needle = ''; String? _previousNeedle; @override void didChangeDependencies() { super.didChangeDependencies(); _client = QueryClientProvider.of(context); _api = ShowcaseScope.apiOf(context); } @override void dispose() { _debounce?.cancel(); _text.dispose(); super.dispose(); } /// `onCancel` callbacks run synchronously inside the cancel, which may be a /// button's tap, a post-frame observer release, or a cache event during a /// build — so the rebuild waits for the frame to end when there is one. void _bumpDuringAnyPhase(VoidCallback change) { change(); scheduleRebuild(); } // --- the slow query ------------------------------------------------------ QueryObserverOptions> get _slowQuery => QueryObserverOptions>( queryKey: slowKey, queryFn: (context) { if (_ignoreSignal) { // Deliberately never reads `context.signal`: the query then has no // way to stop dio, and the core knows it. return _api.posts(delay: slowFetchDelay); } final signal = context.signal; signal.onCancel(() => _bumpDuringAnyPhase(() => _cancels += 1)); return _api.posts(signal: signal, delay: slowFetchDelay); }, // Nothing here fails on purpose, and a retry chain would only blur // what the cancel did. retry: RetryPolicy.never, ); void _cancelSlow() => _cancel(silent: false); /// A silent cancel dispatches no error into the query's state; the revert /// still happens, so the reader sees the entry as it was before the fetch. void _cancelSlowSilently() => _cancel(silent: true); void _cancel({required bool silent}) => _client .cancelQueries( filters: QueryFilters(queryKey: slowKey, exact: true), silent: silent, ) .ignore(); // --- search as you type -------------------------------------------------- QueryObserverOptions> _searchQuery(String needle) => QueryObserverOptions>( queryKey: searchKey(needle), // An empty box asks the backend nothing. enabled: needle.isEmpty ? Enabled.no : Enabled.yes, queryFn: (context) { final signal = context.signal; signal.onCancel(() => _bumpDuringAnyPhase(() => _searchCancels += 1)); return _api.search(needle, signal: signal, delay: searchDelay); }, retry: RetryPolicy.never, ); void _onTyped(String value) { _debounce?.cancel(); _debounce = Timer(searchDebounce, () => _commit(value.trim())); } /// The debounce window closed on [next]. The needle in flight is cancelled /// here, before the read is re-keyed: an explicit decision, at a moment the /// screen names, rather than the release the core would do anyway once the /// old key's last observer leaves in the rebuild below. void _commit(String next) { if (!mounted || next == _needle) { return; } final previous = _needle; if (previous.isNotEmpty) { _client .cancelQueries( filters: QueryFilters(queryKey: searchKey(previous), exact: true), ) .ignore(); } setState(() { _previousNeedle = previous.isEmpty ? null : previous; _needle = next; }); } // --- the screen ---------------------------------------------------------- @override Widget build(BuildContext context) { final small = Theme.of(context).textTheme.bodySmall; return FeatureScaffold( feature: cancellationFeature, children: [ _slowCard(small), QueryDebugStrip(queryKey: slowKey, label: 'slow'), _searchCard(small), QueryDebugStrip(queryKey: searchKey(_needle), label: 'search'), if (_previousNeedle case final String previous) QueryDebugStrip(queryKey: searchKey(previous), label: 'previous'), ], ); } Widget _slowCard(TextStyle? small) { // Read in build, so the card rebuilds with the query's every state. final posts = context.query(_slowQuery); return SectionCard( title: 'Cancel by hand', trailing: posts.isFetching ? const Pill('fetching') : null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'A three-second fetch of /api/posts. Cancelling reverts the entry ' 'to what it was when the fetch began and drops the answer; the ' 'signal decides whether the request itself is aborted.', style: small, ), const SizedBox(height: 12), // A row folds its buttons into one semantics node otherwise, and // each button is found by the name its label gives it. SemanticsGroup( child: Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton( onPressed: posts.isFetching ? null : () => posts.refetch().ignore(), child: const Text('Start slow fetch'), ), OutlinedButton( onPressed: _cancelSlow, child: const Text('Cancel'), ), OutlinedButton( onPressed: _cancelSlowSilently, child: const Text('Cancel silently'), ), ], ), ), // No subtitle: a tile folds one into the switch's accessible name. SwitchListTile( title: const Text('Ignore the signal'), contentPadding: EdgeInsets.zero, value: _ignoreSignal, onChanged: (value) => setState(() => _ignoreSignal = value), ), Text( 'With it on the query function never reads context.signal, so dio ' 'is never told to stop: the backend answers the request in full ' 'and the query throws the answer away.', style: small, ), const SizedBox(height: 12), FactGroup( name: 'slow facts', dense: true, facts: [ 'fetchStatus=${posts.fetchStatus.name}', 'status=${posts.status.name}', 'posts=${switch (posts.dataOrNull) { null => 'none', final List data => '${data.length}', }}', 'cancels=$_cancels', ], ), ], ), ); } Widget _searchCard(TextStyle? small) => SectionCard( title: 'Search as you type', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Every needle is a key of its own. A keystroke that closes the ' '300 ms window cancels the needle still in flight and re-keys ' 'the read, so only the last one can arrive.', style: small, ), const SizedBox(height: 12), TextField( controller: _text, onChanged: _onTyped, decoration: const InputDecoration( labelText: 'Search posts', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), QueryBuilder>( options: _searchQuery(_needle), builder: (context, results) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FactGroup( name: 'search facts', dense: true, facts: [ 'needle=${_needle.isEmpty ? 'none' : _needle}', 'searching=${results.isFetching}', 'results=${switch (results.dataOrNull) { null => 'none', final List data => '${data.length}', }}', 'searchCancels=$_searchCancels', ], ), const SizedBox(height: 8), SizedBox( height: 160, // Eager children in a scroll view: a lazy list would not // build the rows a test has to find. child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (results.dataOrNull case final List data) for (final post in data) Padding( padding: const EdgeInsets.symmetric( vertical: 2, horizontal: 4), child: Text(post.title), ), ], ), ), ), ], ), ), ], ), ); } ```
## Related - Guides: [Query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md), [Query functions](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-functions.md), [Query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md) - Tested by `test/features/cancellation_test.dart` (widget) and `e2e/tests/cancellation.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/cancellation) --- # Offline > A query and a mutation under a connection you switch off and on, compared across the three network modes, with paused mutations resuming on reconnect. A todo list and an add-todo mutation, both running under the network mode you pick, and an **Online** switch that tells the client whether it has a connection. Offline, the query pauses instead of failing and the write waits with its request unsent. When the switch goes back on, the client resumes the paused mutations first and then continues the paused fetches, with nothing on the screen asking it to. This is the shape of any app used where the signal comes and goes: a field technician logging readings from a basement, a warehouse scanner, a settings change made on a train that should reach the server once the train leaves the tunnel. Live demo: [Offline](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/offline), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/offline)). Network modes, paused mutations, and coming back online. ## What to try - Switch **Online** off and press **Refetch** under the `online` network mode: `query fetchStatus=paused`, and no request goes out. Switch it back on and the fetch continues, one request rather than two. - Offline, type a todo and press **Add todo**: `mutation isPaused=true`, `mutations paused=1`, and a notice says the write is waiting. Go online and it is sent by itself; the list refetches after it. - Add two todos while offline, then go online: both are sent, in the order they were made. - Offline, press **Resume paused mutations**: nothing moves. A mutation under `online` is skipped while the client is still offline. - Pick `always` as the network mode and refetch while offline: the request goes out regardless. The switch only changes what the client believes, and the demo's backend is always reachable, so here the request succeeds; with a real outage it would fail like any other. - With nothing paused, flip **Online** off and on under each **On reconnect** value: `ifStale` and `always` refetch the list on reconnect, `never` leaves it alone. ## The code The query takes the network mode and `refetchOnReconnect` as options. Its retry policy is written out, because `offlineFirst` is only visible through it: the first attempt runs offline, and the retry after it pauses. [`examples/showcase/lib/features/offline/offline_screen.dart`, lines 90–108](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/offline/offline_screen.dart#L90-L108): ```dart /// The list, under the mode the screen is set to. /// /// The retry policy is written out because `offlineFirst` is only visible /// through it: the first attempt runs offline, and it is the *retry* that /// pauses. A fixed, short delay keeps that pause a second away rather than /// the default backoff's one, two and four. QueryObserverOptions> todosQuery( ShowcaseApi api, NetworkMode mode, { RefetchOn onReconnect = RefetchOn.ifStale, }) => QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), networkMode: mode, retry: const RetryPolicy.times(2), retryDelay: const RetryDelay.fixed(Duration(milliseconds: 400)), refetchOnReconnect: onReconnect, ); ``` The mutation runs under the same mode. Its `onSuccess` starts the invalidation without returning it, so the mutation reports success as soon as the write reaches the backend, not when the list comes back. [`examples/showcase/lib/features/offline/offline_screen.dart`, lines 110–127](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/offline/offline_screen.dart#L110-L127): ```dart /// The write, under the same mode. `retry` stays at a mutation's default of /// never: pausing is not retrying, and a paused mutation keeps its one /// attempt for when the network is back. MutationOptions addTodoMutation( ShowcaseApi api, QueryClient client, NetworkMode mode, ) => MutationOptions.simple( mutationFn: (text) => api.createTodo(text), networkMode: mode, onSuccess: (_, __, ___) { // Fired, not returned: the mutation's status is meant to say when the // write reached the backend, and awaiting the refetch would move it // to whenever the list came back. See the library doc above. client.invalidateQueries(filters: _todosFilter).ignore(); }, ); ``` The **Online** switch calls `client.onlineManager.setOnline`, and **Resume paused mutations** calls `client.resumePausedMutations()`. The facts come straight from the client: [`examples/showcase/lib/features/offline/offline_screen.dart`, lines 195–199](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/offline/offline_screen.dart#L195-L199): ```dart final online = _client.onlineManager.isOnline(); final rows = todos.dataOrNull ?? const []; final paused = _client.mutationCache.mutations .where((entry) => entry.state.isPaused) .length; ``` In a real app you do not flip the switch by hand: pass `QueryClientProvider` an `onlineStatus` built with `OnlineStatus.stream` from the connectivity package you already use. The binding depends on none.
The whole screen [`examples/showcase/lib/features/offline/offline_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/offline/offline_screen.dart): ```dart /// Upstream's `offline` example, and the `network-mode` guide next to it: a /// query and a mutation under a connection the reader controls. /// /// The switch *is* the connectivity source here. The library installs no /// listener and depends on no connectivity package, so a client nobody /// tells otherwise believes it is online — which is what upstream does with /// no listener too. A real app hands `QueryClientProvider` an /// `OnlineStatus.stream`; this screen calls /// `client.onlineManager.setOnline` by hand, which is exactly what upstream's /// devtools "mock offline behavior" button does. /// /// Both the todos query and the add-todo mutation run under the network mode /// the segmented button picks, so the three modes can be compared on the same /// pair: /// /// * `online` — neither fetches while offline. The query goes /// `fetchStatus=paused` without sending anything, the mutation is `pending` /// with `isPaused=true`, and both continue when the connection returns. /// * `always` — connectivity is ignored: the request goes out, and retries do /// not pause. The switch only tells the library it is offline, so here the /// backend still answers; over a connection that is really down the /// request fails like any other. /// * `offlineFirst` — one attempt runs even offline (a service worker or an /// HTTP cache may answer it); a *retry* after it pauses, which is /// `retryer.dart`'s `canFetch` for the start and `_canContinue` for the /// continue. /// /// Coming back online is the library's own work, not this screen's: /// `QueryClient.mount` — which `QueryClientProvider` calls — subscribes to the /// online manager, resumes the paused mutations and only then lets the query /// cache continue its paused fetches. `Resume paused mutations` is the manual /// door onto the same call, and it is deliberately *not* a no-op-free one: /// `MutationCache.resumePaused` gates per mutation, so an `online` mutation /// asked to resume while still offline is skipped rather than parked on the /// same wait. /// /// The mutation's `onSuccess` invalidates the todos but does not return the /// invalidation's future. Returning it is the idiom the `mutations` screen /// shows, and it works here too — `invalidateQueries` refetches with /// `cancelRefetch: true`, so it replaces a paused fetch instead of waiting /// behind it — but it would keep the mutation `pending` well past the moment /// its write reached the backend, and that moment is what this screen is /// about. /// /// The reconnect is also an event of its own: `refetchOnReconnect` — the /// `On reconnect` knob, a [RefetchOn] like the focus screen's — decides /// whether a query that was *not* paused refetches when the connection /// returns. The default is `ifStale`, and the todos' stale time is zero, so /// out of the box every reconnect refetches them; `never` leaves them alone, /// and `always` would refetch fresh data too. /// /// Read through `QueryMixin` (`watchQuery`, `watchMutation`). /// /// Proofs (widget tests in `test/features/offline_test.dart`, end-to-end in /// `e2e/tests/offline.spec.ts`): offline, `Refetch` under `online` pauses the /// query and sends nothing, and going online resumes it with one request, not /// two; offline, `Add todo` pauses the mutation and sends no `POST`, and going /// online sends it without anyone asking; `Resume paused mutations` while /// still offline leaves an `online` mutation exactly where it was; under /// `always` nothing pauses; under `offlineFirst` the first attempt goes out /// offline and the retry after it pauses; two todos added offline are sent /// in the order they were made once the connection is back; and a reconnect /// with nothing paused refetches the todos under `always` and not under /// `never`. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature offlineFeature = Feature( id: 'offline', title: 'Offline', summary: 'Network modes, paused mutations, and coming back online.', upstream: 'offline', ); QueryFilters get _todosFilter => QueryFilters(queryKey: ShowcaseKeys.todos); /// The list, under the mode the screen is set to. /// /// The retry policy is written out because `offlineFirst` is only visible /// through it: the first attempt runs offline, and it is the *retry* that /// pauses. A fixed, short delay keeps that pause a second away rather than /// the default backoff's one, two and four. QueryObserverOptions> todosQuery( ShowcaseApi api, NetworkMode mode, { RefetchOn onReconnect = RefetchOn.ifStale, }) => QueryObserverOptions>( queryKey: ShowcaseKeys.todos, queryFn: (context) => api.todos(signal: context.signal), networkMode: mode, retry: const RetryPolicy.times(2), retryDelay: const RetryDelay.fixed(Duration(milliseconds: 400)), refetchOnReconnect: onReconnect, ); /// The write, under the same mode. `retry` stays at a mutation's default of /// never: pausing is not retrying, and a paused mutation keeps its one /// attempt for when the network is back. MutationOptions addTodoMutation( ShowcaseApi api, QueryClient client, NetworkMode mode, ) => MutationOptions.simple( mutationFn: (text) => api.createTodo(text), networkMode: mode, onSuccess: (_, __, ___) { // Fired, not returned: the mutation's status is meant to say when the // write reached the backend, and awaiting the refetch would move it // to whenever the list came back. See the library doc above. client.invalidateQueries(filters: _todosFilter).ignore(); }, ); class OfflineScreen extends StatefulWidget { const OfflineScreen({super.key}); @override State createState() => _OfflineScreenState(); } class _OfflineScreenState extends State with QueryMixin, PhaseSafeRebuild { late final ShowcaseApi _api; late final QueryClient _client; late final void Function() _unsubscribeOnline; late final void Function() _unsubscribeMutations; final TextEditingController _text = TextEditingController(); NetworkMode _mode = NetworkMode.online; RefetchOn _onReconnect = RefetchOn.ifStale; @override void initState() { super.initState(); // Plain reads: a State may not depend on an inherited widget yet, and // neither the api nor the client changes underneath a screen. _api = context.getInheritedWidgetOfExactType()!.api; _client = QueryClientProvider.read(context); // The online state is not part of any query's result, and a mutation the // observer no longer holds — the first of two paused writes — still // changes what `isMutating` answers. _unsubscribeOnline = _client.onlineManager.subscribe((_) => scheduleRebuild()); _unsubscribeMutations = _client.mutationCache.subscribe((event) { // State-changing events only. Every build re-applies the mutation's // options, whose callbacks are closures built in `build` and therefore // never equal, so `MutationObserverOptionsUpdated` fires once per // build — rebuilding on it is a loop that never settles. if (event is MutationUpdated || event is MutationAdded || event is MutationRemoved) { scheduleRebuild(); } }); } @override void dispose() { _unsubscribeOnline(); _unsubscribeMutations(); _text.dispose(); super.dispose(); } void _submit(void Function(String text) mutate) { final text = _text.text.trim(); if (text.isEmpty) { return; } mutate(text); _text.clear(); } @override Widget build(BuildContext context) { final todos = watchQuery(todosQuery(_api, _mode, onReconnect: _onReconnect)); final add = watchMutation(addTodoMutation(_api, _client, _mode)); final mutation = add.value; final online = _client.onlineManager.isOnline(); final rows = todos.dataOrNull ?? const []; final paused = _client.mutationCache.mutations .where((entry) => entry.state.isPaused) .length; return FeatureScaffold( feature: offlineFeature, children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Notice( 'Nothing installs a connectivity listener — the binding depends on ' 'no connectivity package — so a client nobody tells otherwise ' 'believes it is online, and this switch is the whole source of ' 'the online state here. A real app passes ' 'QueryClientProvider(onlineStatus: OnlineStatus.stream(…, ' 'initial: …)); six lines with connectivity_plus, which stays ' 'your dependency.', ), ), SectionCard( title: 'Connection', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // No subtitle: a tile folds one into the switch's accessible // name, and the explanation belongs in its own text anyway. SwitchListTile( dense: true, contentPadding: EdgeInsets.zero, title: const Text('Online'), value: online, onChanged: _client.onlineManager.setOnline, ), const Text( 'Turning this back on is all the reconnect there is: the ' 'client is mounted, so it resumes the paused mutations and ' 'then continues the paused fetches by itself.', ), const SizedBox(height: 12), const Text('Network mode'), const SizedBox(height: 4), // Each segmented button in a named semantics group: this one // and the reconnect knob both have an `always`. _Knob( name: 'network-mode', segments: const >[ ButtonSegment( value: NetworkMode.online, label: Text('online'), ), ButtonSegment( value: NetworkMode.always, label: Text('always'), ), ButtonSegment( value: NetworkMode.offlineFirst, label: Text('offlineFirst'), ), ], selected: _mode, onChanged: (value) => setState(() => _mode = value), ), const SizedBox(height: 12), const Text('On reconnect'), const SizedBox(height: 4), _Knob( name: 'on-reconnect', segments: const >[ ButtonSegment( value: RefetchOn.never, label: Text('never'), ), ButtonSegment( value: RefetchOn.ifStale, label: Text('ifStale'), ), ButtonSegment( value: RefetchOn.always, label: Text('always'), ), ], selected: _onReconnect, onChanged: (value) => setState(() => _onReconnect = value), ), const SizedBox(height: 4), const Text( 'refetchOnReconnect: what a query that was not paused does ' 'when the connection returns. ifStale is the default, and the ' 'todos are stale the moment they arrive, so out of the box ' 'every reconnect refetches them; never leaves them alone.', ), const SizedBox(height: 12), Toolbar( children: [ ActionButton( label: 'Refetch', filled: true, onPressed: () => todos.refetch().ignore(), ), ActionButton( label: 'Resume paused mutations', onPressed: () => _client.resumePausedMutations().ignore(), ), ], ), const SizedBox(height: 8), FactGroup( name: 'facts offline', facts: [ 'online=$online', 'query fetchStatus=${todos.fetchStatus.name}', 'mutation status=${mutation.status.name}', 'mutation isPaused=${mutation.isPaused}', 'mutations pending=${_client.isMutating()}', 'mutations paused=$paused', 'todos=${rows.length}', ], ), const SizedBox(height: 8), const Text( 'Resume paused mutations gates per mutation: one under the ' 'online mode is skipped while the device is offline, because ' 'resuming it would only park it on the same wait.', ), ], ), ), QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'), SectionCard( title: 'Add todo', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _text, decoration: const InputDecoration( labelText: 'New todo', border: OutlineInputBorder(), ), onSubmitted: (_) => _submit(add.mutate), ), const SizedBox(height: 12), Toolbar( children: [ ActionButton( label: 'Add todo', filled: true, onPressed: () => _submit(add.mutate), ), ], ), const SizedBox(height: 8), if (mutation.isPaused) const Notice( 'The write is waiting for the network. Its request has not ' 'been sent, and it goes out as it stands the moment the ' 'connection is back.', ) else if (mutation case MutationError(:final error)) Notice('Write failed: $error', error: true), const SizedBox(height: 8), const Text( 'Every write goes to the same mutation, so adding twice while ' 'offline leaves two paused mutations in the cache — the ' 'observer holds the second, the count holds both.', ), ], ), ), SectionCard( title: 'Todos', trailing: todos.isFetching ? const Pill('fetching') : null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (todos case QueryError(:final error)) Notice('$error', error: true), for (final todo in rows) Text('#${todo.id} ${todo.text}'), ], ), ), SectionCard( title: 'The three network modes', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: const [ Notice( 'online — the default: nothing is sent while offline, the ' 'query pauses and the mutation pauses, and both continue when ' 'the connection returns.', ), SizedBox(height: 8), Notice( 'always — connectivity is ignored: the request goes out even ' 'offline, and retries never pause. The switch only tells the ' 'library it is offline, so here the backend still answers; ' 'over a connection that is really down the request fails ' 'like any other.', ), SizedBox(height: 8), Notice( 'offlineFirst — one attempt runs even offline, for a service ' 'worker or an HTTP cache that can answer it; a retry after ' 'that one pauses, like online.', ), ], ), ), ], ); } } /// A segmented button in a named semantics group, so a test can pick this /// knob's `always` apart from another's. class _Knob extends StatelessWidget { const _Knob({ required this.name, required this.segments, required this.selected, required this.onChanged, }); final String name; final List> segments; final T selected; final ValueChanged onChanged; @override Widget build(BuildContext context) => SemanticsGroup( name: name, child: SegmentedButton( showSelectedIcon: false, segments: segments, selected: {selected}, onSelectionChanged: (selection) => onChanged(selection.first), ), ); } ```
## Related - Guides: [Network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md), [Connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md), [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) - Upstream: TanStack's React [`offline`](https://github.com/TanStack/query/tree/main/examples/react/offline) example - Tested by `test/features/offline_test.dart` (widget) and `e2e/tests/offline.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/offline) --- # Focus refetch > Every RefetchOn value for app focus and for mount, a minimum background time before a return refetches, and the provider's own focus and connectivity settings. Three entries sit side by side under different policies (A and B on the server time, C on a counter of its own), so one return to the foreground gets a different answer from each. Entry A turns `refetchOnWindowFocus` through `never`, `ifStale`, `always` and a `RefetchOn.when` rule; entry B adds `refetchOnMount`, with a reader you can detach and attach again; entry C runs on a client of its own, under a nested `QueryClientProvider.create`, so its focus manager can carry a `refetchMinBackgroundDuration`. This is the choice every screen with live data makes: an account balance or a device list that should be current the moment the user comes back, a settings form that must never be refetched under the user's cursor, a dashboard that should skip the refetch when the user only glanced at a notification. Live demo: [Focus refetch](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/focus-refetch), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/focus_refetch)). What happens when the app comes back to the foreground. ## What to try - Clicking outside the demo, or switching browser tabs, is a real focus change here too: Flutter on the web reports it as a lifecycle change. The *App focused* switch is the dependable way to cycle it: turning it off and on again calls `client.focusManager.setFocused(false)` and `(true)`, which is what `QueryClientProvider` does on a lifecycle change. With the default `ifStale` and a 30-second stale time nothing refetches within the first 30 seconds, and both strips keep `fetches=1`. - Set entry A's *On focus* to `always` and cycle the switch: entry A's `serial` goes up and its strip reads `fetches=2`, while entry B, still on `ifStale` with fresh data, stays where it was. Set *Stale time* to `0` and `ifStale` refetches too. `when` refetches only once the data is older than ten seconds, whatever the stale time says. - On entry B, press *Detach entry B* and then *Attach entry B*: a new reader is a mount, so *On mount* decides. `always` fetches, `never` does not, and `ifStale` fetches only once the data is stale. - On entry C, set *Min background* to `long` (an hour) and cycle the *Entry C focused* switch: the return reads `shouldRefetchOnFocus=false` and its `fetches` count does not move. Under `none` the same cycle refetches. - Set *Initial online status* to `offline`: entry C mounts on a new client with `fetchStatus=paused` and sends nothing until you turn *Entry C online* on. The `nearest=` facts show which provider `QueryClientProvider.maybeOf` finds: the app's on the screen, entry C's under the nested provider. ## The code Each entry's options take their policies from the screen's knobs. The `when` choice is a top-level function wrapped in a `const RefetchOn.when`, so it compares equal from one build to the next. [`examples/showcase/lib/features/focus_refetch/focus_refetch_screen.dart`, lines 165–195](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/focus_refetch/focus_refetch_screen.dart#L165-L195): ```dart /// The `RefetchOn.when` on offer. Compared by the identity of its function, so /// the function is a top-level one and the value a `const`: the segmented /// button finds it selected again on every build. const RefetchOn _whenOlderThanTenSeconds = RefetchOn.when(_olderThanTenSeconds); /// Refetch on focus only while the data is older than ten seconds. /// /// [Query.isStaleByTime] answers exactly that question against the library's /// own clock, and it is independent of the `staleTime` the query runs under — /// which is the point of a `when`: a rule of its own, not the stale time /// again. RefetchOn _olderThanTenSeconds(Query query) => query.isStaleByTime(const StaleTime.duration(Duration(seconds: 10))) ? RefetchOn.always : RefetchOn.never; /// One entry's query: the same server time, under the knobs the screen turns. QueryObserverOptions focusTimeQuery( ShowcaseApi api, { required QueryKey queryKey, required RefetchOn onFocus, required RefetchOn onMount, required StaleTime staleTime, }) => QueryObserverOptions( queryKey: queryKey, queryFn: (context) => api.time(signal: context.signal), staleTime: staleTime, refetchOnWindowFocus: onFocus, refetchOnMount: onMount, ); ``` A minimum background time belongs to the focus manager, and a focus manager is given to a client when the client is built. So entry C builds its own: [`examples/showcase/lib/features/focus_refetch/focus_refetch_screen.dart`, lines 302–313](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/focus_refetch/focus_refetch_screen.dart#L302-L313): ```dart /// A client whose focus manager carries [minBackground] — what the nested /// provider's `create` builds, once per key. /// /// Focused up front: the nested provider's mount reads the app's current /// lifecycle state and sets the focus from it, and a manager that starts /// with no opinion would count that as a change and raise a focus event /// nobody asked for. QueryClient _newThresholdClient(Duration minBackground) => QueryClient( focusManager: AppFocusManager( refetchMinBackgroundDuration: minBackground, )..setFocused(true), ); ``` The nested provider creates that client, owns it and clears it when it unmounts. A new `key` is a new client, which is how the threshold and the initial online status are swapped; `isAppShown` is applied to the client as it stands. [`examples/showcase/lib/features/focus_refetch/focus_refetch_screen.dart`, lines 626–639](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/focus_refetch/focus_refetch_screen.dart#L626-L639): ```dart QueryClientProvider.create( // The threshold and the initial online status are read at // the client's construction and mount: a change of either is // a new key, so a new client. The `isAppShown` mapping is // not: it reaches the client as it stands. key: ValueKey( 'entry-c ${_minBackground.inSeconds} ' '${_initialOnline ? 'online' : 'offline'}', ), create: () => _newThresholdClient(_minBackground), onlineStatus: OnlineStatus.fixed(_initialOnline), isAppShown: isAppShownFor(_inactiveRule), child: _ThresholdEntry(api: _api, appClient: _client), ), ```
The whole screen [`examples/showcase/lib/features/focus_refetch/focus_refetch_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/focus_refetch/focus_refetch_screen.dart): ```dart /// `refetchOnWindowFocus` and `refetchOnMount`, on two entries of the server /// time (`GET /api/time`, whose `serial` grows by one per call, so a refetch /// shows as a number and not as a clock) that hold the same data under two /// different policies. /// /// Entry A is a `QueryController` created in `initState` and read through a /// `ListenableBuilder`, so turning a knob reaches a live reader through /// `setOptions`. Entry B is a `QueryBuilder` that can be detached and /// re-attached, because a fresh observer is a mount and that is when /// `refetchOnMount` decides. /// /// Where focus comes from: in a real app `QueryClientProvider` installs an /// `AppLifecycleListener` and maps every `AppLifecycleState` onto /// `client.focusManager.setFocused(...)` — `resumed` is focused, `hidden`, /// `paused` and `detached` are not, and `inactive` depends on the platform: /// an interruption that counts as focused on iOS, Android and Fuchsia, the /// window losing focus — unfocused — on macOS, Windows and Linux (the /// provider's class doc has the reasoning; `isAppShown` overrides it). A /// headless browser raises no lifecycle transition of its own, so the /// screen's own `App focused` switch calls `setFocused` directly and *is* /// the focus source most of the tests drive — a `blur` dispatched on the /// window is one, though, and entry C's `isAppShown` proof uses it. /// /// Entry C is the port's own `AppFocusManager(refetchMinBackgroundDuration:)`: /// a return to the foreground after an absence *shorter* than the threshold /// raises the focus event but suppresses the new refetches it would have /// started; paused work still resumes, and reconnect refetching is never /// touched. The threshold belongs to the focus manager, and a manager belongs /// to a client at construction — the app's client is built in `main` before /// any screen exists, so it cannot be given one afterwards. Entry C therefore /// runs on a client of its own under a nested `QueryClientProvider.create`: /// the provider builds the client, owns it, and clears it when it unmounts, /// and a different `key` is a different client — which is how the threshold /// knob and the `OnlineStatus.fixed` knob swap it. Because the client is its /// own, so is the cache: entry C counts its own fetches off /// `queryCache.subscribe` rather than through the app's `QueryDebugStrip`, /// which reads the app client's counters. /// /// The same nested provider carries the two other things a provider decides. /// `isAppShown` is the mapping from lifecycle states to focus: `platform` is /// the built-in one, `shown` and `hidden` say what `inactive` means outright, /// and the mapping given on the latest build is the one in force, without a /// new client. `onlineStatus` is the connectivity the provider brings, as one /// value: entry C has no stream, so it passes an `OnlineStatus.fixed` — /// `offline` mounts entry C paused, and its own `Entry C online` switch is /// the source that lets the fetch continue. /// And `QueryClientProvider.maybeOf` — `of` for a widget that can do without /// a provider — answers with the nearest one: the app's client on the screen, /// entry C's under the nested provider, as the `nearest=` facts show. /// /// `shouldRefetchOnFocus` reports what the *most recent* focus notification /// permitted, so it is read right after a return: it is `true` again as soon /// as the app leaves the foreground. /// /// Port-specific; it illustrates upstream's *Window Focus Refetching* guide. /// `RefetchOn.when` is upstream's `(query) => boolean | 'always'`, and the /// rule shown here is "refetch on focus only while the data is older than /// ten seconds" — `query.isStaleByTime`, which reads the clock the library /// reads, so it is right under a widget test's fake one too. /// /// Proofs (widget tests in `test/features/focus_refetch_test.dart`, end-to-end /// in `e2e/tests/focus_refetch.spec.ts`): `always` refetches on focus although /// the data is fresh; `never` refetches on nothing, stale data included; /// `ifStale` waits for the data to go stale, by the clock or by the stale-time /// knob; `when` skips a focus five seconds in and takes one eleven seconds in; /// detaching and re-attaching entry B fetches under `always`, not under /// `never`, and under `ifStale` only once the data is stale; one focus /// event reaches both entries, each answering with its own knob; and, on /// entry C, a full absence-and-return under the `long` threshold leaves /// `shouldRefetchOnFocus=false` with its fetch count unmoved, the same /// sequence under `none` refetches, and an `inactive`-only blip — which maps /// to focused on the test binding's default platform, Android, so it is no /// absence at all — changes nothing under either; under `isAppShown` /// `hidden` the same blip *is* an absence and refetches, under `shown` it is /// not, whatever the platform; `OnlineStatus.fixed(false)` mounts entry C /// with its fetch paused and no request sent until its switch puts it online; /// and `maybeOf` names the app's client on the screen and entry C's below. /// The threshold is measured with `package:clock`, which under a widget test /// is real time, not pumped time: the tests use an hour, so every absence /// they stage is short, and `Duration.zero` for the other side. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature focusRefetchFeature = Feature( id: 'focus-refetch', title: 'Focus refetch', summary: 'What happens when the app comes back to the foreground.', ); /// The two entries. Separate keys, so the two policies sit side by side on the /// same data rather than sharing one cache entry — a query has one state, and /// the first observer that wants a focus refetch would fetch for both. QueryKey get focusKeyA => QueryKey(const ['focus', 'a']); QueryKey get focusKeyB => QueryKey(const ['focus', 'b']); /// Entry C's key. It lives in a client of its own, so it could reuse either /// of the two above; a key of its own keeps the cache inspector honest. QueryKey get focusKeyC => QueryKey(const ['focus', 'c']); /// Entry C's `refetchMinBackgroundDuration` choices. /// /// An hour rather than a handful of seconds: the threshold is measured with /// `package:clock`, which is wall-clock time even under a widget test's fake /// async, so "short" has to mean "shorter than any absence a test or a /// visitor can stage". [Duration.zero] is the library's default and upstream's /// behaviour — every return refetches. const Duration longBackground = Duration(hours: 1); /// What `inactive` means: the `isAppShown` knob's choices. [platform] is the /// provider's built-in mapping, which reads `inactive` per platform; the /// other two say it outright. enum InactiveRule { platform, shown, hidden } /// `inactive` counts as the app being looked at — a phone's notification /// shade or an incoming call. A top-level function, like the retry and focus /// predicates: the provider re-applies the mapping whenever the function /// differs from the last build's, and a closure would differ every build. bool inactiveIsShown(AppLifecycleState state) => state == AppLifecycleState.resumed || state == AppLifecycleState.inactive; /// Only `resumed` is the app being looked at: `inactive` is an absence, as /// the window losing focus is on a desktop. bool onlyResumedIsShown(AppLifecycleState state) => state == AppLifecycleState.resumed; /// The mapping a rule stands for; `null` is the provider's own. bool Function(AppLifecycleState state)? isAppShownFor(InactiveRule rule) => switch (rule) { InactiveRule.platform => null, InactiveRule.shown => inactiveIsShown, InactiveRule.hidden => onlyResumedIsShown, }; /// Entry C's query, in its own client. `always`, so nothing but the threshold /// can decide whether a return to the foreground refetches. /// /// It reads `GET /api/counter`, not `/api/time` like the other two, on /// purpose: entries A and B are counted through the request log, and a third /// entry that mounts with the screen would move every one of those totals. /// The reading itself is not the point here — `fetches` and /// `shouldRefetchOnFocus` are. QueryObserverOptions thresholdCounterQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: focusKeyC, queryFn: (context) => api.counter(signal: context.signal), staleTime: StaleTime.zero, refetchOnWindowFocus: RefetchOn.always, ); const StaleTime _thirtySeconds = StaleTime.duration(Duration(seconds: 30)); /// The `RefetchOn.when` on offer. Compared by the identity of its function, so /// the function is a top-level one and the value a `const`: the segmented /// button finds it selected again on every build. const RefetchOn _whenOlderThanTenSeconds = RefetchOn.when(_olderThanTenSeconds); /// Refetch on focus only while the data is older than ten seconds. /// /// [Query.isStaleByTime] answers exactly that question against the library's /// own clock, and it is independent of the `staleTime` the query runs under — /// which is the point of a `when`: a rule of its own, not the stale time /// again. RefetchOn _olderThanTenSeconds(Query query) => query.isStaleByTime(const StaleTime.duration(Duration(seconds: 10))) ? RefetchOn.always : RefetchOn.never; /// One entry's query: the same server time, under the knobs the screen turns. QueryObserverOptions focusTimeQuery( ShowcaseApi api, { required QueryKey queryKey, required RefetchOn onFocus, required RefetchOn onMount, required StaleTime staleTime, }) => QueryObserverOptions( queryKey: queryKey, queryFn: (context) => api.time(signal: context.signal), staleTime: staleTime, refetchOnWindowFocus: onFocus, refetchOnMount: onMount, ); class FocusRefetchScreen extends StatefulWidget { const FocusRefetchScreen({super.key}); @override State createState() => _FocusRefetchScreenState(); } class _FocusRefetchScreenState extends State with PhaseSafeRebuild { static const List<(String, RefetchOn)> _onFocusChoices = <(String, RefetchOn)>[ ('never', RefetchOn.never), ('ifStale', RefetchOn.ifStale), ('always', RefetchOn.always), ('when', _whenOlderThanTenSeconds), ]; static const List<(String, RefetchOn)> _onMountChoices = <(String, RefetchOn)>[ ('never', RefetchOn.never), ('ifStale', RefetchOn.ifStale), ('always', RefetchOn.always), ]; static const List<(String, StaleTime)> _staleTimes = <(String, StaleTime)>[ ('0', StaleTime.zero), ('30 s', _thirtySeconds), ]; // The library's own defaults for both events, so the screen starts by // showing what a query does when nobody says otherwise. RefetchOn _onFocusA = RefetchOn.ifStale; RefetchOn _onFocusB = RefetchOn.ifStale; RefetchOn _onMountB = RefetchOn.ifStale; // Thirty seconds rather than zero, so the first frame already shows fresh // data: with a zero stale time every policy but `never` looks the same. StaleTime _staleTime = _thirtySeconds; static const List<(String, Duration)> _minBackgrounds = <(String, Duration)>[ ('none', Duration.zero), ('long', longBackground), ]; static const List<(String, InactiveRule)> _inactiveRules = <(String, InactiveRule)>[ ('platform', InactiveRule.platform), ('shown', InactiveRule.shown), ('hidden', InactiveRule.hidden), ]; static const List<(String, bool)> _initialOnlineChoices = <(String, bool)>[ ('online', true), ('offline', false), ]; bool _attachedB = true; /// Entry C's threshold. A manager takes its threshold at construction, so /// a new threshold is a new manager, a new manager is a new client — and /// the nested provider's key carries it, so the provider builds one. Duration _minBackground = Duration.zero; /// Entry C's `isAppShown`. Not in the provider's key: a mapping given on a /// later build is applied to the client as it stands. InactiveRule _inactiveRule = InactiveRule.platform; /// Entry C's `OnlineStatus.fixed`. In the key: the point on show is what a /// client assumes at its *mount*, so showing it again means a new client. /// (A fixed status reaches a client on a later build too — see the /// provider's dartdoc; the key is what makes this a mount every time.) bool _initialOnline = true; late final ShowcaseApi _api; late final QueryClient _client; bool _initialised = false; QueryController? _readerA; void Function()? _unsubscribeFocus; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialised) { _initialised = true; _api = ShowcaseScope.apiOf(context); _client = QueryClientProvider.of(context); _readerA = QueryController.create(_client, _optionsA); // The switch shows what the focus manager holds, not what this screen // last set: a test that calls `setFocused` straight on the client, or a // real lifecycle transition, moves it too. _unsubscribeFocus = _client.focusManager.subscribe((_) => scheduleRebuild()); } } @override void dispose() { _unsubscribeFocus?.call(); _readerA?.dispose(); // Entry C's client is the nested provider's to clear: it goes with the // tree, after the observers under it — which is what `create` is for. super.dispose(); } /// A client whose focus manager carries [minBackground] — what the nested /// provider's `create` builds, once per key. /// /// Focused up front: the nested provider's mount reads the app's current /// lifecycle state and sets the focus from it, and a manager that starts /// with no opinion would count that as a change and raise a focus event /// nobody asked for. QueryClient _newThresholdClient(Duration minBackground) => QueryClient( focusManager: AppFocusManager( refetchMinBackgroundDuration: minBackground, )..setFocused(true), ); QueryObserverOptions get _optionsA => focusTimeQuery( _api, queryKey: focusKeyA, onFocus: _onFocusA, // Entry A is never detached, so its mount policy never comes up; the // default is left in place rather than made a knob nobody turns. onMount: RefetchOn.ifStale, staleTime: _staleTime, ); QueryObserverOptions get _optionsB => focusTimeQuery( _api, queryKey: focusKeyB, onFocus: _onFocusB, onMount: _onMountB, staleTime: _staleTime, ); /// A changed knob reaches entry A's live reader through `setOptions`; entry /// B's builder re-applies its options on the rebuild this schedules. void _applyOptions() { setState(() { _readerA?.setOptions(_optionsA); }); } /// The focus source of this screen. In an app the `AppLifecycleListener` /// inside `QueryClientProvider` calls this; here the switch does. void _setFocused(bool focused) { _client.focusManager.setFocused(focused); } Widget _reading( BuildContext context, { required String group, required QueryResult result, required List extraFacts, }) => _Reading( group: group, facts: [ ...extraFacts, if (result.dataOrNull case final ServerTime data) 'serial=${data.serial}', 'isStale=${result.isStale}', ], child: switch (result) { QueryPending() => const SkeletonBox(width: 200), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Row( children: [ Expanded( child: Text( 'Server clock ${hhmmss(data.now)}', style: Theme.of(context).textTheme.titleMedium, ), ), if (result.isFetching) const Pill('refreshing'), ], ), }, ); @override Widget build(BuildContext context) { final small = Theme.of(context).textTheme.bodySmall; final readerA = _readerA; return FeatureScaffold( feature: focusRefetchFeature, children: [ SectionCard( title: 'App focus', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'In a real app QueryClientProvider installs an ' 'AppLifecycleListener and maps every state onto the focus ' 'manager: resumed is focused, hidden, paused and detached ' 'are not, and inactive is focused on a phone (an ' 'interruption) but not on a desktop (the window lost ' 'focus). A headless browser reports no such ' 'transition, so this switch calls ' 'client.focusManager.setFocused(false) and (true) itself, and ' 'is the focus source the tests drive.', style: small, ), SwitchListTile( key: const ValueKey('app-focused'), contentPadding: EdgeInsets.zero, title: const Text('App focused'), value: _client.focusManager.isFocused(), onChanged: _setFocused, ), FactGroup( name: 'focus-state', dense: true, facts: [ 'focused=${_client.focusManager.isFocused()}', // `maybeOf` on the screen's own context: the app's provider // is the nearest one here. 'nearest=${_nearest(context, appClient: _client)}', ], ), const Divider(height: 24), knob( context, title: 'Stale time', name: 'stale-time', choices: _staleTimes, selected: _staleTime, onChanged: (value) { _staleTime = value; _applyOptions(); }, ), const SizedBox(height: 4), Text( 'Shared by both entries: it decides what "stale" means for ' 'ifStale, on focus and on mount alike.', style: small, ), ], ), ), SectionCard( title: 'Entry A · QueryController', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (readerA != null) ListenableBuilder( listenable: readerA, builder: (context, _) => _reading( context, group: 'reader-a', result: readerA.value, extraFacts: const [], ), ), const Divider(height: 24), knob( context, title: 'On focus', name: 'on-focus-a', choices: _onFocusChoices, selected: _onFocusA, onChanged: (value) { _onFocusA = value; _applyOptions(); }, ), const SizedBox(height: 4), Text( 'when: refetch on focus only while the data is older than ' '10 s — a rule of its own, whatever the stale time says.', style: small, ), ], ), ), QueryDebugStrip(queryKey: focusKeyA, label: 'focus-a'), SectionCard( title: 'Entry B · QueryBuilder', trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Detach entry B', onPressed: _attachedB ? () => setState(() => _attachedB = false) : null, icon: const Icon(Icons.visibility_off), ), IconButton( tooltip: 'Attach entry B', onPressed: _attachedB ? null : () => setState(() => _attachedB = true), icon: const Icon(Icons.visibility), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!_attachedB) const _Reading( group: 'reader-b', facts: ['reader=detached'], child: Text( 'No reader: the entry keeps its data, and attaching one ' 'again is a mount.', ), ) else QueryBuilder( options: _optionsB, builder: (context, result) => _reading( context, group: 'reader-b', result: result, extraFacts: const ['reader=attached'], ), ), const Divider(height: 24), knob( context, title: 'On focus', name: 'on-focus-b', choices: _onFocusChoices, selected: _onFocusB, onChanged: (value) { _onFocusB = value; _applyOptions(); }, ), const SizedBox(height: 8), knob( context, title: 'On mount', name: 'on-mount-b', choices: _onMountChoices, selected: _onMountB, onChanged: (value) { _onMountB = value; _applyOptions(); }, ), const SizedBox(height: 4), Text( 'Detach and attach again to see it: a new observer is a ' 'mount, and the entry keeps its data while nobody reads it.', style: small, ), ], ), ), QueryDebugStrip(queryKey: focusKeyB, label: 'focus-b'), SectionCard( title: 'Entry C · a nested provider of its own', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'A focus manager takes refetchMinBackgroundDuration at ' 'construction, and the app\'s client is built before any ' 'screen exists — so this entry runs on a client of its own, ' 'under a nested QueryClientProvider.create: the provider ' 'builds the client, owns it and clears it when it goes, and ' 'a different key is a different client. Its query is always, ' 'so nothing but the threshold decides. none is the library ' 'default: every return refetches. long is an hour, so every ' 'absence you can stage here is short, the return raises the ' 'focus event with shouldRefetchOnFocus=false, and no new ' 'refetch starts. Paused work would resume either way, and ' 'reconnect refetching is never touched.', style: small, ), const SizedBox(height: 12), knob( context, title: 'Min background', name: 'min-background', choices: _minBackgrounds, selected: _minBackground, onChanged: (value) => setState(() => _minBackground = value), ), const SizedBox(height: 12), knob( context, title: 'Inactive is', name: 'inactive-is', choices: _inactiveRules, selected: _inactiveRule, onChanged: (value) => setState(() => _inactiveRule = value), ), const SizedBox(height: 4), Text( 'isAppShown: which lifecycle states count as the app being ' 'looked at. platform is the built-in mapping — inactive is ' 'shown on a phone, hidden on a desktop. shown and hidden say ' 'it outright: under hidden a notification shade, or a ' 'browser window losing focus, is an absence like any other. ' 'The mapping on the latest build is the one in force; the ' 'client stays.', style: small, ), const SizedBox(height: 12), knob( context, title: 'Initial online status', name: 'initial-online', choices: _initialOnlineChoices, selected: _initialOnline, onChanged: (value) => setState(() => _initialOnline = value), ), const SizedBox(height: 4), Text( 'onlineStatus: what the client assumes before any ' 'connectivity source has spoken — OnlineStatus.fixed here, ' 'since entry C has no stream; online, unless told ' 'otherwise. offline mounts a new entry C with its first fetch ' 'paused and nothing sent; the Entry C online switch below is ' 'its connectivity source, and turning it on lets the fetch ' 'continue.', style: small, ), const SizedBox(height: 12), QueryClientProvider.create( // The threshold and the initial online status are read at // the client's construction and mount: a change of either is // a new key, so a new client. The `isAppShown` mapping is // not: it reaches the client as it stands. key: ValueKey( 'entry-c ${_minBackground.inSeconds} ' '${_initialOnline ? 'online' : 'offline'}', ), create: () => _newThresholdClient(_minBackground), onlineStatus: OnlineStatus.fixed(_initialOnline), isAppShown: isAppShownFor(_inactiveRule), child: _ThresholdEntry(api: _api, appClient: _client), ), ], ), ), ], ); } } /// Which provider is nearest to [context], named: the app's client, the /// nested one, or none at all — `QueryClientProvider.maybeOf`, the lookup for /// a widget that can do without a provider, where `of` would throw. String _nearest(BuildContext context, {required QueryClient appClient}) { final nearest = QueryClientProvider.maybeOf(context); if (nearest == null) { return 'none'; } return identical(nearest, appClient) ? 'app' : 'entry C'; } /// Entry C: one query on a client of its own, so the focus manager under it /// can carry a `refetchMinBackgroundDuration`. /// /// It counts its own fetches — the app's `CacheStats`, which every /// `QueryDebugStrip` reads, listens to the app's client and would show zero /// for this one — and rebuilds on its client's focus and online events, so /// `shouldRefetchOnFocus` is what the last notification actually permitted /// and `online=` is what the client believes right now. class _ThresholdEntry extends StatefulWidget { const _ThresholdEntry({required this.api, required this.appClient}); final ShowcaseApi api; /// The app's client, so the `nearest=` fact can tell it from this entry's. final QueryClient appClient; @override State<_ThresholdEntry> createState() => _ThresholdEntryState(); } class _ThresholdEntryState extends State<_ThresholdEntry> with PhaseSafeRebuild<_ThresholdEntry> { QueryClient? _client; void Function()? _unsubscribeFocus; void Function()? _unsubscribeOnline; void Function()? _unsubscribeCache; int _fetches = 0; @override void didChangeDependencies() { super.didChangeDependencies(); final client = QueryClientProvider.of(context); if (client == _client) { return; } _unsubscribe(); _client = client; // A new client is a new cache: the count starts again with it, which is // what makes the knob's two sides comparable. _fetches = 0; _unsubscribeFocus = client.focusManager.subscribe((_) => scheduleRebuild()); _unsubscribeOnline = client.onlineManager.subscribe((_) => scheduleRebuild()); _unsubscribeCache = client.queryCache.subscribe(_onCacheEvent); } void _unsubscribe() { _unsubscribeFocus?.call(); _unsubscribeFocus = null; _unsubscribeOnline?.call(); _unsubscribeOnline = null; _unsubscribeCache?.call(); _unsubscribeCache = null; } @override void dispose() { _unsubscribe(); super.dispose(); } /// Only the state-changing events. Every build re-applies the options and /// an inline `queryFn` is never equal, so rebuilding on /// `QueryObserverOptionsUpdated` would feed itself. void _onCacheEvent(QueryCacheEvent event) { if (event is! QueryUpdated) { return; } if (event.action is QueryFetchAction) { _fetches++; } scheduleRebuild(); } @override Widget build(BuildContext context) { final client = _client!; final focus = client.focusManager; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SwitchListTile( key: const ValueKey('app-focused-c'), contentPadding: EdgeInsets.zero, title: const Text('Entry C focused'), value: focus.isFocused(), onChanged: focus.setFocused, ), // This client's own connectivity source, like the offline screen's // switch is the app client's; the app's online state is untouched. SwitchListTile( key: const ValueKey('entry-c-online'), contentPadding: EdgeInsets.zero, title: const Text('Entry C online'), value: client.onlineManager.isOnline(), onChanged: client.onlineManager.setOnline, ), QueryBuilder( options: thresholdCounterQuery(widget.api), builder: (context, result) => _Reading( group: 'reader-c', facts: [ 'focused=${focus.isFocused()}', 'shouldRefetchOnFocus=${focus.shouldRefetchOnFocus}', 'fetches=$_fetches', 'online=${client.onlineManager.isOnline()}', 'fetchStatus=${result.fetchStatus.name}', 'nearest=${_nearest(context, appClient: widget.appClient)}', ], child: switch (result) { QueryPending() => const SkeletonBox(width: 200), QueryError(:final error, staleData: null) => Notice('$error', error: true), QuerySuccess(:final data) || QueryError(staleData: final data!) => Text('Server counter $data'), }, ), ), ], ); } } /// What one entry shows, with its own facts underneath. class _Reading extends StatelessWidget { const _Reading({ required this.group, required this.facts, required this.child, }); final String group; final List facts; final Widget child; @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ child, const SizedBox(height: 8), FactGroup(name: group, facts: facts, dense: true), ], ); } ```
## Related - Guides: [App focus refetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md), [Connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md), [Important defaults](https://dualmeta-gmbh.github.io/query_kit/docs/important-defaults.md) - Tested by `test/features/focus_refetch_test.dart` (widget) and `e2e/tests/focus_refetch.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/focus_refetch) --- # Filtering rebuilds > buildWhen on the eight keyless reads, each read twice over one entry, filtered and plain, with build counters side by side. Every keyless read in the binding, `watchQuery`, `context.query`, the two select reads, the two infinite reads and the two mutation reads, appears here twice over the same cache entry: once with a `buildWhen` predicate and once without. Each row counts its own builds, so the effect of the predicate is the gap between two numbers on one screen. A knob picks the predicate the filtered half passes: rebuild only when the data changed, never, or always. You would reach for `buildWhen` where a widget is expensive to build and has no use for the fetching flag: a chart over a sensor's readings that should not redraw when a background refetch starts, a large table of orders, a map with many markers. Live demo: [Filtering rebuilds](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/build-when), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/build_when)). buildWhen on the eight keyless reads, each beside its unfiltered twin. ## What to try - Press *Refetch the posts*. The backend answers the same posts, so the data is unchanged: every *no buildWhen* row goes up by two (the switch to fetching and the landing), and every *buildWhen data* row stays where it was. The `posts` debug strip shows `fetches=2`. - Press *Drop a post*. It writes a shorter list straight into the cache, so the data changed and all eight query rows go up by one, filtered ones included; they read `posts=29` or `count=29`. - Press *Load next* in the infinite card. The plain rows go up by two, the filtered rows by one: the fetch starting changes only `fetchStatus`, the new page is a real change. - Press *Run the mutation*. The plain mutation rows go up by two (pending, then success) and the filtered ones by one, since the pending result carries no data. - Set the *buildWhen* knob to *never* and refetch: the filtered half stays frozen. Set it to *always* and the two halves move together. Moving the knob itself adds one build to every row, because a new widget from the parent is not a notification a predicate can refuse. ## The code The predicates are top-level functions, so each read receives the same function object on every build. A `BuildWhen` takes the previous and the current result and returns whether to rebuild. [`examples/showcase/lib/features/build_when/build_when_screen.dart`, lines 111–133](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/build_when/build_when_screen.dart#L111-L133): ```dart bool queryDataMoved( QueryResult previous, QueryResult current, ) => previous.dataOrNull != current.dataOrNull; bool mutationDataMoved( MutationResult previous, MutationResult current, ) => previous.dataOrNull != current.dataOrNull; bool refuse(T previous, T current) => false; bool accept(T previous, T current) => true; /// The predicate a filtered query or infinite read passes for [filter]. BuildWhen> queryBuildWhen(Filter filter) => switch (filter) { Filter.data => queryDataMoved, Filter.never => refuse>, Filter.always => accept>, }; ``` A read passes the predicate as `buildWhen`, or `null` for no filter. This is the `context.query` pair: [`examples/showcase/lib/features/build_when/build_when_screen.dart`, lines 532–537](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/build_when/build_when_screen.dart#L532-L537): ```dart final result = context.query>( postsQuery(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? queryBuildWhen>(widget.filter) : null, ); _builds += 1; ``` A mutation read takes the same parameter, typed over `MutationResult`. A mutation has no `select`, so the predicate is its only filter. [`examples/showcase/lib/features/build_when/build_when_screen.dart`, lines 824–829](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/build_when/build_when_screen.dart#L824-L829): ```dart final controller = _controller = context.mutation( incrementMutation(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? mutationBuildWhen(widget.filter) : null, ); _builds += 1; ```
The whole screen [`examples/showcase/lib/features/build_when/build_when_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/build_when/build_when_screen.dart): ```dart /// Port-specific: `buildWhen` on the **eight keyless reads**, each one beside /// its unfiltered twin. /// /// The site's [What rebuilds, and when](https://github.com/dualmeta-gmbh/query_kit/blob/main/website/docs/guides/render-optimizations.md) /// names twelve places that take the predicate: four builders and eight /// keyless reads — `watchQuery`, `watchSelectQuery`, `watchInfiniteQuery`, /// `watchMutation`, `context.query`, `context.selectQuery`, /// `context.infiniteQuery` and `context.mutation`. The builders are /// demonstrated on `select-and-sharing`; the eight are demonstrated here, /// which is the whole reason this screen exists. This screen shows /// what that page describes and explains nothing a second way. /// /// **Sixteen readers, in eight pairs.** Every member is read twice over the /// same cache entry: once **filtered**, passing the predicate the knob names, /// and once **plain**, passing none. Both halves count their own builds, so /// the effect of the predicate is the difference between two numbers on one /// screen rather than the same number before and after a knob. /// /// What the counters do, and why: /// /// - **A first load is two builds for everyone.** The first build reads the /// result the subscribe produced — pending, already fetching — and the /// data landing is the second. `null` to a list is a change of the data, /// so the `data` predicate lets it through too. A mutation reader starts at /// one: nothing has been reported yet. /// - **A refetch that brings back equal data is two builds for a plain /// reader and none for a filtered one.** `QueryResult`'s `==` covers /// `fetchStatus` and `dataUpdatedAt`, so the flip to fetching and the /// landing are both changed results — and `select` cannot narrow either of /// them away, which is why the two select reads sit here next to the two /// plain ones and move exactly as far. /// - **A real change gets through the filter.** `Drop a post` writes a /// shorter list straight into the cache; the data moved, so every reader /// rebuilds, filtered ones included. /// - **`Load next` is two builds plain and one filtered.** The page fetch /// starting moves `fetchStatus` while the pages are still the pages the /// reader is showing — refused — and the new page is a real change. /// - **A mutation run is three builds plain and two filtered.** `idle`, /// `pending` and `success` are three results; only the last of them carries /// data, so the `data` predicate drops the pending one. A mutation has no /// `select`, so the predicate is the *only* filter one of these readers /// has. /// - **The knob moves every counter by one.** A predicate filters /// *notifications*, not rebuilds from above: a new value for the knob is a /// new widget for all sixteen readers, and nothing about `buildWhen` can /// refuse a parent. `never` freezes the filtered half where it stands and /// `always` makes it its twin again, which is the proof that the predicate /// is the only difference between the two halves of a pair. /// /// One case the screen deliberately does **not** reach: an infinite query /// whose paging flags move while the result stays equal — two fetches in /// opposite directions — rebuilds whatever the predicate says, because there /// is nothing there for a predicate over results to compare. `max-pages` is /// the screen with fetches in both directions; here every page fetch moves /// `fetchStatus` too, so the predicate is always the one that answers. /// /// Proofs (widget tests in `test/features/build_when_test.dart`, end-to-end /// in `e2e/tests/build_when.spec.ts`): all sixteen readers load from one /// request per entry; a refetch with equal data moves the eight plain query /// counters by two and none of the filtered ones; `Drop a post` moves all /// sixteen posts-side counters by one; `Load next` is two against one; a /// mutation run is three against two; `never` freezes the filtered half and /// `always` makes both halves of every pair equal again. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature buildWhenFeature = Feature( id: 'build-when', title: 'Filtering rebuilds', summary: 'buildWhen on the eight keyless reads, each beside its ' 'unfiltered twin.', ); /// Which predicate the filtered half of every pair passes. The knob's three /// values, and the one string each is named by in both test layers. enum Filter { /// `previous.dataOrNull != current.dataOrNull`, the canonical one: rebuild /// for the data and for nothing else. data('data'), /// `(_, __) => false`. The filtered half stops rebuilding entirely, which /// is what makes "the predicate decides" visible rather than plausible. never('never'), /// `(_, __) => true`. The filtered half becomes its plain twin again: the /// predicate is the only difference between them. always('always'); const Filter(this.label); /// The knob's segment, and the word the screen prints. final String label; } // The predicates are top-level functions, not closures built in `build`: a // tear-off of one is the same object on every build, so nothing about the // filtering depends on which frame asked. bool queryDataMoved( QueryResult previous, QueryResult current, ) => previous.dataOrNull != current.dataOrNull; bool mutationDataMoved( MutationResult previous, MutationResult current, ) => previous.dataOrNull != current.dataOrNull; bool refuse(T previous, T current) => false; bool accept(T previous, T current) => true; /// The predicate a filtered query or infinite read passes for [filter]. BuildWhen> queryBuildWhen(Filter filter) => switch (filter) { Filter.data => queryDataMoved, Filter.never => refuse>, Filter.always => accept>, }; /// [queryBuildWhen] for a mutation read, whose result is a different type /// with the same `dataOrNull`. BuildWhen> mutationBuildWhen(Filter filter) => switch (filter) { Filter.data => mutationDataMoved, Filter.never => refuse>, Filter.always => accept>, }; /// The entry the eight posts-side readers share. /// /// The `staleTime` is what makes "eight readers, one request" hold whichever /// frame each one first builds in: a reader that subscribes after the data is /// in joins it instead of starting a refetch of its own. The buttons are /// unaffected — `refetchQueries` ignores staleness. QueryObserverOptions> postsQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal), staleTime: const StaleTime.duration(Duration(minutes: 5)), ); /// [postsQuery] with a `select`, for the two select reads: the same entry, /// the same fetch, the count instead of the list. QuerySelectOptions, int> postCountQuery(ShowcaseApi api) => QuerySelectOptions, int>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal), select: countPosts, staleTime: const StaleTime.duration(Duration(minutes: 5)), ); int countPosts(List posts) => posts.length; /// This screen's own infinite entry, so the `load-more` and `max-pages` /// entries are untouched by what happens here. QueryKey get pagesKey => QueryKey(const ['projects', 'build-when']); InfiniteQueryObserverOptions pagesQuery(ShowcaseApi api) => InfiniteQueryObserverOptions( queryKey: pagesKey, initialPageParam: 0, pageFn: (context) => api.projectsFrom( context.pageParam, limit: 10, signal: context.signal, ), getNextPageParam: (page, _, __, ___) => page.nextId, staleTime: const StaleTime.duration(Duration(minutes: 5)), ); /// One increment, so a mutation reader has something to report. Each of the /// four mutation readers owns a mutation of its own — mutations are never /// shared — and what any of them wrote is deliberately not on screen: the /// four run at once, so the value the backend answers depends on the order /// four requests happened to arrive in, and nothing here may depend on that. MutationOptions incrementMutation(ShowcaseApi api) => MutationOptions.simple( mutationFn: (by) => api.increment(by: by), ); /// What a toolbar button asks a reader to do. /// /// The work a card offers — run the mutation, load the next page — lives on a /// controller a *reader* holds, and a button inside one reader would make /// that row different from its twin. So the buttons stay in the card's /// toolbar and tick this, and the readers that must act listen. class Trigger extends ValueNotifier { Trigger() : super(0); /// One more press. void fire() => value += 1; } class BuildWhenScreen extends StatefulWidget { const BuildWhenScreen({super.key}); @override State createState() => _BuildWhenScreenState(); } class _BuildWhenScreenState extends State { Filter _filter = Filter.data; /// Created once and never replaced, which is why the readers may listen in /// `initState` and forget about them until `dispose`. final Trigger _loadNext = Trigger(); final Trigger _runMutation = Trigger(); @override void dispose() { _loadNext.dispose(); _runMutation.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final filter = _filter; return FeatureScaffold( feature: buildWhenFeature, children: [ SectionCard( title: 'The predicate the filtered half passes', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Every read below is made twice over one entry: filtered, ' 'with the predicate this knob names, and plain, with none. ' 'A predicate filters notifications, not rebuilds from above — ' 'moving this knob is a new widget for all sixteen readers, so ' 'every counter goes up by one whichever value you pick.', ), const SizedBox(height: 12), knob( context, title: 'buildWhen', name: 'predicate', choices: <(String, Filter)>[ for (final value in Filter.values) (value.label, value), ], selected: filter, onChanged: (value) => setState(() => _filter = value), ), ], ), ), QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'), _QueryReadsCard(filter: filter), QueryDebugStrip(queryKey: pagesKey, label: 'pages'), _InfiniteReadsCard(filter: filter, loadNext: _loadNext), _MutationReadsCard(filter: filter, run: _runMutation), ], ); } } /// The four query-shaped reads, filtered and plain: one entry, eight /// observers. class _QueryReadsCard extends StatelessWidget { const _QueryReadsCard({required this.filter}); final Filter filter; @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); return SectionCard( title: 'The query reads: watchQuery, context.query, and the two selects', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Toolbar( children: [ ActionButton( label: 'Refetch the posts', filled: true, onPressed: () => client .refetchQueries( filters: QueryFilters(queryKey: ShowcaseKeys.posts), ) .ignore(), ), ActionButton( label: 'Drop a post', onPressed: () => client.updateQueryData>( ShowcaseKeys.posts, (previous) => previous == null || previous.isEmpty ? null : previous.sublist(1), ), ), ], ), const SizedBox(height: 8), const Text( 'The backend answers the same thirty posts every time, so a ' 'refetch is a changed result with unchanged data: two rebuilds ' 'for a plain reader and none for a filtered one. Dropping a post ' 'changes the data, and every reader shows it.', ), for (final filtered in [true, false]) ...[ _WatchQueryReader(filter: filter, filtered: filtered), _ContextQueryReader(filter: filter, filtered: filtered), _WatchSelectQueryReader(filter: filter, filtered: filtered), _ContextSelectQueryReader(filter: filter, filtered: filtered), ], ], ), ); } } /// The two infinite reads, filtered and plain. Both hand back the controller, /// because paging lives on it. class _InfiniteReadsCard extends StatelessWidget { const _InfiniteReadsCard({required this.filter, required this.loadNext}); final Filter filter; final Trigger loadNext; @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); return SectionCard( title: 'The infinite reads: watchInfiniteQuery, context.infiniteQuery', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Toolbar( children: [ ActionButton( label: 'Load next', filled: true, onPressed: loadNext.fire, ), ActionButton( label: 'Refetch the pages', onPressed: () => client .refetchQueries(filters: QueryFilters(queryKey: pagesKey)) .ignore(), ), ], ), const SizedBox(height: 8), const Text( 'Load next is two rebuilds plain and one filtered: the fetch ' 'starting moves fetchStatus while the pages are still the pages ' 'on screen, and the page landing is a real change. Refetching the ' 'pages brings back the pages that are already there, so the ' 'filtered half does not move at all. Paging goes through the ' 'plain mixin reader, so no row carries a button its twin does ' 'not.', ), for (final filtered in [true, false]) ...[ _WatchInfiniteQueryReader( filter: filter, filtered: filtered, loadNext: filtered ? null : loadNext, ), _ContextInfiniteQueryReader(filter: filter, filtered: filtered), ], ], ), ); } } /// The two mutation reads, filtered and plain. Four readers, four mutations: /// one is never shared, so all four run together on one button. class _MutationReadsCard extends StatelessWidget { const _MutationReadsCard({required this.filter, required this.run}); final Filter filter; final Trigger run; @override Widget build(BuildContext context) => SectionCard( title: 'The mutation reads: watchMutation, context.mutation', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Toolbar( children: [ ActionButton( label: 'Run the mutation', filled: true, onPressed: run.fire, ), ], ), const SizedBox(height: 8), const Text( 'A run is idle, pending and success: three results, of which ' 'only the last carries data. The plain half rebuilds three ' 'times, the filtered half twice. A mutation has no select, so ' 'the predicate is the only filter one of these readers has.', ), for (final filtered in [true, false]) ...[ _WatchMutationReader( filter: filter, filtered: filtered, run: run), _ContextMutationReader( filter: filter, filtered: filtered, run: run), ], ], ), ); } /// One reader's row: which member it is, whether it filters, what it shows, /// and how often it has built. /// /// A semantics group named `reader watchQuery filtered` or /// `reader watchQuery plain` — the member spelled exactly as the call is /// written, because the /// eight members are what this screen is a catalogue of. It composes /// [SemanticsGroup] and [FactList] rather than calling `FactGroup`, since it /// carries a heading beside its facts. class _ReaderRow extends StatelessWidget { const _ReaderRow({ required this.member, required this.filter, required this.filtered, required this.builds, required this.facts, }); /// `watchQuery`, `context.mutation`: the call, as written. final String member; /// The knob's value — printed, but only used when [filtered]. final Filter filter; /// Whether this half of the pair passes the predicate. final bool filtered; final int builds; /// What the reader read, as exact `key=value` texts. final List facts; @override Widget build(BuildContext context) => SemanticsGroup( name: 'reader $member ${filtered ? 'filtered' : 'plain'}', child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( filtered ? '$member · buildWhen ${filter.label}' : '$member · no buildWhen', style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 2), FactList([...facts, 'builds=$builds'], dense: true), ], ), ), ); } /// The facts a query-shaped reader prints, given what it read. List _queryFacts( QueryResult result, String name, Object? had) => ['status=${result.status.name}', '$name=$had']; /// Reader 1: `watchQuery`, in a `QueryMixin`. class _WatchQueryReader extends StatefulWidget { const _WatchQueryReader({required this.filter, required this.filtered}); final Filter filter; final bool filtered; @override State<_WatchQueryReader> createState() => _WatchQueryReaderState(); } class _WatchQueryReaderState extends State<_WatchQueryReader> with QueryMixin { int _builds = 0; @override Widget build(BuildContext context) { final result = watchQuery>( postsQuery(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? queryBuildWhen>(widget.filter) : null, ); _builds += 1; return _ReaderRow( member: 'watchQuery', filter: widget.filter, filtered: widget.filtered, builds: _builds, facts: _queryFacts(result, 'posts', result.dataOrNull?.length ?? 0), ); } } /// Reader 2: `context.query`. class _ContextQueryReader extends StatefulWidget { const _ContextQueryReader({required this.filter, required this.filtered}); final Filter filter; final bool filtered; @override State<_ContextQueryReader> createState() => _ContextQueryReaderState(); } class _ContextQueryReaderState extends State<_ContextQueryReader> { int _builds = 0; @override Widget build(BuildContext context) { final result = context.query>( postsQuery(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? queryBuildWhen>(widget.filter) : null, ); _builds += 1; return _ReaderRow( member: 'context.query', filter: widget.filter, filtered: widget.filtered, builds: _builds, facts: _queryFacts(result, 'posts', result.dataOrNull?.length ?? 0), ); } } /// Reader 3: `watchSelectQuery`. The same entry, the count instead of the /// list — and the same two rebuilds per refetch, because `select` decides /// what the data is and not when the widget rebuilds. class _WatchSelectQueryReader extends StatefulWidget { const _WatchSelectQueryReader({ required this.filter, required this.filtered, }); final Filter filter; final bool filtered; @override State<_WatchSelectQueryReader> createState() => _WatchSelectQueryReaderState(); } class _WatchSelectQueryReaderState extends State<_WatchSelectQueryReader> with QueryMixin { int _builds = 0; @override Widget build(BuildContext context) { final result = watchSelectQuery, int>( postCountQuery(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? queryBuildWhen(widget.filter) : null, ); _builds += 1; return _ReaderRow( member: 'watchSelectQuery', filter: widget.filter, filtered: widget.filtered, builds: _builds, facts: _queryFacts(result, 'count', result.dataOrNull ?? 0), ); } } /// Reader 4: `context.selectQuery`. class _ContextSelectQueryReader extends StatefulWidget { const _ContextSelectQueryReader({ required this.filter, required this.filtered, }); final Filter filter; final bool filtered; @override State<_ContextSelectQueryReader> createState() => _ContextSelectQueryReaderState(); } class _ContextSelectQueryReaderState extends State<_ContextSelectQueryReader> { int _builds = 0; @override Widget build(BuildContext context) { final result = context.selectQuery, int>( postCountQuery(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? queryBuildWhen(widget.filter) : null, ); _builds += 1; return _ReaderRow( member: 'context.selectQuery', filter: widget.filter, filtered: widget.filtered, builds: _builds, facts: _queryFacts(result, 'count', result.dataOrNull ?? 0), ); } } /// The facts an infinite reader prints. List _pageFacts(QueryResult> result) => [ 'status=${result.status.name}', 'pages=${result.dataOrNull?.pages.length ?? 0}', ]; /// Reader 5: `watchInfiniteQuery`, in a `QueryMixin`. /// /// The plain half of the pair is also the one the card's `Load next` button /// reaches, through [loadNext] — one reader pages, or four observers of one /// entry would each ask for the next page. class _WatchInfiniteQueryReader extends StatefulWidget { const _WatchInfiniteQueryReader({ required this.filter, required this.filtered, required this.loadNext, }); final Filter filter; final bool filtered; /// Non-null on exactly one of the four infinite readers. final Trigger? loadNext; @override State<_WatchInfiniteQueryReader> createState() => _WatchInfiniteQueryReaderState(); } class _WatchInfiniteQueryReaderState extends State<_WatchInfiniteQueryReader> with QueryMixin { int _builds = 0; InfiniteQueryController>? _controller; @override void initState() { super.initState(); widget.loadNext?.addListener(_loadNext); } @override void dispose() { widget.loadNext?.removeListener(_loadNext); super.dispose(); } void _loadNext() { final controller = _controller; if (controller != null && controller.hasNextPage && !controller.isFetchingNextPage) { controller.fetchNextPage().ignore(); } } @override Widget build(BuildContext context) { final controller = _controller = watchInfiniteQuery( pagesQuery(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? queryBuildWhen>(widget.filter) : null, ); _builds += 1; return _ReaderRow( member: 'watchInfiniteQuery', filter: widget.filter, filtered: widget.filtered, builds: _builds, facts: _pageFacts(controller.value), ); } } /// Reader 6: `context.infiniteQuery`. class _ContextInfiniteQueryReader extends StatefulWidget { const _ContextInfiniteQueryReader({ required this.filter, required this.filtered, }); final Filter filter; final bool filtered; @override State<_ContextInfiniteQueryReader> createState() => _ContextInfiniteQueryReaderState(); } class _ContextInfiniteQueryReaderState extends State<_ContextInfiniteQueryReader> { int _builds = 0; @override Widget build(BuildContext context) { final controller = context.infiniteQuery( pagesQuery(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? queryBuildWhen>(widget.filter) : null, ); _builds += 1; return _ReaderRow( member: 'context.infiniteQuery', filter: widget.filter, filtered: widget.filtered, builds: _builds, facts: _pageFacts(controller.value), ); } } /// Reader 7: `watchMutation`, in a `QueryMixin`. class _WatchMutationReader extends StatefulWidget { const _WatchMutationReader({ required this.filter, required this.filtered, required this.run, }); final Filter filter; final bool filtered; final Trigger run; @override State<_WatchMutationReader> createState() => _WatchMutationReaderState(); } class _WatchMutationReaderState extends State<_WatchMutationReader> with QueryMixin { int _builds = 0; MutationController? _controller; @override void initState() { super.initState(); widget.run.addListener(_run); } @override void dispose() { widget.run.removeListener(_run); super.dispose(); } void _run() => _controller?.mutate(1); @override Widget build(BuildContext context) { final controller = _controller = watchMutation( incrementMutation(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? mutationBuildWhen(widget.filter) : null, ); _builds += 1; return _ReaderRow( member: 'watchMutation', filter: widget.filter, filtered: widget.filtered, builds: _builds, facts: ['status=${controller.value.status.name}'], ); } } /// Reader 8: `context.mutation`. class _ContextMutationReader extends StatefulWidget { const _ContextMutationReader({ required this.filter, required this.filtered, required this.run, }); final Filter filter; final bool filtered; final Trigger run; @override State<_ContextMutationReader> createState() => _ContextMutationReaderState(); } class _ContextMutationReaderState extends State<_ContextMutationReader> { int _builds = 0; MutationController? _controller; @override void initState() { super.initState(); widget.run.addListener(_run); } @override void dispose() { widget.run.removeListener(_run); super.dispose(); } void _run() => _controller?.mutate(1); @override Widget build(BuildContext context) { final controller = _controller = context.mutation( incrementMutation(ShowcaseScope.apiOf(context)), buildWhen: widget.filtered ? mutationBuildWhen(widget.filter) : null, ); _builds += 1; return _ReaderRow( member: 'context.mutation', filter: widget.filter, filtered: widget.filtered, builds: _builds, facts: ['status=${controller.value.status.name}'], ); } } ```
## Related - Guides: [What rebuilds, and when](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md), [Four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) - Tested by `test/features/build_when_test.dart` (widget) and `e2e/tests/build_when.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/build_when) --- # Global callbacks > QueryCache and MutationCache callbacks logged as they fire, meta deciding which failures get a SnackBar, and meta reaching the query function. A `QueryCache` and a `MutationCache` take `onSuccess`, `onError` and `onSettled` (and, for mutations, `onMutate`) in their constructors, and they run for every query and every mutation in the client. This screen builds a client with all of them, writes each call into a log, and uses a query's `meta` to decide which failures deserve a `SnackBar`. It is the place for what every screen of an app should get without asking: an error toast for failed loads, reporting to a crash tracker, a "saved" confirmation after any settings form is submitted. Live demo: [Global callbacks](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/global-callbacks), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/global_callbacks)). Cache-level callbacks, and meta on its way through. ## What to try - On open the posts load, and the *Callback log* reads `query success posts` then `query settled posts`. - Press *Fetch a missing post*: post 999 does not exist, the log shows `query error post-999 (meta: toast)`, and a `SnackBar` says *Post not found*. The query carries `meta: {'toast': true}`, and the cache's `onError` reads it. - Press *Fetch with meta tag*: the query function reads `context.meta` and echoes it, so the row shows `meta seen=showcase`. - Press *Create todo*: the log shows `mutation mutate`, `mutation success`, `option onSuccess`, `mutation settled`, `option onSettled`. The cache's callback runs before the mutation's own each time. *Create failing todo* asks the backend to refuse, and the log shows `mutation error (…)` followed by `option onError`. - The bin icon (*Clear log*) empties the log; the caches keep their entries. ## The code The callbacks are constructor arguments of the caches, so the screen builds a client of its own with them and puts it under a nested `QueryClientProvider`: [`examples/showcase/lib/features/global_callbacks/global_callbacks_screen.dart`, lines 141–157](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/global_callbacks/global_callbacks_screen.dart#L141-L157): ```dart /// The screen's own client, built with both caches configured. The app's /// client cannot be given callbacks after the fact — they are constructor /// arguments of the caches — and the app's must stay callback-free for the /// other screens. late final QueryClient _client = QueryClient( queryCache: QueryCache( onSuccess: _onQuerySuccess, onError: _onQueryError, onSettled: _onQuerySettled, ), mutationCache: MutationCache( onMutate: _onMutationMutate, onSuccess: _onMutationSuccess, onError: _onMutationError, onSettled: _onMutationSettled, ), ); ``` The missing post tags itself with `meta`. The map is `const`: options compare by value, and a new map on every build would count as a change. [`examples/showcase/lib/features/global_callbacks/global_callbacks_screen.dart`, lines 69–84](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/global_callbacks/global_callbacks_screen.dart#L69-L84): ```dart /// Post 999, tagged for the cache's `onError`. No retries: the point is the /// error, and a reader counting requests should see one. Disabled until /// [wanted], so the entry sits idle in the cache until the button. QueryObserverOptions missingPostQuery( ShowcaseApi api, { required bool wanted, }) => QueryObserverOptions( queryKey: ShowcaseKeys.post(missingPostId), queryFn: (context) => api.post(missingPostId, signal: context.signal), enabled: wanted ? Enabled.yes : Enabled.no, retry: RetryPolicy.never, // A const map: options carry value equality, and a fresh map every // build would count as a change on every rebuild. meta: const {'toast': true}, ); ``` The cache's `onError` receives the query, reads its `meta`, and shows the `SnackBar` only for queries that asked for one: [`examples/showcase/lib/features/global_callbacks/global_callbacks_screen.dart`, lines 218–233](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/global_callbacks/global_callbacks_screen.dart#L218-L233): ```dart void _onQueryError(Object error, StackTrace _, Query query) { final meta = query.meta; final toast = meta is Map && meta['toast'] == true; _append( 'query error ${_labelOf(query.queryKey)}${toast ? ' (meta: toast)' : ''}', ); if (toast && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$error'), // Long enough for a test to read it; a reader dismisses it. duration: const Duration(seconds: 30), ), ); } } ```
The whole screen [`examples/showcase/lib/features/global_callbacks/global_callbacks_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/global_callbacks/global_callbacks_screen.dart): ```dart /// Port-specific: the cache-wide callbacks a `QueryCache` and a /// `MutationCache` take in their constructors — upstream's `QueryCacheConfig` /// and `MutationCacheConfig` — and `meta`, on its way from the options to the /// query function (`context.meta`) and to those callbacks (`query.meta`). /// /// The callbacks are constructor arguments, so a cache that has them has to /// be built with them: this screen runs on a `QueryClient` of its own, wrapped /// in a nested `QueryClientProvider`, and the debug strips under it read that /// client. A log panel shows every callback as one line. The query side has /// no per-query `onSuccess`/`onError` — upstream removed those in v5, and the /// cache-level ones are what replaced them — so the log is the whole story /// for queries; the mutation side has both, and the log shows the cache's /// running first (`mutation success` before `option onSuccess`), which is the /// order the core runs them in. /// /// Two of upstream's `meta` idioms are here. `Fetch a missing post` asks for /// post 999 with `meta: {'toast': true}`, and the cache's `onError` reads /// `query.meta` to decide whether the failure deserves a `SnackBar` — the /// "meta drives global error handling" pattern from the `QueryCache` docs. /// `Fetch with meta tag` runs a query function that reads `context.meta` and /// echoes it into its data, which is upstream's "additional information about /// your query" reaching the function. /// /// Proofs (widget tests in `test/features/global_callbacks_test.dart`, /// end-to-end in `e2e/tests/global_callbacks.spec.ts`): loading the screen /// logs `query success posts` and then `query settled posts`; the missing /// post logs `query error post-999 (meta: toast)` and shows the `SnackBar` /// `Post not found`; the meta query shows `meta seen=showcase`; a created /// todo logs `mutation mutate`, `mutation success`, `option onSuccess`, /// `mutation settled`, `option onSettled` in that order, and a refused one /// logs `mutation error (Requested: 500)`; leaving the screen disposes its /// client, and the app's client never held any of these entries. library; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/cache_listener.dart'; import '../../shared/chrome.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/models.dart'; import '../../shared/scope.dart'; const Feature globalCallbacksFeature = Feature( id: 'global-callbacks', title: 'Global callbacks', summary: 'Cache-level callbacks, and meta on its way through.', ); /// The post that does not exist, so its fetch is a sure error. const int missingPostId = 999; /// The key of the query that proves `meta` reaches the query function. Owned /// by this screen alone, so it lives here rather than in `ShowcaseKeys`. QueryKey get metaKey => QueryKey(const ['meta']); QueryObserverOptions> postsQuery(ShowcaseApi api) => QueryObserverOptions>( queryKey: ShowcaseKeys.posts, queryFn: (context) => api.posts(signal: context.signal), ); /// Post 999, tagged for the cache's `onError`. No retries: the point is the /// error, and a reader counting requests should see one. Disabled until /// [wanted], so the entry sits idle in the cache until the button. QueryObserverOptions missingPostQuery( ShowcaseApi api, { required bool wanted, }) => QueryObserverOptions( queryKey: ShowcaseKeys.post(missingPostId), queryFn: (context) => api.post(missingPostId, signal: context.signal), enabled: wanted ? Enabled.yes : Enabled.no, retry: RetryPolicy.never, // A const map: options carry value equality, and a fresh map every // build would count as a change on every rebuild. meta: const {'toast': true}, ); /// What the meta query hands back: the tag it found in `context.meta`, next /// to the serial the backend answered with, so the fetch is a real one. class MetaEcho { const MetaEcho({required this.tag, required this.serial}); final String tag; final int serial; } QueryObserverOptions metaQuery( ShowcaseApi api, { required bool wanted, }) => QueryObserverOptions( queryKey: metaKey, queryFn: (context) async { final meta = context.meta as Map?; final time = await api.time(signal: context.signal); return MetaEcho(tag: '${meta?['tag']}', serial: time.serial); }, enabled: wanted ? Enabled.yes : Enabled.no, meta: const {'tag': 'showcase'}, ); /// One `mutate` call's input: the todo's text and whether the backend should /// refuse it. One mutation serves both buttons. typedef CreateTodoInput = ({String text, bool fail}); /// The mutation's own callbacks log with an `option` prefix, so the log /// shows where they land relative to the cache's. MutationOptions createTodoMutation( ShowcaseApi api, { required void Function(String line) log, }) => MutationOptions.simple( mutationFn: (input) => api.createTodo(input.text, fail: input.fail ? 500 : null), onSuccess: (_, __, ___) => log('option onSuccess'), onError: (_, __, ___, ____) => log('option onError'), onSettled: (_, __, ___, ____, _____) => log('option onSettled'), ); class GlobalCallbacksScreen extends StatefulWidget { const GlobalCallbacksScreen({super.key}); @override State createState() => _GlobalCallbacksScreenState(); } class _GlobalCallbacksScreenState extends State with PhaseSafeRebuild { final List _log = []; bool _missingWanted = false; bool _metaWanted = false; /// The screen's own client, built with both caches configured. The app's /// client cannot be given callbacks after the fact — they are constructor /// arguments of the caches — and the app's must stay callback-free for the /// other screens. late final QueryClient _client = QueryClient( queryCache: QueryCache( onSuccess: _onQuerySuccess, onError: _onQueryError, onSettled: _onQuerySettled, ), mutationCache: MutationCache( onMutate: _onMutationMutate, onSuccess: _onMutationSuccess, onError: _onMutationError, onSettled: _onMutationSettled, ), ); late final void Function() _unsubscribeQueries; late final void Function() _unsubscribeMutations; @override void initState() { super.initState(); // The strips rebuild on the *app's* cache events (through `CacheStats`), // which this client never emits; this screen stands in for that listener // so the strips under it stay live. Only the events that change what a // strip shows: a rebuild re-applies every builder's options, and the // options-updated event that follows would rebuild again, for good. _unsubscribeQueries = _client.queryCache.subscribe((event) { if (event is QueryUpdated || event is QueryAdded || event is QueryRemoved || event is QueryObserverAdded || event is QueryObserverRemoved) { scheduleRebuild(); } }); _unsubscribeMutations = _client.mutationCache.subscribe((event) { if (event is MutationUpdated || event is MutationAdded || event is MutationRemoved) { scheduleRebuild(); } }); } @override void dispose() { _unsubscribeQueries(); _unsubscribeMutations(); // The nested provider unmounted the client when it went; what is left is // the cache itself, with its `gcTime` timers. `clear` is the whole of a // client's teardown — there is nothing else to release. _client.clear(); super.dispose(); } // --- the query cache's callbacks ------------------------------------- static String _labelOf(QueryKey key) { if (key == ShowcaseKeys.posts) { return 'posts'; } if (key == ShowcaseKeys.post(missingPostId)) { return 'post-$missingPostId'; } if (key == metaKey) { return 'meta'; } return key.debugString; } void _onQuerySuccess(Object? data, Query query) => _append('query success ${_labelOf(query.queryKey)}'); /// Upstream's idiom: a global error handler that looks at `query.meta` to /// decide what the failure deserves. Here `toast` means a `SnackBar`. void _onQueryError(Object error, StackTrace _, Query query) { final meta = query.meta; final toast = meta is Map && meta['toast'] == true; _append( 'query error ${_labelOf(query.queryKey)}${toast ? ' (meta: toast)' : ''}', ); if (toast && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$error'), // Long enough for a test to read it; a reader dismisses it. duration: const Duration(seconds: 30), ), ); } } void _onQuerySettled( Object? data, Object? error, StackTrace? _, Query query, ) => _append('query settled ${_labelOf(query.queryKey)}'); // --- the mutation cache's callbacks ---------------------------------- FutureOr _onMutationMutate( Object? variables, Mutation mutation, ) { _append('mutation mutate'); } FutureOr _onMutationSuccess( Object? data, Object? variables, Object? onMutateResult, Mutation mutation, ) { _append('mutation success'); } FutureOr _onMutationError( Object error, StackTrace stackTrace, Object? variables, Object? onMutateResult, Mutation mutation, ) { _append('mutation error ($error)'); } FutureOr _onMutationSettled( Object? data, Object? error, StackTrace? stackTrace, Object? variables, Object? onMutateResult, Mutation mutation, ) { _append('mutation settled'); } // --- the log --------------------------------------------------------- void _append(String line) { _log.add(line); scheduleRebuild(); } @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); // Everything below — the builders, the mutation, the strips — reads the // nearest provider, and that is this one. The app's lifecycle is left to // the app's provider: two focus listeners on one app would refetch twice. return QueryClientProvider( client: _client, observeAppLifecycle: false, child: FeatureScaffold( feature: globalCallbacksFeature, children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Notice( 'This screen runs on a QueryClient of its own, because the ' 'callbacks are constructor arguments of its caches. The strips ' 'below read that client; their fetches counter is the app ' "client's and is not tracked for a nested one.", ), ), SectionCard( title: 'Queries', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ QueryBuilder>( options: postsQuery(api), builder: (context, posts) => switch (posts) { QueryPending() => const Text('posts=loading'), QueryError(:final error) => Notice('$error', error: true), QuerySuccess(:final data) => Text('posts=${data.length}'), }, ), const SizedBox(height: 12), QueryBuilder( options: missingPostQuery(api, wanted: _missingWanted), builder: (context, result) => _QueryRow( label: 'Fetch a missing post', fetching: result.isFetching, onPressed: () { if (_missingWanted) { result.refetch(); } else { setState(() => _missingWanted = true); } }, child: Text(switch (result) { QueryPending() => result.isFetching ? 'missing=fetching' : 'missing=not fetched yet', QueryError(:final error) => 'missing=error: $error', QuerySuccess(:final data) => 'missing=${data.title}', }), ), ), const SizedBox(height: 12), QueryBuilder( options: metaQuery(api, wanted: _metaWanted), builder: (context, result) => _QueryRow( label: 'Fetch with meta tag', fetching: result.isFetching, onPressed: () { if (_metaWanted) { result.refetch(); } else { setState(() => _metaWanted = true); } }, child: switch (result) { QueryPending() => Text(result.isFetching ? 'meta seen=fetching' : 'meta seen=not fetched yet'), QueryError(:final error) => Text('meta seen=error: $error'), QuerySuccess(:final data) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('meta seen=${data.tag}'), Text('serial=${data.serial}'), ], ), }, ), ), ], ), ), SectionCard( title: 'Mutations', child: _MutationsCard(api: api, log: _append), ), QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'), QueryDebugStrip( queryKey: ShowcaseKeys.post(missingPostId), label: 'post-$missingPostId', ), QueryDebugStrip(queryKey: metaKey, label: 'meta'), SectionCard( title: 'Callback log', trailing: IconButton( tooltip: 'Clear log', onPressed: _log.isEmpty ? null : () => setState(_log.clear), icon: const Icon(Icons.delete_outline), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('log=${_log.length}'), const SizedBox(height: 4), // Its own semantics group, like a strip: a test finds the // group and each line as an exact text inside it. SemanticsGroup( name: 'callback log', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final line in _log) Text( line, style: const TextStyle( fontFamily: 'monospace', fontSize: 12, ), ), ], ), ), ], ), ), ], ), ); } } /// A button next to what its query shows. The button's label is its /// accessible name; the tooltip is for a pointer only. class _QueryRow extends StatelessWidget { const _QueryRow({ required this.label, required this.fetching, required this.onPressed, required this.child, }); final String label; final bool fetching; final VoidCallback onPressed; final Widget child; @override Widget build(BuildContext context) => SemanticsGroup( child: Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Tooltip( message: label, excludeFromSemantics: true, child: FilledButton.tonal( onPressed: fetching ? null : onPressed, child: Text(label), ), ), if (fetching) const Pill('fetching'), child, ], ), ); } /// Its own widget so `context.mutation` reads the nested provider's client: /// the screen's own `context` sits above that provider. class _MutationsCard extends StatelessWidget { const _MutationsCard({required this.api, required this.log}); final ShowcaseApi api; final void Function(String line) log; @override Widget build(BuildContext context) { final create = context.mutation(createTodoMutation(api, log: log)); final pending = create.value.isPending; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SemanticsGroup( child: Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Tooltip( message: 'Create todo', excludeFromSemantics: true, child: FilledButton.tonal( onPressed: pending ? null : () => create.mutate( (text: 'From the callbacks screen', fail: false), ), child: const Text('Create todo'), ), ), Tooltip( message: 'Create failing todo', excludeFromSemantics: true, child: FilledButton.tonal( onPressed: pending ? null : () => create.mutate( (text: 'Refused by the backend', fail: true), ), child: const Text('Create failing todo'), ), ), if (pending) const Pill('pending'), ], ), ), const SizedBox(height: 8), Text(switch (create.value) { MutationIdle() => 'todo=idle', MutationPending() => 'todo=pending', MutationSuccess(:final data) => 'todo=#${data.id} ${data.text}', MutationError(:final error) => 'todo=error: $error', }), ], ); } } ```
## Related - Guides: [Global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md), [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md), [Query functions](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-functions.md) - Tested by `test/features/global_callbacks_test.dart` (widget) and `e2e/tests/global_callbacks.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/global_callbacks) --- # Diagnostics > The errors the library raises for a read or write of the wrong type and for a mutation with no function, and the fix for each. A cache entry holds one exact type. A read or write that names another type throws `QueryDataTypeError` from the call itself, before any future exists, and the error names the type asked for and the type held. A mutation run with no `mutationFn` and no default registered for its key fails with `MissingMutationFunctionError` as its error state, and the message names the cure, `QueryClient.setMutationDefaults`. Both show up in real apps the same way: a product detail screen that reads the list entry as the wrong model class, or a mutation key whose default is registered in a setup function that has not run yet. Live demo: [Diagnostics](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/diagnostics), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/diagnostics)). What the library throws, and when: the wrong type, the missing function. ## What to try - Wait for the counter to load (`counter=` in the card header), then press *Read as int*: `read=int` and the value. - Press *Read as String*: `read=QueryDataTypeError`, with `expected=String` and `actual=int`. - Press *Write a String*: `write=QueryDataTypeError`, and the `counter` strip still reads `updates=1`, because the refused write left the entry as it was. - Press *Mutate without a function*: the mutation ends in `status=error` with `error=MissingMutationFunctionError`, and nothing is sent. - Press *Register a default mutationFn*, then *Mutate without a function* again: the same mutation now runs the default and ends in `status=success`. ## The code The counter is fetched as an `int`, and the mutation has a key and nothing else: [`examples/showcase/lib/features/diagnostics/diagnostics_screen.dart`, lines 58–68](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/diagnostics/diagnostics_screen.dart#L58-L68): ```dart /// The counter, as an `int`: the one exact type the entry holds from then on. QueryObserverOptions counterQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: diagnosticsCounterKey, queryFn: (context) => api.counter(signal: context.signal), ); /// A mutation with a key and no function. `retry: never` is a mutation's /// default anyway, and the library forces it while the function is missing. MutationOptions noFunctionMutation() => MutationOptions.simple(mutationKey: noFunctionKey); ``` The wrong-type read throws synchronously, so an ordinary `try` catches it: [`examples/showcase/lib/features/diagnostics/diagnostics_screen.dart`, lines 92–106](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/diagnostics/diagnostics_screen.dart#L92-L106): ```dart /// A read that names another type. It throws before any future exists — /// from the call — so a plain `try` is where it is caught. void _readAsString() { final client = QueryClientProvider.of(context); try { final value = client.getQueryData(diagnosticsCounterKey); setState(() => _read = 'String $value'); } on QueryDataTypeError catch (error) { setState(() { _read = 'QueryDataTypeError'; _expected = '${error.expected}'; _actual = '${error.actual}'; }); } } ``` The cure is a default for every mutation under the key. The mutation's reader applies its options again on the next build, and the default supplies the function: [`examples/showcase/lib/features/diagnostics/diagnostics_screen.dart`, lines 124–136](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/diagnostics/diagnostics_screen.dart#L124-L136): ```dart /// The cure the error message names: a default for every mutation under /// the key. The observer re-applies its options on the rebuild, and the /// default fills the function in. void _registerDefault() { final api = ShowcaseScope.apiOf(context); QueryClientProvider.of(context).setMutationDefaults( noFunctionKey, MutationDefaults( mutationFn: (variables) => api.increment(by: variables! as int), ), ); setState(() => _defaultRegistered = true); } ```
The whole screen [`examples/showcase/lib/features/diagnostics/diagnostics_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/diagnostics/diagnostics_screen.dart): ```dart /// Port-specific: what the library throws, and when. Two errors upstream /// does not have, because they guard what TypeScript checks at compile time /// and JavaScript lets slide at run time: /// /// * [QueryDataTypeError] — one key, one exact type. A read or write that /// names a type other than the one the entry holds throws *synchronously*, /// from the call itself — `getQueryData`, `setQueryData`, `getQueriesData`, /// an observer's `setOptions` — rather than handing back a value that is /// not what the caller asked for. The error names /// the key, the type asked for and the type held. /// * [MissingMutationFunctionError] — a mutation run with no `mutationFn` and /// no default registered for its key fails with it, as its error state, the /// way upstream's "No mutationFn found" rejects. The message names the cure: /// `QueryClient.setMutationDefaults`, and the same button here registers /// one and the next run succeeds. /// /// The counter (`GET /api/counter`) is the entry the typed reads are made /// against, read through a `QueryBuilder`; the function-less mutation is /// a `context.mutation`. /// /// Proofs (widget tests in `test/features/diagnostics_test.dart`, end-to-end /// in `e2e/tests/diagnostics.spec.ts`): a read as `int` answers the cached /// value and a read as `String` throws `QueryDataTypeError` naming `String` /// and `int`; a write of a `String` throws the same and leaves the entry as it /// was, `updates=1`; a mutation without a function ends in `status=error` /// with `MissingMutationFunctionError` and sends nothing; and once a default /// `mutationFn` is registered for its key the same mutation succeeds with /// `data=1`, one `POST`. library; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import '../../shared/api.dart'; import '../../shared/chrome.dart'; import '../../shared/controls.dart'; import '../../shared/debug_strip.dart'; import '../../shared/fact_group.dart'; import '../../shared/feature.dart'; import '../../shared/feature_scaffold.dart'; import '../../shared/scope.dart'; const Feature diagnosticsFeature = Feature( id: 'diagnostics', title: 'Diagnostics', summary: 'What the library throws, and when: the wrong type, the missing ' 'function.', ); /// The entry the typed reads are made against. This screen's own key. QueryKey get diagnosticsCounterKey => QueryKey(const ['diagnostics', 'counter']); /// The function-less mutation's key — what `setMutationDefaults` addresses. QueryKey get noFunctionKey => QueryKey(const ['diagnostics', 'no-function']); /// The counter, as an `int`: the one exact type the entry holds from then on. QueryObserverOptions counterQuery(ShowcaseApi api) => QueryObserverOptions( queryKey: diagnosticsCounterKey, queryFn: (context) => api.counter(signal: context.signal), ); /// A mutation with a key and no function. `retry: never` is a mutation's /// default anyway, and the library forces it while the function is missing. MutationOptions noFunctionMutation() => MutationOptions.simple(mutationKey: noFunctionKey); class DiagnosticsScreen extends StatefulWidget { const DiagnosticsScreen({super.key}); @override State createState() => _DiagnosticsScreenState(); } class _DiagnosticsScreenState extends State { String _read = 'none'; String _write = 'none'; String _expected = 'none'; String _actual = 'none'; bool _defaultRegistered = false; /// A read that names the entry's own type: the value, or null if nothing /// is cached yet. void _readAsInt() { final value = QueryClientProvider.of(context) .getQueryData(diagnosticsCounterKey); setState(() => _read = 'int $value'); } /// A read that names another type. It throws before any future exists — /// from the call — so a plain `try` is where it is caught. void _readAsString() { final client = QueryClientProvider.of(context); try { final value = client.getQueryData(diagnosticsCounterKey); setState(() => _read = 'String $value'); } on QueryDataTypeError catch (error) { setState(() { _read = 'QueryDataTypeError'; _expected = '${error.expected}'; _actual = '${error.actual}'; }); } } /// A write of another type: refused the same way, and the entry is left /// exactly as it was. void _writeString() { final client = QueryClientProvider.of(context); try { client.setQueryData(diagnosticsCounterKey, 'not a number'); setState(() => _write = 'String written'); } on QueryDataTypeError catch (error) { setState(() { _write = 'QueryDataTypeError'; _expected = '${error.expected}'; _actual = '${error.actual}'; }); } } /// The cure the error message names: a default for every mutation under /// the key. The observer re-applies its options on the rebuild, and the /// default fills the function in. void _registerDefault() { final api = ShowcaseScope.apiOf(context); QueryClientProvider.of(context).setMutationDefaults( noFunctionKey, MutationDefaults( mutationFn: (variables) => api.increment(by: variables! as int), ), ); setState(() => _defaultRegistered = true); } @override Widget build(BuildContext context) { final api = ShowcaseScope.apiOf(context); return FeatureScaffold( feature: diagnosticsFeature, children: [ SectionCard( title: 'One key, one type', trailing: QueryBuilder( options: counterQuery(api), builder: (context, counter) => Text( switch (counter) { QueryPending() => 'counter=…', QueryError(staleData: null) => 'counter=error', QuerySuccess(:final data) || QueryError(staleData: final data!) => 'counter=$data', }, style: monoStyle, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'The entry holds an int, fetched by the builder up here. A ' 'read or write that names another type throws ' 'QueryDataTypeError from the call itself — synchronously, ' 'before any future exists — instead of handing back ' 'something that is not what was asked for. The error names ' 'the key, the type asked for and the type held.', ), const SizedBox(height: 12), Toolbar( children: [ ActionButton(label: 'Read as int', onPressed: _readAsInt), ActionButton( label: 'Read as String', onPressed: _readAsString), ActionButton( label: 'Write a String', onPressed: _writeString), ], ), const SizedBox(height: 8), FactGroup( name: 'facts typed', facts: [ 'read=$_read', 'write=$_write', 'expected=$_expected', 'actual=$_actual', ], ), ], ), ), QueryDebugStrip(queryKey: diagnosticsCounterKey, label: 'counter'), SectionCard( title: 'A mutation without a function', child: _NoFunctionCard( defaultRegistered: _defaultRegistered, onRegisterDefault: _registerDefault, ), ), ], ); } } /// The error's name by an `is` check, not `runtimeType`: a web build /// minifies type names, and the fact is read as an exact text. String _nameOf(Object error) => switch (error) { MissingMutationFunctionError() => 'MissingMutationFunctionError', QueryDataTypeError() => 'QueryDataTypeError', _ => 'other', }; /// Its own widget so `context.mutation` re-applies its options on the /// rebuild that follows the default being registered — which is when the /// default fills the function in. class _NoFunctionCard extends StatelessWidget { const _NoFunctionCard({ required this.defaultRegistered, required this.onRegisterDefault, }); final bool defaultRegistered; final VoidCallback onRegisterDefault; @override Widget build(BuildContext context) { final mutation = context.mutation(noFunctionMutation()); final result = mutation.value; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'MutationOptions.simple(mutationKey: …) and nothing else: no ' 'mutationFn, and no default registered for the key. Running it ' 'fails with MissingMutationFunctionError as the mutation\'s error ' 'state — nothing is sent — and the message names the cure. ' 'Register a default mutationFn for the key, and the same mutation ' 'runs it.', ), const SizedBox(height: 12), Toolbar( children: [ ActionButton( label: 'Mutate without a function', filled: true, onPressed: () => mutation.mutate(1), ), ActionButton( label: 'Register a default mutationFn', onPressed: defaultRegistered ? null : onRegisterDefault, ), ], ), const SizedBox(height: 8), FactGroup( name: 'facts no-function', facts: [ 'status=${result.status.name}', if (result case MutationError(:final error)) 'error=${_nameOf(error)}', if (result case MutationSuccess(:final data)) 'data=$data', 'default=${defaultRegistered ? 'registered' : 'none'}', ], ), if (result case MutationError(:final error)) ...[ const SizedBox(height: 8), Notice('$error', error: true), ], ], ); } } ```
## Related - Guides: [Type safety in Dart](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md), [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md), [Debugging](https://dualmeta-gmbh.github.io/query_kit/docs/guides/debugging.md) - Tested by `test/features/diagnostics_test.dart` (widget) and `e2e/tests/diagnostics.spec.ts` (browser) - [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/diagnostics) --- # Cookbook > Recipes for tasks that combine several features — each one a complete, compiled answer to "how do I…". The guides explain one concept each. A recipe starts from a task instead — "refresh a token once for every request that hit it", "load the next page before the user reaches the end" — and puts together the pieces it needs, as complete code in the shape of an app: which file each part lives in, what each step does, the traps, and the variations. The recipes below build one small app between them — a product catalogue with a list, a search, a detail screen, an edit form and an endless feed — so the code on one page is the code the next page uses. ## Data and networking | Recipe | What it answers | |---|---| | [Wiring dio or package:http](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/wiring-dio-and-http.md) | One API client: cancellation handed to the transport, timeouts, readable errors | | [Auth and token refresh](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/auth-and-token-refresh.md) | Refresh a token once, retry only what can succeed, a cache per signed-in user | | [List to detail, seeded](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/list-detail-seeding.md) | Open a detail screen with the data the list already has | | [Lifecycle and connectivity wiring](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/lifecycle-and-connectivity-wiring.md) | `connectivity_plus`, a reachability probe, calmer focus refetches, an offline switch | ## UI patterns | Recipe | What it answers | |---|---| | [Pull to refresh](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/pull-to-refresh.md) | A `RefreshIndicator` that lasts as long as the refetch and keeps the list on failure | | [Search as you type](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/search-as-you-type.md) | Debounced, cancelled when superseded, the last results kept while the next load | | [Forms and server validation](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/forms-and-server-validation.md) | A mutation-driven form with the server's field errors next to the fields | | [A global error snackbar](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/global-error-snackbar.md) | One toast for failed refreshes and saves, and a per-query way out | | [An infinite list view](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/infinite-list-view.md) | Load the next page near the end, once per page, with a footer for the rest | ## Testing | Recipe | What it answers | |---|---| | [Testing a screen](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/testing-a-screen.md) | A fake API with latency, a harness with the teardown, and tests that step fake time | ## Architecture and integration How query_kit fits into the rest of an app: state management, injection, routing, persistence, realtime data, devices, accounts and models. | Recipe | What it answers | |---|---| | [Next to Riverpod, Bloc or Provider](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/riverpod-bloc-provider.md) | Server state in the cache, app state in your store, connected through `QueryController` | | [Offline first, and surviving a restart](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/offline-first-and-persistence.md) | Save chosen queries and unsent writes to disk, and restore them before the first frame | | [Where the client lives](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/dependency-injection.md) | One client, provided at the root, reached with `of`, `maybeOf` and `read`, and shared with get_it | | [Routing with go_router](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/routing-go-router.md) | Keys from path parameters, a prefetch on tap, a refetch on return, reads in dialogs | | [Realtime updates over a WebSocket](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/realtime-websockets.md) | Server events write to the cache or invalidate it, with a resync after a reconnect | | [Poll until a device confirms](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/poll-until-confirmed.md) | An accepted write, a poll that starts and stops itself, and a state for giving up | | [Disconnecting a device](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/device-and-iot-disconnect.md) | No request to a device the user disconnected, and no old reading left in the cache | | [Sign out and multiple accounts](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/sign-out-and-multi-account.md) | A fresh cache per user, cleared after the screens, and accounts kept apart by prefix | | [Normalised data or one key per entity](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/normalised-vs-per-entity-keys.md) | A list plus per-item keys, or a map by id, with unchanged items kept | | [Models with freezed and JSON](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/freezed-and-json-models.md) | Value equality, and `StructurallyShareable` for a class that wraps a list | Every sample on this site is compiled and checked against its source, so a recipe you copy here compiles against the current release. The few that need `dio`, `package:http` or `connectivity_plus` — packages this library does not depend on — say so above the code. --- # Wiring dio or package:http > One API client for every query — cancellation handed on to the transport, timeouts, and failures turned into errors a screen can show. A query function is any function that returns a `Future`, so the library does not care how you talk to your server. The app does: a request the library has cancelled should stop on the wire, a server that hangs should fail rather than spin for ever, a 404 has to become an error (`package:http` does not throw for one), and whatever reaches a screen should be a sentence a user can read, not a `DioException` with a stack of HTTP detail. This recipe puts all of that in one API client and keeps it out of the queries. Every recipe in the cookbook's first half builds on the same small app: a product catalogue with a list, a search, a detail screen, an edit form and an endless feed. This page lays its foundation. ## The finished code The error type every layer above the transport sees: `lib/data/api_exception.dart`: ```dart /// Every failure the API layer throws: a sentence a user can read, and the /// HTTP status when a response arrived. class ApiException implements Exception { const ApiException(this.message, {this.status}); final String message; /// `null` when no response arrived at all — a timeout, no network. final int? status; /// The server knows who we are and said no, or does not know who we are. bool get isAuth => status == 401 || status == 403; /// The request itself was wrong; asking again will not help. bool get isClientError => status != null && status! >= 400 && status! < 500; @override String toString() => message; } /// A 422: the server refused the input, field by field. class ValidationException extends ApiException { const ValidationException(this.fieldErrors) : super('Please correct the highlighted fields', status: 422); /// Field name → what is wrong with it, as the server said it. final Map fieldErrors; } ``` The client, on dio 5. It is the only file that imports dio: `lib/data/api_client.dart`: ```dart import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import 'api_exception.dart'; class ApiClient { ApiClient({required String baseUrl, Dio? dio}) : dio = dio ?? Dio(BaseOptions( baseUrl: baseUrl, // A server that hangs must fail the query, not leave it // fetching for ever. connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), )); final Dio dio; Future get( String path, T Function(Object? json) parse, { Map? query, QueryCancelToken? signal, }) => _run( () => dio.get( path, queryParameters: query, cancelToken: _bridge(signal), ), parse, ); Future send( String method, String path, T Function(Object? json) parse, { Object? body, }) => _run( () => dio.request( path, data: body, options: Options(method: method), ), parse, ); /// A query's cancellation, handed on to dio: when the library cancels the /// fetch, dio aborts the request. static CancelToken? _bridge(QueryCancelToken? signal) { if (signal == null) return null; final token = CancelToken(); signal.onCancel(token.cancel); return token; } Future _run( Future> Function() request, T Function(Object? json) parse, ) async { final Response response; try { response = await request(); } on DioException catch (error) { // Cancelled by the library: it already knows, and a CancelledError of // its own wins. Nothing to translate. if (CancelToken.isCancel(error)) rethrow; throw _translate(error); } try { return parse(response.data); } on Object { // A missing field or a wrong type is a server failure too, and a // TypeError is no message for a user. throw const ApiException('The server sent something unexpected'); } } static ApiException _translate(DioException error) { final status = error.response?.statusCode; final body = _decode(error.response?.data); if (status == 422 && body is Map && body['errors'] is Map) { return ValidationException({ for (final MapEntry(:key, :value) in (body['errors'] as Map).entries) '$key': '$value', }); } if (body is Map && body['message'] is String) { return ApiException(body['message'] as String, status: status); } return ApiException( switch (error.type) { DioExceptionType.connectionTimeout || DioExceptionType.sendTimeout || DioExceptionType.receiveTimeout => 'The server is not responding', DioExceptionType.badResponse => 'The server said no ($status)', _ => 'The server is unreachable', }, status: status, ); } /// dio hands an error body back parsed or as text, depending on its /// content type; read both. static Object? _decode(Object? data) { if (data is! String) return data; try { return jsonDecode(data); } on FormatException { return null; } } } ``` The calls the screens make, as an interface, and its dio implementation: `lib/data/product_api.dart`: ```dart /// The calls the screens make. The query functions depend on this, not on /// dio — so a test hands them a fake, and the transport can change. abstract interface class ProductApi { Future> list({String search = '', QueryCancelToken? signal}); Future get(String id, {QueryCancelToken? signal}); Future page(int offset, {QueryCancelToken? signal}); /// Creates the product when [ProductDraft.id] is null, else updates it. Future save(ProductDraft draft); } ``` `lib/data/dio_product_api.dart`: ```dart import 'package:query_kit_flutter/query_kit_flutter.dart'; import 'api_client.dart'; import 'models.dart'; import 'product_api.dart'; class DioProductApi implements ProductApi { DioProductApi(this._client); final ApiClient _client; @override Future> list({String search = '', QueryCancelToken? signal}) => _client.get( '/products', (json) => [ for (final item in json! as List) Product.fromJson(item! as Map), ], query: {if (search.isNotEmpty) 'q': search}, signal: signal, ); @override Future get(String id, {QueryCancelToken? signal}) => _client.get( '/products/$id', (json) => Product.fromJson(json! as Map), signal: signal, ); @override Future page(int offset, {QueryCancelToken? signal}) => _client.get( '/products', (json) { final body = json! as Map; return ProductPage( items: [ for (final item in body['items']! as List) Product.fromJson(item! as Map), ], nextOffset: body['nextOffset'] as int?, ); }, query: {'offset': offset, 'limit': 20}, signal: signal, ); @override Future save(ProductDraft draft) => _client.send( draft.id == null ? 'POST' : 'PUT', draft.id == null ? '/products' : '/products/${draft.id}', (json) => Product.fromJson(json! as Map), body: {'name': draft.name, 'price': draft.price}, ); } ``` And the keys the queries use, in one place: `lib/features/products/product_keys.dart`: ```dart /// Every key of the products feature, in one place. abstract final class ProductKeys { static final QueryKey all = QueryKey(const ['products']); /// Every list, whatever it was searched for — the prefix a write /// invalidates. static final QueryKey lists = all.append(const ['list']); static QueryKey list({String search = ''}) => lists.append([search]); static QueryKey detail(String id) => all.append(['detail', id]); static final QueryKey feed = all.append(const ['feed']); } ``` The app hands the implementation to its widgets once, in `main`: `lib/main.dart`: ```dart import 'package:flutter/material.dart'; import 'app/app.dart'; import 'data/api_client.dart'; import 'data/dio_product_api.dart'; void main() { final api = DioProductApi(ApiClient(baseUrl: 'https://api.example.com/v1')); runApp(CatalogueApp(api: api)); } ``` `CatalogueApp` is the root widget from [A global error snackbar](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/global-error-snackbar.md#the-finished-code): a `ProductApiScope` (a plain `InheritedWidget` holding the `ProductApi`), the `QueryClientProvider` and the `MaterialApp`. Any dependency-injection tool does the same job as `ProductApiScope`. ## How it works 1. **The query functions depend on `ProductApi`, not on dio.** A query's `queryFn` calls `api.list(signal: context.signal)` and never sees an HTTP type. That is what lets [Testing a screen](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/testing-a-screen.md) hand the same screens a fake that answers from memory, and what would let you swap dio for `package:http` without touching a query. 2. **Cancellation crosses the bridge in `_bridge`.** Every query function is handed a `QueryCancelToken` as `context.signal`. `signal.onCancel(token.cancel)` hands the library's cancel on to a dio `CancelToken`, so when the library cancels a fetch — `cancelQueries` was called, or the last reader left while it ran, as when a newer search replaces an older one — dio aborts the request. (Reading `context.signal` is what allows the second case: a query function that never reads it is left to finish when its readers go.) `onCancel` runs the callback at once if the signal is already cancelled. 3. **A cancelled request is rethrown untouched.** When dio reports `CancelToken.isCancel(error)`, the library cancelled the fetch itself and already knows. Translating that into an `ApiException` would turn a quiet cancel into a failure a toast could report. 4. **Everything else becomes an `ApiException`.** A 422 with field errors becomes a `ValidationException` (the form in [Forms and server validation](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/forms-and-server-validation.md) reads it), a body with a `message` keeps the server's wording, and the rest is a sentence per failure kind. The `status` survives, so the retry policy in [Auth and token refresh](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/auth-and-token-refresh.md#retry-only-what-can-succeed) can refuse to repeat a 4xx. 5. **A parse failure is a server failure.** `parse` runs inside the client, so a missing field or a wrong type fails the query with a readable message instead of a `TypeError`. 6. **Timeouts are set on `BaseOptions`.** Without a `receiveTimeout`, a server that accepts the connection and never answers leaves the query fetching — the library has no timeout of its own, as TanStack Query has none. ## The same client on package:http `package:http` 1.5 and later can abort a request: send an `AbortableRequest` and complete its `abortTrigger`. A `QueryCancelToken` has a future that completes on cancel, `whenCancelled`, which is exactly that trigger: `lib/data/http_api_client.dart`: ```dart import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:query_kit_flutter/query_kit_flutter.dart'; import 'api_exception.dart'; class HttpApiClient { HttpApiClient({required this.baseUrl, http.Client? client}) : _client = client ?? http.Client(); /// No trailing slash, as for dio: `https://api.example.com/v1`, and paths /// start with one, `/products`. (`Uri.resolve` would drop the `/v1`.) final String baseUrl; final http.Client _client; Future get( String path, T Function(Object? json) parse, { Map? query, QueryCancelToken? signal, }) async { final url = Uri.parse('$baseUrl$path'); final request = http.AbortableRequest( 'GET', query == null ? url : url.replace(queryParameters: query), // http 1.5 and later: the request is aborted when this completes. abortTrigger: signal?.whenCancelled, ); final http.Response response; try { // The timeout covers waiting for the headers and reading the body. response = await _client .send(request) .then(http.Response.fromStream) .timeout(const Duration(seconds: 30)); } on http.RequestAbortedException { rethrow; // cancelled by the library, which already knows } on http.ClientException { throw const ApiException('The server is unreachable'); } on TimeoutException { throw const ApiException('The server is not responding'); } // package:http does not throw for a 404 or a 500 — a status is just a // number on a response. A query only fails if its function throws. if (response.statusCode >= 400) { throw ApiException( 'The server said no (${response.statusCode})', status: response.statusCode, ); } try { return parse(jsonDecode(response.body)); } on Object { throw const ApiException('The server sent something unexpected'); } } } ``` ## Traps - **`package:http` does not throw for an error status.** A 404 or a 500 is a response like any other, and a query only fails when its function throws. A client that returns `jsonDecode(response.body)` without checking the status hands an error page to `fromJson`, or, worse, succeeds with it. - **`.timeout` stops waiting; it does not stop the request.** On `package:http` the request goes on until the server answers or the client is closed. For a real deadline on dio, use its timeouts, which do abort. - **Before `package:http` 1.5 there is no abort.** On an older version the request runs to the end on the wire whatever the library does. An explicit cancel (`cancelQueries`) still puts the entry back and drops the late answer; a query function that never reads `context.signal` is not even cancelled when its last reader leaves — the request finishes and its answer is cached. Upgrade rather than work around it. - **Do not create a `CancelToken` per client.** One shared token cancels every request the client ever makes. Make one per call, from that call's signal, as `_bridge` does. - **A mutation's signal is opt-in.** `mutationFn` receives only the variables, which is why `send` takes no `signal` here. A write that can be cancelled uses `mutationFnWithContext`, whose context carries a signal to bridge the same way — see [Cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md). ## Variations - **Several backends.** One `ApiClient` per base URL, and one `ProductApi`-style interface per feature. The queries still only see interfaces. - **Auth headers and token refresh** go into a dio interceptor on the same `Dio` — see [Auth and token refresh](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/auth-and-token-refresh.md). - **Logging.** dio's `LogInterceptor` on the client, not a `print` in each query function. > **Note: In React Query** > > The same split: a query function receives an `AbortSignal` and passes it to > `fetch` or axios. Here the signal is a `QueryCancelToken`, and the one-line > bridge in `_bridge` plays the part that `signal` plays for axios. The > [showcase](https://github.com/dualmeta-gmbh/query_kit/blob/main/examples/showcase/lib/shared/api.dart) > and the [task manager](https://github.com/dualmeta-gmbh/query_kit/blob/main/examples/task_manager/lib/src/api.dart) > both wire dio this way. ## See also - [Query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md) — what the library does when it cancels, and what the signal adds. - [Query functions](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-functions.md) — what a query function is handed and what it may throw. - [Query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md) — why the keys are a hierarchy. --- # Auth and token refresh > Refresh an expired token once for every request that hit it, never retry a refused request, and give each signed-in user a cache of their own. An access token expires while the app is open. The next few requests come back 401 at once — the list, its details, a background refetch — and each of them should wait for one refresh, then go again with the new token, without a screen ever seeing the 401. A refresh that fails means the session is over. Meanwhile the library's retry must not repeat a request the server refused, and when a different user signs in, nothing the last one loaded may show up on their screens. None of this belongs in a query function: the refresh sits in the transport, and the cache boundary sits in the widget tree. ## The finished code The tokens and the refresh call, on a `Dio` without the interceptor below: `lib/data/token_store.dart`: ```dart import 'package:dio/dio.dart'; import '../app/signed_in_shell.dart'; // signedInUser class TokenStore { TokenStore({required this.plainDio}); /// A Dio without the interceptor below, for the refresh and for the /// repeated request: neither may run into the interceptor again. final Dio plainDio; String? accessToken; String? refreshToken; Future refresh() async { final response = await plainDio.post>( '/auth/refresh', data: {'refreshToken': refreshToken}, ); accessToken = response.data!['accessToken']! as String; refreshToken = response.data!['refreshToken']! as String; } void signOut() { accessToken = null; refreshToken = null; signedInUser.value = null; } } ``` The interceptor that adds the token and handles a 401: `lib/data/auth_interceptor.dart`: ```dart import 'package:dio/dio.dart'; import 'token_store.dart'; /// Adds the access token to every request, and on a 401 refreshes it once /// and repeats the request. Queued: while one refresh runs, the other /// requests that failed wait for it instead of refreshing again. class AuthInterceptor extends QueuedInterceptor { AuthInterceptor(this._tokens); final TokenStore _tokens; @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { if (_tokens.accessToken case final token?) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } @override Future onError( DioException err, ErrorInterceptorHandler handler, ) async { // Every path below ends in exactly one handler call: a queued // interceptor waits for it before it takes the next error. if (err.response?.statusCode != 401) return handler.next(err); final request = err.requestOptions; // Another request may have refreshed while this one waited its turn. if (request.headers['Authorization'] == 'Bearer ${_tokens.accessToken}') { try { await _tokens.refresh(); } on Object catch (error) { // Refused, or an answer without tokens: the session is over. No // answer at all (no network) leaves the user signed in. if (error is! DioException || error.response != null) { _tokens.signOut(); } return handler.next(err); } } final token = _tokens.accessToken; if (token == null) return handler.next(err); // signed out meanwhile request.headers['Authorization'] = 'Bearer $token'; try { // The same options, so the same CancelToken: a query cancelled during // the refresh still aborts the repeat. handler.resolve(await _tokens.plainDio.fetch(request)); } on DioException catch (error) { handler.next(error); } } } Dio authenticatedDio(String baseUrl) { final dio = Dio(BaseOptions(baseUrl: baseUrl)); final tokens = TokenStore(plainDio: Dio(BaseOptions(baseUrl: baseUrl))); dio.interceptors.add(AuthInterceptor(tokens)); return dio; } ``` Hand the result to the client from [Wiring dio or package:http](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/wiring-dio-and-http.md): `ApiClient(baseUrl: url, dio: authenticatedDio(url))`. ### Retry only what can succeed `lib/app/retry.dart`: ```dart /// Retries what might succeed next time — a timeout, a 503 — and never a /// refused login or a request the server called wrong. const RetryPolicy retryTransientFailures = RetryPolicy.when(_isTransient); bool _isTransient(int failureCount, Object error, StackTrace _) => failureCount < 3 && !(error is ApiException && error.isClientError); ``` ### A cache per signed-in user `lib/app/signed_in_shell.dart`: ```dart /// Who is signed in: `null` while nobody is. Your auth layer owns this. final ValueNotifier signedInUser = ValueNotifier(null); class SignedInShell extends StatelessWidget { const SignedInShell({super.key, required this.child}); final Widget child; @override Widget build(BuildContext context) => ValueListenableBuilder( valueListenable: signedInUser, builder: (context, userId, _) { if (userId == null) return const SignInScreen(); // A new user is a new key, so a new client with an empty cache; // the old one is cleared once its subtree has gone. return QueryClientProvider.create( key: ValueKey(userId), create: () => QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults(retry: retryTransientFailures), ), ), child: child, ); }, ); } ``` ## How it works 1. **The refresh happens below the library.** By the time a query function's future completes, the interceptor has refreshed and repeated the request. The query sees one slow success, not a failure and a retry, so no screen flashes an error and no error callback fires. 2. **`QueuedInterceptor` makes the refresh happen once.** Its `onError` calls run one after another: the next starts only when the one before has called `next` or `resolve`. The first 401 refreshes; the ones queued behind it find that their request went out with an older token than the store now holds, skip the refresh and repeat at once. 3. **The repeat goes out on the plain `Dio`.** Sent through the intercepted `Dio`, a repeat that failed again — a second 401, a 500, a cancel — would queue its error behind the `onError` that is waiting for it, and both would wait for ever, with every later error on that `Dio` queued behind them. On the plain `Dio` a failed repeat simply throws, and its error is passed on. That also makes a request repeat at most once: the repeat never meets this interceptor. 4. **A failed refresh ends the session, once.** A refresh the server refused (or answered without tokens) signs out and passes the original 401 on; it never starts another refresh. A refresh that got no answer — no network — passes the 401 on and leaves the user signed in. 5. **The repeat keeps its `CancelToken`.** `fetch(request)` sends the same `RequestOptions`, with the new token set by hand, so a query the library cancels during the refresh still aborts the repeated request. Other interceptors on the main `Dio` (logging, say) do not see the repeat; add them to the plain one too if they must. 6. **`retryTransientFailures` refuses 4xx.** The library's default retries a failed query three times with a growing delay, whatever the error. A 403 or a 404 will be a 403 or a 404 again; the policy returns `false` for any `ApiException` with a 4xx status and keeps the default three for the rest. It is a `const` built from a top-level function, so it can sit in `DefaultOptions` and compares equal from build to build. 7. **A new user is a new `QueryClient`.** `QueryClientProvider.create` owns the client it creates. Keyed by the user's id, a different user gives a new provider, so a new client with an empty cache; the old provider leaves the tree with everything below it, and the client it owned is cleared after it unmounts. The new user's screens never read the old user's client. ## Signing out without a new client If one client lives for the whole app — created in `main`, say — clearing it on sign-out is the equivalent, in a fixed order: `lib/app/sign_out.dart`: ```dart Future signOutKeepingTheClient(QueryClient client) async { // 1. Leave the signed-in screens, so nothing reads a key any more … signedInUser.value = null; // … and let that frame run, so their observers are gone. await WidgetsBinding.instance.endOfFrame; // 2. Stop what is still in flight, then drop every entry and mutation. await client.cancelQueries(); client.clear(); } ``` `clear()` empties the caches; it does not stop the observers reading them. A screen still on the tree would find its entry gone and fetch it again — with no token. So the signed-in screens go first, the frame that removes them runs, and only then is the cache cleared. A mutation that was still pending is dropped by `clear()`: a paused one fails with a `CancelledError`, one whose request was already on its way settles with that request's outcome, and either way its callbacks run a few microtasks later. If that callback writes to the cache — an optimistic update's rollback does — it re-creates the entry it names. Where that matters, call `clear()` once more after the next frame. ## Traps - **Do not refresh in a query function.** Catching a 401 there, refreshing and calling the API again works for one query and races for five: each refreshes, and all but one refresh with a token that the first has already rotated. - **Do not retry a 401 through the library.** A retry is the same request with the same token after a delay. The interceptor is where a new token exists. - **Do not put the user id in every key instead.** `['user', id, 'products']` keeps two users apart in one cache, but every key in the app has to remember it, and the old user's entries stay in memory until their `gcTime` runs out. A client per user gets both right by construction. - **Paused mutations belong to the user who made them.** A mutation paused offline and then resumed after a sign-out would be sent with the next user's token. Clearing the client (or dropping it, as the keyed provider does) removes it. ## Variations - **Sign-out from the interceptor.** `TokenStore.signOut` sets `signedInUser` to `null`, which takes `SignedInShell` back to the sign-in screen and drops the old client, from anywhere. - **Several clients in one app.** A keyed provider can sit anywhere in the tree, not only at the root: an admin area that switches between tenants keys its provider by tenant the same way. > **Note: In React Query** > > React Query has no opinion on auth either, and the same split applies: an axios > interceptor for the refresh, `retry` as a function for the policy, and > `queryClient.clear()` on sign-out. The keyed provider is the Flutter form of > remounting `QueryClientProvider` with a new client under a `key`. ## See also - [Query retries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md) — `RetryPolicy`, its delays, and what the defaults are. - [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) — paused mutations and when they resume. - [Reading queries in widgets](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) — how a widget finds its client. --- # List to detail, seeded > Open a detail screen with the data the list already has — no spinner, no second request — and still refetch it when it is old. The list has just loaded twenty products, each with its name and price. The user taps one, and the detail screen shows a spinner while it asks the server for a product the app received a second ago. It should open with that product instead, fetch nothing if the list is fresh, and fetch in the background if the list is old — without the detail ever being treated as newer than the data it came from. Two techniques do it, and this recipe uses both: the list *pushes* each product into its detail entry when it loads, and the detail *pulls* from the lists when it opens on an entry nobody pushed. ## The finished code The list query, which writes every product it receives into that product's detail entry: `lib/features/products/product_queries.dart`: ```dart QueryObserverOptions> productListQuery( ProductApi api, { String search = '', }) => QueryObserverOptions>( queryKey: ProductKeys.list(search: search), queryFn: (context) async { final products = await api.list(search: search, signal: context.signal); // One entry per product, so the detail screen opens with data and // every later list response reaches the detail too. for (final product in products) { context.client .setQueryData(ProductKeys.detail(product.id), product); } return products; }, staleTime: const StaleTime.duration(Duration(seconds: 30)), ); ``` The detail query, with a fallback that looks through the lists already in the cache: `lib/features/products/product_queries.dart`: ```dart QueryObserverOptions productQuery( QueryClient client, ProductApi api, String id, ) => QueryObserverOptions( queryKey: ProductKeys.detail(id), queryFn: (context) => api.get(id, signal: context.signal), staleTime: const StaleTime.duration(Duration(seconds: 30)), // The fallback for an entry the list has not seeded yet — a deep // link, or a list still loading when the row was tapped. initialData: InitialData.compute(() => _findInLists(client, id)), // As old as the list it came from, so it is refetched when that is // stale rather than trusted as brand new. initialDataUpdatedAtCompute: () => _listUpdatedAt(client, id), ); Product? _findInLists(QueryClient client, String id) { for (final (_, products) in client.getQueriesData>( filters: QueryFilters(queryKey: ProductKeys.lists), )) { for (final product in products ?? const []) { if (product.id == id) return product; } } return null; } DateTime? _listUpdatedAt(QueryClient client, String id) { for (final (key, products) in client.getQueriesData>( filters: QueryFilters(queryKey: ProductKeys.lists), )) { if (products?.any((product) => product.id == id) ?? false) { return client.getQueryState>(key)?.dataUpdatedAt; } } return null; } ``` A row of the list, which opens the detail: `lib/features/products/product_tile.dart`: ```dart class ProductTile extends StatelessWidget { const ProductTile(this.product, {super.key}); final Product product; @override Widget build(BuildContext context) => ListTile( title: Text(product.name), subtitle: Text(formatPrice(product.price)), onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => ProductDetailScreen(id: product.id), ), ), ); } ``` And the detail screen, read through a `QueryBuilder`: `lib/features/products/product_detail_screen.dart`: ```dart class ProductDetailScreen extends StatelessWidget { const ProductDetailScreen({super.key, required this.id}); final String id; @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); final api = ProductApiScope.of(context); return QueryBuilder( options: productQuery(client, api, id), builder: (context, product) => Scaffold( appBar: AppBar(title: Text(product.dataOrNull?.name ?? 'Product')), body: switch (product) { QueryPending() => const Center(child: CircularProgressIndicator()), QueryError(:final error, staleData: null) => Center(child: Text('Could not load: $error')), QueryError(staleData: final data?) || QuerySuccess(:final data) => ProductDetails(data, refreshing: product.isFetching), }, ), ); } } ``` `ProductKeys` and `ProductApi` are from [Wiring dio or package:http](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/wiring-dio-and-http.md#the-finished-code). ## How it works 1. **The list pushes.** `context.client` is the client running the fetch. After the list arrives, `setQueryData` writes each product under `ProductKeys.detail(id)`, stamped with the time of the write. A detail opened in the next thirty seconds is fresh: it shows at once and fetches nothing. 2. **Every later list response reaches the details too.** A refetch of the list writes the products again, so a detail entry never shows a price older than the list next to it. 3. **The detail pulls when nothing was pushed.** `InitialData.compute` runs only when the detail's entry does not exist yet — a deep link, a detail entry that was garbage-collected while the list was kept, or a list that is still loading from another screen. It looks through every cached list with `getQueriesData` and returns the product if one of them has it. 4. **Pulled data is as old as its list.** `initialDataUpdatedAtCompute` returns the list's `dataUpdatedAt`. Initial data without it would be stamped *now*, and a product from a list loaded ten minutes ago would count as fresh. With it, the detail is stale when its list is, and refetches in the background while showing the seeded product. 5. **`null` means "not found".** When no list has the product, `compute` returns `null` and the query starts `pending`, like any first load. The screen's spinner is still there for that case. 6. **The screen shows what it has.** `QueryError` with `staleData` shows the product it already had; `product.isFetching` puts a thin progress bar over a background refetch. Try it in the demo's card A: open a post — it appears at once, seeded from the cached list, and the debug strip under it shows no fetch. Switch on "Treat initial data as old" and open another: the seed still shows at once, and a background fetch follows, because the seed is dated as old as its list. Live demo: [Initial and placeholder data](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/initial-and-placeholder), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/initial_and_placeholder)). Data before the first fetch: written to the cache, or shown only. ## Traps - **Seed from the list's type, not the widget's.** `getQueriesData>` checks the type of every entry it matches and throws a `QueryDataTypeError` for one that holds something else. That is why the endless feed's key (`ProductKeys.feed`, which holds pages) sits beside `ProductKeys.lists` rather than under it: under the prefix, the lookup would reach it and throw. - **`initialData` is only for an entry that does not exist.** When the detail entry is already in the cache — pushed by the list, or loaded before — `compute` never runs. It does not overwrite; it fills an empty slot. - **Pushing costs entries.** A list of 500 products writes 500 detail entries. They are cheap, and garbage-collected after `gcTime` when nothing reads them, but a list with large pages is a reason to pull only. - **A list row is not always a whole detail.** If the list endpoint sends a summary (name and price) and the detail has more (a description), seeding the detail with a summary shows a screen with holes. Use [placeholder data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md) from the list instead: it shows while the full detail loads, and is never cached as the detail. - **Keep the pushed product equal.** `Product` has value equality, so a refetch that returns the same product changes nothing and rebuilds nothing. ## Variations - **Pull only.** Drop the loop from the list's query function. Every detail is seeded when it opens, from whichever list has it; nothing is written up front. - **Push only.** Drop `initialData` from the detail. A deep link then loads with a spinner, which is often fine. - **Another call style.** `context.query(productQuery(...))`, the mixin's `watchQuery` or a `QueryController` read the same options; nothing on this page depends on the `QueryBuilder`. > **Note: In React Query** > > The same two approaches, often called push and pull: `queryClient.setQueryData` > in the list's `queryFn`, or `initialData` with `initialDataUpdatedAt` from > `getQueryState(...).dataUpdatedAt`. `InitialData.compute` is the function form > of `initialData`. ## See also - [Initial query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md#seeding-a-detail-from-a-list) — `initialData`, its timestamp, and the other seeding patterns. - [Updates from mutation responses](https://dualmeta-gmbh.github.io/query_kit/docs/guides/updates-from-mutation-responses.md) — the same write, after a save. - [Caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md) — `staleTime`, `gcTime`, and when an entry is fetched. --- # Lifecycle and connectivity wiring > One main.dart that tells the client when the app is in front and when the network is there — connectivity_plus, a reachability probe, a calmer focus refetch and a debug offline switch. Two facts decide when a query fetches on its own: whether the user is looking at the app, and whether the network is there. The first comes for free — `QueryClientProvider` follows the app lifecycle, so data that went stale while the app was in the background refetches when it comes back. The second is not installed at all: the client believes it is online until told otherwise, so a phone in a tunnel fails every fetch and burns its retries. This recipe wires both in `main`: connectivity from `connectivity_plus`, a lighter focus rule for an app the user switches away from often, a probe for when a link is not enough, and a switch to try the offline states from a debug build. ## The finished code `lib/main.dart`: ```dart import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:query_kit_flutter/query_kit_flutter.dart'; import 'app/query_client.dart'; import 'data/api_client.dart'; import 'data/dio_product_api.dart'; import 'data/product_api.dart'; import 'features/products/product_list_screen.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final connectivity = Connectivity(); bool isOnline(List results) => !results.contains(ConnectivityResult.none); // Built once, here: a stream made in `build` would be a new one on every // rebuild, and the provider would listen again each time. final changes = connectivity.onConnectivityChanged.map(isOnline); // What is true right now, so an app started in flight mode does not // fetch once against a network that is not there. final online = isOnline(await connectivity.checkConnectivity()); final api = DioProductApi(ApiClient(baseUrl: 'https://api.example.com/v1')); runApp( ProductApiScope( api: api, child: QueryClientProvider.create( create: createQueryClient, onlineStatus: OnlineStatus.stream(changes, initial: online), child: MaterialApp( scaffoldMessengerKey: scaffoldMessengerKey, home: const ProductListScreen(), ), ), ), ); } ``` `createQueryClient` is the client from [A global error snackbar](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/global-error-snackbar.md#the-finished-code), and `DioProductApi` the API from [Wiring dio or package:http](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/wiring-dio-and-http.md). This is `connectivity_plus` 6, whose events are lists of `ConnectivityResult`s — one per interface — so "online" means "any interface but none". ## How it works 1. **`OnlineStatus.stream` follows the link.** Every value the stream sends tells the client whether it is online. While it is not, a query in the default network mode does not fetch: one with nothing cached stays `pending` with `fetchStatus: paused`, one with data keeps showing it. A mutation started offline waits. 2. **`initial` is what is true at start.** A stream has no current value, and the first event can take a while. `checkConnectivity()` answers at once, so an app launched in flight mode starts offline instead of fetching once against no network. 3. **The stream is built once.** It is made in `main`, before `runApp`. A stream built in a `build` method would be a new stream on every rebuild, and the provider would listen again each time. `onConnectivityChanged` is a broadcast stream, so a remounted provider can listen again. 4. **Coming back online resumes.** When the stream says online again, paused fetches continue, paused mutations are sent, and active queries whose data is stale refetch (`refetchOnReconnect`, `RefetchOn.ifStale` by default). 5. **The lifecycle needs nothing.** The provider maps `AppLifecycleState` to the client's focus. Back in front, every active query whose data is stale refetches (`refetchOnWindowFocus`). ## A calmer focus refetch On a phone the user leaves the app for a notification and is back in five seconds. With short `staleTime`s that is a refetch of every screen, every time. `AppFocusManager` can ignore short absences: `lib/app/query_client.dart`: ```dart QueryClient createClientWithFocusRules() => QueryClient( // Back after less than a minute away? Not a reason to refetch the // world. Paused work still resumes at once. focusManager: AppFocusManager( refetchMinBackgroundDuration: const Duration(minutes: 1), ), ); ``` Back after less than a minute, focus-triggered refetches are skipped. Fetches that were paused still resume at once — they are waiting for the app, not refreshing it. ## When a link is not enough `connectivity_plus` reports a *link*: a phone on hotel wifi behind a captive portal is "connected", and so is one whose mobile data has run out. When that matters, ask your own backend: `lib/app/reachability.dart`: ```dart /// Asks [probe] — "can I reach my own backend?" — every [every], and says /// whenever the answer changes. Stream reachability( Future Function() probe, { Duration every = const Duration(seconds: 30), }) async* { bool? last; while (true) { final reachable = await probe(); if (reachable != last) yield last = reachable; await Future.delayed(every); } } ``` `probe` is a cheap request — `HEAD /health` with a short timeout, returning `false` on any error. The generator only sends a value when the answer changes. It is a single-subscription stream, so make it broadcast before it reaches a provider, and give it the link's verdict as its start: ```dart // pingBackend: Future Function() — HEAD /health, false on any error. final reachable = reachability(pingBackend).asBroadcastStream(); // … and in the provider, instead of the link's stream: onlineStatus: OnlineStatus.stream(reachable, initial: online), ``` ## A debug switch for offline Trying the offline states on a device means flight mode and waiting. In a debug build, a switch is quicker: `lib/debug/debug_online_switch.dart`: ```dart /// A developer's "pretend to be offline" switch, for trying the offline /// states without a flight-mode dance. final ValueNotifier simulateOffline = ValueNotifier(false); class DebugOnlineSwitch extends StatelessWidget { const DebugOnlineSwitch({ super.key, required this.client, required this.child, }); final QueryClient client; final Widget child; @override Widget build(BuildContext context) => ValueListenableBuilder( valueListenable: simulateOffline, builder: (context, offline, _) => QueryClientProvider( client: client, onlineStatus: OnlineStatus.fixed(!offline), child: child, ), ); } ``` In a debug build (`kDebugMode`), use `DebugOnlineSwitch` in place of the provider in `main` — with the client created there, `createQueryClient()` — rather than around it: two providers giving one client a verdict would overrule each other. Toggle `simulateOffline` from a debug menu. `OnlineStatus.fixed` is a verdict with no source of changes; a changed one reaches the client on the rebuild that changes it. Try it: turn "Online" off in the demo and press "Refetch" — the query pauses instead of failing. Turn it back on and the paused work continues. Live demo: [Offline](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/offline), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/offline)). Network modes, paused mutations, and coming back online. ## Traps - **Nothing is installed by default.** Without an `onlineStatus`, the client is online for ever. That is a safe default — a fetch that cannot reach the network fails and retries — but none of the pausing on this page happens. - **A link is not reachability.** A "connected" phone may reach nothing. Pair the link with a probe, or accept that a captive portal fails fetches instead of pausing them. - **Offline is not an error.** A paused query is `pending` or shows its data, with `isPaused` true and no error. A screen that shows a spinner for every `pending` spins until the network is back; show "Offline" when `isPaused`. - **A single-subscription stream fails on remount.** The provider listens again when it is rebuilt with a new stream or remounted. Pass a broadcast stream, or wrap it in `asBroadcastStream()`. - **One focus source.** The provider's lifecycle listener and a `setEventListener` of your own both write the client's focus. Turn one off (`observeAppLifecycle: false`) if you install the other. ## Variations - **Per-query network mode.** A query that talks to a local server or a device on the LAN can say `networkMode: NetworkMode.always` and ignore the online status altogether. See [Network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md). - **Pause polling in the background.** A `refetchInterval` stops while the app is not in front unless `refetchIntervalInBackground` says otherwise; see [Polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md). - **Desktop.** On macOS, Windows and Linux, a window that loses focus counts as unfocused; pass `isAppShown` to the provider if your app reads the lifecycle differently. > **Note: In React Query** > > React Native wires the same two managers by hand: > `onlineManager.setEventListener` with NetInfo, and `focusManager.setFocused` > from `AppState`. Here the focus side is built into the provider, and the > online side is one argument. ## See also - [Connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md) — `OnlineStatus` and its rules. - [App focus refetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md#skipping-refetches-after-a-short-absence) — the lifecycle mapping and `refetchMinBackgroundDuration`. - [Network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md) — what a paused query and a paused mutation do, and how they resume. --- # Pull to refresh > A RefreshIndicator whose spinner lasts exactly as long as the refetch, keeps the list on screen when the refresh fails, and works on an empty list too. The user pulls the list down; the spinner should stay until the new data is there — not vanish at once, not hang when the refresh fails — and a refresh that fails should leave the products they were looking at on screen, with a note that they may be out of date. The pull has to work on every state of the screen, including the spinner of a first load and the "no products yet" of an empty catalogue, where there is nothing to scroll. The library does most of this already: a query's `refetch` returns a future that completes when the fetch has settled, and a failed refetch keeps the last data as `staleData`. ## The finished code `lib/features/products/product_list_screen.dart`: ```dart class ProductListScreen extends StatelessWidget { const ProductListScreen({super.key}); @override Widget build(BuildContext context) { final products = context.query(productListQuery(ProductApiScope.of(context))); return Scaffold( appBar: AppBar(title: const Text('Products')), body: RefreshIndicator( // Completes when the refetch has settled, success or failure; it // never throws, so the spinner always goes away. onRefresh: products.refetch, child: switch (products) { QueryPending() => const _Scrollable( child: Center(child: CircularProgressIndicator()), ), QuerySuccess(:final data) => ProductListView(data), // A failed refresh keeps the list on screen, with a banner. QueryError(:final error, staleData: final data?) => ProductListView(data, problem: '$error'), QueryError(:final error) => _Scrollable( child: Center(child: Text('Could not load products: $error')), ), }, ), ); } } ``` The states with nothing to scroll are wrapped so the pull still works: `lib/features/products/product_list_screen.dart`: ```dart /// A `RefreshIndicator` only works over something that scrolls — also when /// there is nothing to show yet. class _Scrollable extends StatelessWidget { const _Scrollable({required this.child}); final Widget child; @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), child: SizedBox(height: constraints.maxHeight, child: child), ), ); } ``` `productListQuery` is defined in [List to detail, seeded](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/list-detail-seeding.md#the-finished-code), and `ProductListView` is a `ListView.builder` of `ProductTile`s with `AlwaysScrollableScrollPhysics`, plus a first row saying "Could not refresh" when `problem` is set. ## How it works 1. **`onRefresh: products.refetch` is the whole wiring.** `RefreshIndicator` wants a `Future Function()`, and `refetch` is one: it fetches again, stale or not, and completes with the new result once the fetch has settled. The spinner therefore lasts as long as the request. 2. **`refetch` never throws.** A failed fetch completes the future with a `QueryError` result rather than an error, so the indicator always gets its answer and the spinner always goes away. There is no `try` to write. 3. **A failed refresh keeps the data.** The result is a sealed type: a `QueryError` that follows a success carries the last good list as `staleData`. The screen matches that case before the plain error, and shows the list with a banner instead of replacing it with an error page. 4. **Every state is scrollable.** `RefreshIndicator` listens to a scrollable below it. The loading spinner and the empty state are put in a `SingleChildScrollView` with `AlwaysScrollableScrollPhysics`, sized to the viewport by a `LayoutBuilder`, so a first load that failed can be pulled again. 5. **A pull on a list already being fetched starts over.** `refetch` cancels a fetch that is running for a query with data and starts a new one (`cancelRefetch: true`, the default). Pass `cancelRefetch: false` in a lambda to join the running fetch instead: `onRefresh: () => products.refetch(cancelRefetch: false)`. ## Refreshing everything on the screen When one pull should refresh several queries — a dashboard, or a list and the counts in its header — refetch them through the client, by key prefix: `lib/features/products/refresh.dart`: ```dart /// Everything under `products` that is on screen, at once. Unlike an /// observer's `refetch`, it does not wait for a fetch paused offline. Future refreshProducts(BuildContext context) => QueryClientProvider.read(context).refetchQueries( filters: QueryFilters( queryKey: ProductKeys.all, type: QueryTypeFilter.active, ), ); ``` `type: QueryTypeFilter.active` limits it to the queries something on screen reads; the inactive ones are refetched when a screen reads them again, if they are stale. Use it as `onRefresh: () => refreshProducts(context)`. ## Traps - **The spinner can last longer than the request.** `refetch` waits for the fetch to *settle*, and a failing fetch retries first: with the default three retries and their growing delays, a pull on a dead server spins for about seven seconds before the banner appears. Give the query a shorter `retry`, or the [retry policy from the auth recipe](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/auth-and-token-refresh.md#retry-only-what-can-succeed), if that is too long. - **Offline, `refetch` waits for the network.** A fetch that starts while the client believes it is offline is paused, not failed, and the future waits for it to resume — the spinner stays until the connection is back. `refreshProducts` above behaves differently: `refetchQueries` does not wait for paused fetches, so its spinner ends at once. Pick the one that fits the screen; [Network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md) says when a fetch pauses. - **Two error reports for one failure.** With the [global error snackbar](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/global-error-snackbar.md) installed, a failed refresh of data on screen shows a toast *and* this screen's banner. Keep one: mark the list query silent (`meta: ErrorReporting.silent`), or drop the banner. - **`RefreshIndicator` needs a scrollable directly below it.** A `Column` with a `ListView` inside an `Expanded` works; a `Center` with a spinner does not, which is what `_Scrollable` is for. ## Variations - **Another call style.** The same screen reads as well through a `QueryBuilder`, the `QueryMixin` methods or a `QueryController` held by a view model — `refetch` is on each of them. A view model that owns a `QueryController` passes `controller.refetch` to the indicator the same way. - **An infinite list** refreshes the same way; see [An infinite list view](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/infinite-list-view.md) for how many pages it reloads. - **Cupertino.** `CupertinoSliverRefreshControl` takes the same `onRefresh`. > **Note: In React Query** > > React Native's `RefreshControl` is wired the same way: `refreshing` from > `isRefetching` and `onRefresh={refetch}`. Here `RefreshIndicator` keeps its own > spinner state from the future, so there is no flag to pass. ## See also - [Queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md) — the result types, `staleData` included. - [Background fetching indicators](https://dualmeta-gmbh.github.io/query_kit/docs/guides/background-fetching-indicators.md) — showing a refetch that the user did not ask for. - [Filters](https://dualmeta-gmbh.github.io/query_kit/docs/guides/filters.md) — what `QueryFilters` can match. --- # Search as you type > A debounced search box that asks the server nothing while empty, cancels the request a newer keystroke replaced, and keeps the last results on screen while the next arrive. A search field over the product list. Typing "kettle" should not send six requests, an empty field should send none, and a request for "ket" that is still on its way when the user has typed "kett" should be stopped rather than answered and thrown away. While the new results load, the old ones should stay on screen — dimmed, not replaced by a spinner that flickers on every keystroke. Four pieces do it: a debounce in the widget, a disabled query for the empty field, the cancellation signal, and `keepPrevious` placeholder data. ## The finished code The options: the list query, per search term, off while the term is empty. `lib/features/products/product_search.dart`: ```dart QueryObserverOptions> productSearchQuery( ProductApi api, String needle, ) => productListQuery(api, search: needle).copyWith( // An empty box asks the server nothing. enabled: needle.isEmpty ? Enabled.no : Enabled.yes, // While the new needle loads, keep showing the last results. placeholderData: const PlaceholderData>.keepPrevious(), ); ``` The screen, read through the `QueryMixin` methods: `lib/features/products/product_search.dart`: ```dart class ProductSearchScreen extends StatefulWidget { const ProductSearchScreen({super.key}); @override State createState() => _ProductSearchScreenState(); } class _ProductSearchScreenState extends State with QueryMixin { static const Duration debounce = Duration(milliseconds: 300); Timer? _debounce; String _needle = ''; void _onChanged(String text) { _debounce?.cancel(); _debounce = Timer(debounce, () { final needle = text.trim(); if (mounted && needle != _needle) setState(() => _needle = needle); }); } @override void dispose() { _debounce?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { // One `id`, so the read follows the needle from key to key and // `keepPrevious` has a previous to keep. final results = watchQuery( productSearchQuery(ProductApiScope.of(context), _needle), id: 'search', ); return Scaffold( appBar: AppBar( title: TextField( autofocus: true, onChanged: _onChanged, decoration: const InputDecoration(hintText: 'Search products'), ), bottom: results.isFetching ? const PreferredSize( preferredSize: Size.fromHeight(4), child: LinearProgressIndicator(), ) : null, ), body: _needle.isEmpty ? const Center(child: Text('Type to search')) : switch (results) { QueryPending() => const SizedBox.shrink(), QueryError(:final error) => Center(child: Text('$error')), QuerySuccess(data: []) => Center(child: Text('Nothing matches "$_needle"')), QuerySuccess(:final data) => Opacity( // Last needle's results, while this one loads. opacity: results.isPlaceholderData ? 0.5 : 1, child: ListView( children: [ for (final product in data) ProductTile(product), ], ), ), }, ); } } ``` `productListQuery` is the list query from [List to detail, seeded](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/list-detail-seeding.md#the-finished-code); its key is `ProductKeys.list(search: needle)`, so every term has an entry of its own. ## How it works 1. **The debounce lives in the widget.** The text field changes on every keystroke; `_needle` changes only after 300 ms without one. The query reads `_needle`, so the key — and with it the request — changes once per pause in the typing, not once per letter. 2. **A new term is a new key.** `ProductKeys.list(search: 'kett')` and `ProductKeys.list(search: 'ket')` are different entries. Going back to a term searched a moment ago shows its cached results at once, fresh for the list query's thirty-second `staleTime`. 3. **An empty field is a disabled query.** `Enabled.no` means the query never fetches on its own; the screen shows "Type to search" instead of reading the result. Clearing the box costs nothing. 4. **The superseded request is cancelled.** When the key changes, the reader moves on to the new entry and the old one has no reader left. Its query function consumed `context.signal` (the API client passes it to dio), so the library cancels the fetch and the bridge from [Wiring dio or package:http](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/wiring-dio-and-http.md) aborts the request on the wire. 5. **`keepPrevious` holds the last results.** While the new term's first fetch runs, the result is a `QuerySuccess` with the previous term's data and `isPlaceholderData` set. The list stays, at half opacity, and the progress bar in the app bar says something is on its way. 6. **One `id` for the read.** `watchQuery(..., id: 'search')` tells the mixin that the read for "ket" and the read for "kett" are the same read with a new key. Without it, a changed key would be a new read, and a new read has no previous data to keep. Try it in the demo's "Search as you type" card: type a few letters, pause, then type again before the answer arrives — `searchCancels` counts the searches stopped in flight. The demo's backend is in memory, with a deliberate delay. Live demo: [Cancellation](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/cancellation), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/cancellation)). A query cancelled is a request aborted. ## Traps - **Debouncing in the query function does not work.** A `Future.delayed` before the request makes every keystroke a fetch that waits, then runs; the entries still pile up, one per letter. Debounce the *key*. - **A query function that ignores the signal is not cancelled.** When the key moves on, the library cancels the old fetch only if its function read `context.signal`. One that never read it is left to finish: the request runs to the end and its answer is cached under the old term. Pass `context.signal` to the transport. - **Trim before comparing.** `'ket '` and `'ket'` are two keys and two requests for the same results; the screen trims the term once, where it sets `_needle`. - **Placeholder data is not cached data.** While `isPlaceholderData` is true, the rows belong to the *previous* term. Do not act on them as if they answered the current one — a "3 results for kett" header would be wrong for a moment. This screen dims them instead. - **The list query seeds the detail entries.** Every search response writes each product to its detail entry, which is what makes a tapped result open at once. That is useful here, but it means a search returning 200 products writes 200 entries; drop the seeding for a search that returns large pages. ## Variations - **Search on submit.** Drop the debounce and set `_needle` in the field's `onSubmitted`. The rest stays. - **A minimum length.** `enabled: needle.length < 2 ? Enabled.no : Enabled.yes` — and the matching message in the empty state. - **Another call style.** The same options work with `context.query` — the id there is a named argument too — with a `QueryBuilder`, whose observer already follows a changing key, or with a `QueryController` in a view model that owns the debounce; call `setOptions` on it with the new term. > **Note: In React Query** > > The same recipe, with `placeholderData: keepPreviousData` and a > `useDebounce` hook. A hook's identity comes from its position in the > component, so a changed key keeps the observer without asking; here the mixin > is told with `id:`. ## See also - [Disabling queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/disabling-queries.md#lazy-queries) — lazy queries and `Enabled`. - [Query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md) — what a cancel does to the entry, and what the signal adds. - [Placeholder query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md#keeping-the-previous-keys-data) — `keepPrevious` and `isPlaceholderData`. - [Reading queries in widgets](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) — the four call styles and what `id:` does. --- # Forms and server validation > An edit form driven by a mutation — disabled while saving, the server's field errors next to the fields, the cache updated from the response, and the screen closed on success. An edit form for a product. The app checks what it can (a name is required, a price is a number); the server checks the rest (the name is already taken) and answers 422 with an error per field. While the save is on its way the fields and the button are disabled; a refusal puts the server's message under the field it concerns, and typing into that field clears it; any other failure says so above the form; a success updates the cache and closes the screen. A mutation already holds every piece of state this needs — pending, the error, the saved product — so the form keeps none of its own. ## The finished code The mutation, with what a successful save does to the cache: `lib/features/products/product_mutations.dart`: ```dart MutationOptions saveProductMutation( QueryClient client, ProductApi api, ) => MutationOptions.simple( mutationKey: QueryKey(const ['products', 'save']), mutationFn: api.save, onSuccess: (product, _, __) { // The response is the product as saved: the detail has it now … client.setQueryData(ProductKeys.detail(product.id), product); // … and every list may have changed order or membership. return client.invalidateQueries( filters: QueryFilters(queryKey: ProductKeys.lists), ); }, ); ``` The form, reading the mutation through the `QueryMixin` methods: `lib/features/products/product_form_screen.dart`: ```dart class ProductFormScreen extends StatefulWidget { const ProductFormScreen({super.key, this.initial}); /// The product being edited, or `null` for a new one. final Product? initial; @override State createState() => _ProductFormScreenState(); } class _ProductFormScreenState extends State with QueryMixin { final GlobalKey _form = GlobalKey(); late final TextEditingController _name = TextEditingController(text: widget.initial?.name); late final TextEditingController _price = TextEditingController( text: widget.initial == null ? '' : '${widget.initial!.price / 100}', ); @override void dispose() { _name.dispose(); _price.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final save = watchMutation( saveProductMutation(queryClient, ProductApiScope.of(context)), ); final result = save.value; // The server's verdict, read off the mutation's state: no second copy // of it to keep in sync. final serverErrors = switch (result) { MutationError(error: ValidationException(:final fieldErrors)) => fieldErrors, _ => const {}, }; void submit() { if (!_form.currentState!.validate()) return; save.mutate( ProductDraft( id: widget.initial?.id, name: _name.text.trim(), price: (double.parse(_price.text) * 100).round(), ), callbacks: MutateCallbacks( // Runs only while this screen still listens, so the context is // still in the tree. onSuccess: (product, _, __) => Navigator.of(context).pop(product), ), ); } // Typing into a field the server refused clears the refusal. void edited(String _) { if (result.isError) save.reset(); } return Scaffold( appBar: AppBar( title: Text(widget.initial == null ? 'New product' : 'Edit product'), ), body: Form( key: _form, child: ListView( padding: const EdgeInsets.all(16), children: [ if (result case MutationError(:final error) when error is! ValidationException) Text('Could not save: $error'), TextFormField( controller: _name, enabled: !result.isPending, onChanged: edited, decoration: InputDecoration( labelText: 'Name', errorText: serverErrors['name'], ), validator: (value) => (value ?? '').trim().isEmpty ? 'Required' : null, ), TextFormField( controller: _price, enabled: !result.isPending, onChanged: edited, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration( labelText: 'Price', errorText: serverErrors['price'], ), validator: (value) => double.tryParse(value ?? '') == null ? 'A number' : null, ), const SizedBox(height: 16), FilledButton( onPressed: result.isPending ? null : submit, child: result.isPending ? const SizedBox.square( dimension: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save'), ), ], ), ), ); } } ``` The `ValidationException` comes from the API client in [Wiring dio or package:http](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/wiring-dio-and-http.md): a 422 whose body has an `errors` map becomes one, field by field. ## How it works 1. **Two kinds of validation, two places.** The `Form`'s `validator`s check what the app can know, before anything is sent. The server's verdict comes back as the mutation's error and is shown through each field's `errorText`. The two never compete: a field with a client-side problem is never sent. 2. **The field errors are derived, not stored.** `serverErrors` is a pattern match on the mutation's result: a `MutationError` whose error is a `ValidationException` yields its map, anything else an empty one. There is no `setState` that could miss a case. 3. **Typing clears the refusal.** `edited` calls `save.reset()` when the result is an error, which takes the mutation back to idle — and the derived `serverErrors` with it. 4. **Pending disables the form.** `result.isPending` turns off the fields and the button and puts a spinner in the button. A double tap cannot save twice. 5. **The response updates the cache.** The server answers with the product as saved. `onSuccess` in the options writes it to the detail entry, so the detail screen behind the form shows the new name without a request, and invalidates every list, whose order or membership may have changed. It returns the invalidation's future, so the mutation stays pending until the lists have refetched — the screen closes on current data. 6. **Closing is a per-call callback.** `onSuccess` in `MutateCallbacks` runs after the options' `onSuccess`, and only while this screen still listens to the mutation. If the user has already left, it does not run, and there is no `Navigator` call on a context that is gone. ## Awaiting instead of callbacks When the code that saves is not the widget that reads the mutation — a button elsewhere, or a view model — `mutateAsync` returns the saved product or throws: `lib/features/products/save_and_report.dart`: ```dart Future saveAndReport( MutationController save, ProductDraft draft, ScaffoldMessengerState messenger, ) async { try { final product = await save.mutateAsync(draft); messenger.showSnackBar(SnackBar(content: Text('Saved ${product.name}'))); } on ValidationException { // The form shows these next to the fields; nothing to add here. } on Object catch (error) { messenger.showSnackBar(SnackBar(content: Text('Could not save: $error'))); } } ``` `mutate` never throws, which is why the form uses it; `mutateAsync` does, so every call needs the `try`. ## Traps - **A mutation's error stays until something clears it.** Without the `reset` in `edited`, the server's "name is taken" would sit under the field while the user types a new name. `reset` is also how a form clears an error it showed when it is reopened with the same mutation. - **Do not keep the server errors in state.** Copying them into a field in `onError` means a second copy that has to be cleared on every retry, reset and reopening. Read them off the result. - **Do not navigate from the options' `onSuccess`.** It runs even when the form is gone, and it has no `BuildContext`. The cache update belongs there; the navigation belongs in the call's callbacks. - **The global error toast would fire too.** With the [global error snackbar](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/global-error-snackbar.md) installed, its mutation handler skips a `ValidationException` — the form shows it — but toasts every other failure. The form's own "Could not save" line then duplicates it; keep one of the two. - **Mutations are not retried by default.** A save that failed on a timeout fails at once. That is on purpose: repeating a write is rarely safe. Opt in with `retry:` on the options for an idempotent `PUT`. ## Variations - **Optimistic save.** For an edit that should appear before the server confirms it — a rename in place, a toggle — see [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md). - **A create form.** The same screen with `initial: null`: the draft has no id, the API client sends a `POST`, and the list invalidation brings the new product into the lists. - **Another call style.** The same mutation reads through a `MutationController` held by a view model, or through `context.mutation` or a `MutationBuilder` in a stateless widget. > **Note: In React Query** > > `useMutation` gives the same state: `isPending`, `error`, `reset`. Form > libraries like React Hook Form hold the client-side validation; here that is > Flutter's own `Form`, and the server's field errors are matched off the > mutation's sealed result. ## See also - [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) — `mutate`, `mutateAsync`, the callbacks and the order they run in. - [Updates from mutation responses](https://dualmeta-gmbh.github.io/query_kit/docs/guides/updates-from-mutation-responses.md) — writing the response into the cache. - [Invalidations from mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/invalidations-from-mutations.md) — what to invalidate after a write. --- # A global error snackbar > One place that turns failed background refreshes and failed saves into a SnackBar — once per failure, never for what a screen already shows, and with a per-query way out. A list the user is reading refreshes in the background, and the refresh fails. The list is still on screen and still correct as far as anyone knows, so the screen shows it as before — but the user should hear that it may be out of date. A save that fails behind a closed dialog should be reported too. Doing that in every screen is repetitive and easy to forget; doing it in a query's `queryFn` would report every retry. The caches take one `onError` each, which runs once per failure after the retries are spent: the place for a single toast. The work is in deciding what *not* to report. ## The finished code What a query or a mutation can tell the handler, through `meta`: `lib/app/error_reporting.dart`: ```dart /// What a query or a mutation tells the app-wide error handler. The library /// never reads `meta`; this app's handler does. @immutable class ErrorReporting { /// Toast failures, saying [message] instead of the generic sentence. const ErrorReporting.toast([this.message]) : show = true; const ErrorReporting._silent() : show = false, message = null; /// No toast: the screen shows this failure itself. static const ErrorReporting silent = ErrorReporting._silent(); final bool show; final String? message; } ``` The client, with a handler on each cache: `lib/app/query_client.dart`: ```dart final GlobalKey scaffoldMessengerKey = GlobalKey(); QueryClient createQueryClient() => QueryClient( queryCache: QueryCache( onError: (error, _, query) { // A first load has nothing on screen and shows its own error // state; a toast is for a refresh of data the user is looking at. if (!query.state.hasData) return; _toast(error, query.meta, fallback: 'Could not refresh'); }, ), mutationCache: MutationCache( onError: (error, _, __, ___, mutation) { // A form shows its field errors next to the fields. if (error is ValidationException) return; // A mutation with an `onError` of its own handles its failures. if (mutation.options.onError != null) return; _toast(error, mutation.meta, fallback: 'Could not save'); }, ), ); void _toast(Object error, Object? meta, {required String fallback}) { final reporting = meta is ErrorReporting ? meta : null; if (reporting?.show == false) return; final detail = error is ApiException ? error.message : 'Something went wrong'; scaffoldMessengerKey.currentState // Ten queries failing together — the network went — say it once. ?..hideCurrentSnackBar() ..showSnackBar( SnackBar(content: Text('${reporting?.message ?? fallback}: $detail'))); } ``` And the root widget, which hands the `MaterialApp` the messenger key: `lib/app/app.dart`: ```dart class CatalogueApp extends StatelessWidget { const CatalogueApp({super.key, required this.api}); final ProductApi api; @override Widget build(BuildContext context) => ProductApiScope( api: api, child: QueryClientProvider.create( create: createQueryClient, child: MaterialApp( scaffoldMessengerKey: scaffoldMessengerKey, home: const ProductListScreen(), ), ), ); } ``` ## How it works 1. **The caches' `onError` runs once per failure.** `QueryCache.onError` is called when a query's fetch has failed for good — after its retries — and not for each attempt. A cancelled fetch — a search the next keystroke replaced, a first load its reader left — is not a failure and does not call it. `MutationCache.onError` is the same for a mutation. 2. **A toast needs no `BuildContext`.** The handler lives in the client, far from any widget. A `GlobalKey` given to the `MaterialApp` reaches its messenger from anywhere, and `currentState` is `null` only before the app has built — then the toast is skipped. 3. **A first load is not toasted.** A query with no data has nothing on screen; its own error state is the report (the list screen's "Could not load products"). `query.state.hasData` tells the two cases apart. 4. **A form's field errors are not toasted.** A `ValidationException` is shown next to the fields by [the form](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/forms-and-server-validation.md), so the mutation handler skips it. 5. **A mutation with its own `onError` is not toasted.** The handler reads `mutation.options.onError`: if the mutation's options handle their failures, they know better than a generic sentence. 6. **`meta` is the per-query switch.** The library carries `meta` from the options to `query.meta` and `mutation.meta` and never reads it. This app reads it as an `ErrorReporting`: `toast('Could not refresh the catalogue')` changes the sentence, `ErrorReporting.silent` turns the toast off. 7. **Ten failures, one toast.** When the network goes, every active query fails at once. `hideCurrentSnackBar` before `showSnackBar` replaces the visible toast rather than queueing ten. A query that reports its own failures opts out: `lib/features/products/product_queries.dart`: ```dart QueryObserverOptions> quietProductListQuery(ProductApi api) => productListQuery(api).copyWith(meta: ErrorReporting.silent); ``` Try it: "Fetch a missing post" in the demo asks for a post that does not exist, with a `meta` that asks for a toast; the cache's handler reads it and shows the `SnackBar`. The log panel lists every cache callback as it runs. Live demo: [Global callbacks](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/global-callbacks), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/global_callbacks)). Cache-level callbacks, and meta on its way through. ## Traps - **Queries have no `onError` of their own.** Per-query `onSuccess`, `onError` and `onSettled` are not options; the cache-level callbacks replace them, and a screen reacts to a failure through the result it reads. - **The callbacks are constructor arguments.** A `QueryCache` gets its `onError` when it is built, so the client has to be built with the caches — here in `createQueryClient`, handed to `QueryClientProvider.create`. - **Pull-to-refresh reports twice.** A pulled refresh that fails gets a toast from here and a banner from the [pull-to-refresh screen](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/pull-to-refresh.md). Keep one: the screen's query can say `meta: ErrorReporting.silent`. - **Offline is mostly a pause, not a failure.** In the default network mode a fetch that starts while the client believes it is offline pauses instead of failing, and a failed attempt waits for the network before its next retry. Going into a tunnel therefore does not produce a toast per query; only a fetch whose retries are spent fails, and is toasted once. - **`meta` is typed `Object?`.** Anything can be there; the handler checks `meta is ErrorReporting` and treats anything else as "no preference". ## Variations - **Report to a crash reporter.** The same handler is the place to send unexpected errors — not `ApiException`s — to your error-reporting service, with the query's key as context. - **A success toast for mutations.** `MutationCache(onSuccess: ...)` with a `meta` that carries the sentence ("Saved") gives every mutation that asks for it the same confirmation. - **Sign out on 401.** When auth is not handled in the transport, the query handler can check `error is ApiException && error.status == 401` and sign out; [Auth and token refresh](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/auth-and-token-refresh.md) handles it lower down instead. > **Note: In React Query** > > The same pattern, and the same reasons: `new QueryCache({ onError })`, checking > `query.state.data !== undefined` before toasting, and `meta` to opt out. The > per-query `onError` was removed from `useQuery` in v5 for exactly this reason. ## See also - [Global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md) — every cache-level callback and the order they run in. - [Network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md) — when a fetch pauses instead of failing. - [Query retries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-retries.md) — how long a failure takes to reach the handler. --- # An infinite list view > A ListView that loads the next page as the user nears the end — once per page, with a footer that shows progress, a retry and the end of the list, and pull-to-refresh on top. The catalogue's "All products" screen pages through the whole range, twenty products at a time. The next page should load before the user reaches the end, exactly once per page however many scroll events arrive, with a spinner at the bottom while it does. A failed page should offer a retry without losing the pages above it; the last page should say so; and a first page too short to scroll should still have a way on. The library keeps the pages and knows whether there is a next one. The screen owns the scroll listener and the footer. ## The finished code The options: pages of products, flattened into one list by a `select`. `lib/features/products/product_feed.dart`: ```dart InfiniteQuerySelectOptions> productFeedQuery( ProductApi api, ) => InfiniteQuerySelectOptions>( queryKey: ProductKeys.feed, initialPageParam: 0, pageFn: (context) => api.page(context.pageParam, signal: context.signal), getNextPageParam: (page, _, __, ___) => page.nextOffset, select: _productsOf, ); /// A top-level function, not a closure: a new function on every build would /// run the select again on every build. List _productsOf(InfiniteData data) => [for (final page in data.pages) ...page.items]; ``` The screen, with an `InfiniteQueryBuilder` and a scroll listener: `lib/features/products/product_feed_screen.dart`: ```dart class ProductFeedScreen extends StatefulWidget { const ProductFeedScreen({super.key}); @override State createState() => _ProductFeedScreenState(); } class _ProductFeedScreenState extends State { static const double loadMoreThreshold = 400; final ScrollController _scroll = ScrollController(); /// The builder's controller, for the scroll listener. The builder owns it. InfiniteQueryController>? _feed; /// How long the list was when the last page was asked for. double? _askedAtExtent; @override void initState() { super.initState(); _scroll.addListener(_onScroll); } @override void dispose() { _scroll.dispose(); super.dispose(); } void _onScroll() { final feed = _feed; if (feed == null || !_scroll.hasClients) return; final position = _scroll.position; if (position.extentAfter < loadMoreThreshold && // Once per length of the list: the listener hears every pixel. position.maxScrollExtent != _askedAtExtent && feed.hasNextPage && !feed.isFetchingNextPage) { _askedAtExtent = position.maxScrollExtent; feed.fetchNextPage().ignore(); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('All products')), body: InfiniteQueryBuilder( options: productFeedQuery(ProductApiScope.of(context)), builder: (context, feed) { _feed = feed; return switch (feed.value) { QueryPending() => const Center(child: CircularProgressIndicator()), QueryError(:final error, staleData: null) => Center(child: Text('Could not load: $error')), QuerySuccess(:final data) || QueryError(staleData: final data?) => RefreshIndicator( onRefresh: feed.refetch, child: ListView.builder( controller: _scroll, physics: const AlwaysScrollableScrollPhysics(), // One more row than there are products: the footer. itemCount: data.length + 1, itemBuilder: (context, index) => index < data.length ? ProductTile(data[index]) : FeedFooter(feed), ), ), }; }, ), ); } } ``` The footer — the list's last row: `lib/features/products/feed_footer.dart`: ```dart class FeedFooter extends StatelessWidget { const FeedFooter(this.feed, {super.key}); final InfiniteQueryController> feed; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.all(16), child: Center( child: switch (( feed.isFetchingNextPage, feed.isFetchNextPageError, feed.hasNextPage, )) { (true, _, _) => const CircularProgressIndicator(), (_, true, _) => TextButton( onPressed: feed.fetchNextPage, child: const Text('Could not load more — try again'), ), // Also the way on when the first page does not fill the screen, // so there is nothing to scroll. (_, _, true) => TextButton( onPressed: feed.fetchNextPage, child: const Text('Load more'), ), _ => const Text("That's everything"), }, ), ); } ``` `ProductPage` is the API's answer — a page of products and the offset the next page starts at, `null` on the last one — and `ProductApi.page` is its call in [Wiring dio or package:http](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/wiring-dio-and-http.md#the-finished-code). ## How it works 1. **The server says where the next page starts.** `getNextPageParam` returns the page's `nextOffset`; `null` means there is no next page, which sets `hasNextPage` to false. The library asks for the first page with `initialPageParam`, and for each next one with what the last page said. 2. **`select` flattens the pages.** The cache holds `InfiniteData`, a list of pages and the param each was fetched with. The widget wants one list of products; `_productsOf` makes it, and the builder receives `List`. 3. **The listener asks near the end.** `extentAfter` is how much list is left below the viewport. Under 400 pixels, the listener asks for the next page — early enough that the rows are usually there before the user reaches them. 4. **Once per page.** A scroll listener fires for every pixel. Before asking, it checks that the list has grown since the last ask (`maxScrollExtent` against `_askedAtExtent`), that there is a next page, and that one is not being fetched already. The first check is what stops a burst of scroll events from asking twice between the ask and the new rows. 5. **The controller comes from the builder.** `InfiniteQueryBuilder` owns its controller and hands it to `builder`; the screen keeps a reference for the listener, which runs outside `build`. `fetchNextPage` is on the controller, not on the result, because it is an action and the result is a value. 6. **The footer is a row like any other.** `itemCount` is one more than the products; the last index builds `FeedFooter`, which switches on three flags: fetching (a spinner), a failed next page (a retry button — the pages above stay), a next page to load (a button, for the list too short to scroll), or the end. 7. **Pull-to-refresh reloads what is held.** `feed.refetch` fetches every page the list holds again, first to last, each from the param the page before it now gives, so the list stays consistent if products were added. Try it: scroll the demo's list to the bottom — the next page loads as you near it, and "Load more" does the same by hand. Leave with "Go to about" and come back: the pages are still cached. Live demo: [Load more and infinite scroll](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/load-more), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/load_more)). An infinite query that appends pages as you scroll. ## Traps - **Guard on the list's length, not on a pixel offset.** A check like "within 400 pixels of the end" is true for every scroll event until the new rows arrive, and `isFetchingNextPage` only turns true once the fetch has been started. Comparing `maxScrollExtent` with the extent at the last ask is what makes the ask happen once per page. - **`select` must be a top-level function or a static method.** A closure written inside `build` is a new function on every build; the library cannot tell it is the same, and runs it again for every rebuild. A top-level function is the same object every time. - **A first page that does not fill the screen never scrolls.** No scroll, no listener call. The footer's "Load more" is the way on; without it, a tall screen with twenty short rows shows a list that never grows. - **Refetching many pages is many requests.** A list scrolled to page 30 and then invalidated refetches thirty pages, one after another. See `maxPages` below. - **Keep the infinite key out of plain list prefixes.** `ProductKeys.feed` holds `InfiniteData`, not `List`. Under the `lists` prefix, a `getQueriesData>` over that prefix — as the [detail seeding](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/list-detail-seeding.md) does — would reach it and throw. ## Variations - **Cap the pages.** `maxPages: 10` keeps at most ten pages: an eleventh drops the first, and a refetch reloads ten, not thirty. Pages dropped from the top come back only through `getPreviousPageParam` and `fetchPreviousPage`, and the rows above the viewport disappear — so this suits a list that is also loaded upward, not a plain endless scroll. - **Only a button.** Drop the scroll listener; the footer's "Load more" alone is a complete, simpler screen. - **Another call style.** `context.infiniteQuery`, the mixin's `watchInfiniteQuery` or an `InfiniteQueryController` held by the state read the same options. With the controller in hand from the start, `_feed` becomes a `late final` field. - **Cursor pagination.** Make the page param a `String?` cursor instead of an offset; nothing else changes. > **Note: In React Query** > > `useInfiniteQuery` with the same four options. On the web the trigger is > usually an `IntersectionObserver` on a sentinel element; in Flutter it is a > `ScrollController` listener, or a footer row that asks when it is built. ## See also - [Infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md#scroll-triggered-loading) — the options, both directions, and `maxPages`. - [Paginated queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/paginated-queries.md) — numbered pages instead of an endless list. - [Scroll restoration](https://dualmeta-gmbh.github.io/query_kit/docs/guides/scroll-restoration.md) — coming back to the same place in the list. - [Pull to refresh](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/pull-to-refresh.md) — the refresh gesture on an ordinary query. --- # Testing a screen > Widget tests for screens that read queries — a fake API with latency, a harness with the teardown built in, and tests for loading, errors, seeded data, staleness and a refused save. The screens in the other recipes read queries and mutations against a `ProductApi`. A widget test for one of them should run the real screen, the real options and a real `QueryClient` — only the network replaced — and it should be able to say *when* things happen: that a spinner shows first, that a tapped row opens without a request, that data older than thirty seconds is fetched again. The library's timers and the fake's latency run on the test's fake clock, so none of this needs a real wait. What needs care is the beginning and the end of each test: a client with no retries, and a teardown that leaves no timer behind. ## The finished code A fake of the API, with latency and a record of every call: `test/fake_product_api.dart`: ```dart /// Answers from memory, after [latency], and records every call. class FakeProductApi implements ProductApi { FakeProductApi({List? products}) : products = products ?? [ const Product(id: 'p1', name: 'Kettle', price: 3900), const Product(id: 'p2', name: 'Toaster', price: 4900), ]; List products; Duration latency = const Duration(milliseconds: 100); /// Thrown by the next call instead of answering, then forgotten. Object? failNext; final List calls = []; Future _answer(String call, T Function() body) async { calls.add(call); await Future.delayed(latency); if (failNext case final failure?) { failNext = null; throw failure; } return body(); } @override Future> list({String search = '', QueryCancelToken? signal}) => _answer('list $search', () => List.of(products)); @override Future get(String id, {QueryCancelToken? signal}) => _answer('get $id', () => products.firstWhere((p) => p.id == id)); @override Future page(int offset, {QueryCancelToken? signal}) => _answer('page $offset', () { final items = products.skip(offset).take(20).toList(); final next = offset + items.length; return ProductPage( items: items, nextOffset: next < products.length ? next : null, ); }); @override Future save(ProductDraft draft) => _answer('save', () { final saved = Product( id: draft.id ?? 'p${products.length + 1}', name: draft.name, price: draft.price, ); products = [ for (final p in products) if (p.id != saved.id) p, saved, ]; return saved; }); } ``` A harness: a fresh fake and client per test, the app's wiring, and the teardown. `test/harness.dart`: ```dart /// `testWidgets` with a fresh fake, a client without retries, the app's /// wiring around [home] — and the teardown a `QueryClient` needs. void screenTest( String description, Widget home, Future Function(WidgetTester tester, FakeProductApi api) body, ) { testWidgets(description, (tester) async { final api = FakeProductApi(); final client = QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults(retry: RetryPolicy.never), ), ); try { await tester.pumpWidget(ProductApiScope( api: api, child: QueryClientProvider( client: client, child: MaterialApp(home: home), ), )); await body(tester, api); } finally { await tester.pumpWidget(const SizedBox()); await tester.pumpAndSettle(); client.clear(); await tester.pump(); client.clear(); } }); } ``` ## The tests A first load: the spinner, then the rows, after one request. `test/product_list_screen_test.dart`: ```dart screenTest('shows a spinner, then the products', const ProductListScreen(), (tester, api) async { expect(find.byType(CircularProgressIndicator), findsOneWidget); await tester.pump(api.latency); // the fake's latency is a timer expect(find.text('Kettle'), findsOneWidget); expect(find.text('Toaster'), findsOneWidget); expect(api.calls, ['list ']); }); ``` A failure and an empty answer, each with its own message: `test/product_list_screen_test.dart`: ```dart screenTest('a first load that fails says so', const ProductListScreen(), (tester, api) async { api.failNext = const ApiException('The server is down'); await tester.pump(api.latency); expect( find.text('Could not load products: The server is down'), findsOneWidget, ); }); screenTest('an empty catalogue says so', const ProductListScreen(), (tester, api) async { api.products = []; // read when the fake answers await tester.pump(api.latency); expect(find.text('No products yet'), findsOneWidget); }); ``` The detail screen opens from the list's data, with no request of its own — the [seeding recipe](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/list-detail-seeding.md), proved: `test/product_detail_test.dart`: ```dart screenTest('a tapped row opens with no request of its own', const ProductListScreen(), (tester, api) async { await tester.pump(api.latency); await tester.tap(find.text('Kettle')); await tester.pumpAndSettle(); // the route transition is frames expect(find.text('€39.00'), findsOneWidget); expect(api.calls, ['list ']); // no 'get p1' }); ``` Staleness, with fake time: after thirty-one seconds the seeded detail is stale, so opening it again shows it *and* fetches it. `test/product_detail_test.dart`: ```dart screenTest('data older than its staleTime is refetched behind the rows', const ProductListScreen(), (tester, api) async { await tester.pump(api.latency); await tester.tap(find.text('Kettle')); await tester.pumpAndSettle(); // Thirty seconds of fake time: the detail is stale now. await tester.pump(const Duration(seconds: 31)); api.products = [ const Product(id: 'p1', name: 'Kettle', price: 3500), ...api.products.skip(1), ]; await tester.pageBack(); await tester.pumpAndSettle(); await tester.tap(find.text('Kettle')); await tester.pump(); // a stale entry mounts: it shows, and refetches expect(api.calls.last, 'get p1'); await tester.pumpAndSettle(); expect(find.text('€35.00'), findsOneWidget); }); ``` A save the server refuses, with its field error under the field — the [form recipe](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/forms-and-server-validation.md): `test/product_form_screen_test.dart`: ```dart screenTest('a refused save shows the server\'s field errors', const ProductFormScreen(), (tester, api) async { await tester.enterText(find.byType(TextFormField).at(0), 'Kettle'); await tester.enterText(find.byType(TextFormField).at(1), '39'); api.failNext = const ValidationException({ 'name': 'A product with this name exists', }); await tester.tap(find.text('Save')); await tester.pump(); // pending: the button spins expect(find.text('Save'), findsNothing); await tester.pump(api.latency); expect(find.text('A product with this name exists'), findsOneWidget); expect(api.calls, ['save']); }); ``` ## How it works 1. **The fake replaces the network, nothing else.** `FakeProductApi` implements the same `ProductApi` the dio client does, so the screen, its options and the client are the ones the app runs. `calls` records every request, which is how a test says "no request was made". 2. **Latency is a timer.** The fake waits `latency` before it answers, with a plain `Future.delayed`. In a widget test that runs on the fake clock: `tester.pump(api.latency)` moves time forward exactly that far, and the answer arrives. `pumpAndSettle` does not do it — it pumps only while a frame is scheduled, and a pending timer is not a frame. 3. **A failure is set before it happens.** `failNext` makes the next call throw the given error. Because the fake reads it only when it answers, a test can also set it after the call has started. 4. **No retries in tests.** The harness's client says `RetryPolicy.never`. With the default three retries, a failing request would take seconds of fake time and three more calls before the error shows. 5. **Time moves the cache too.** `staleTime` and `gcTime` are measured with the library's clock, which the test binding fakes as well. `tester.pump(const Duration(seconds: 31))` is thirty-one seconds for the detail's `dataUpdatedAt` — the stale-time test takes no real time. 6. **The teardown is the harness's `finally`.** A `QueryClient` outlives the widget tree and owns `gcTime` timers, and the test binding fails a test that ends with a timer pending — before any `tearDown` runs. So the tree comes down (`pumpWidget(const SizedBox())`), the frames settle, the client is cleared, and one more pump and clear catch what a dropped mutation's callbacks wrote. ## Traps - **One client per test.** A client shared across tests carries one test's cache into the next. The harness creates both the fake and the client inside `testWidgets`. - **`pumpAndSettle` does not wait for the fake.** It returns as soon as no frame is scheduled, with the request still on its timer. Step time with `tester.pump(duration)`, then settle the frames it caused. - **Assert on the calls, not only the screen.** "The detail shows the price" is true with or without a request. `api.calls` is what proves the seeding. - **A route transition shows both screens.** Right after a tap, the old and the new route are both on the tree. `pumpAndSettle` after the tap lets the transition finish before a finder looks for a single match. - **Values in the fake are read when it answers.** `api.products = []` and `api.failNext` set in the test body still reach the first request, which `pumpWidget` has already started, because the fake builds its answer after the latency. `latency` is not: the first request is already waiting with the old one. A test that needs another latency from the start gets it from a harness parameter. ## Variations - **The same cases against the real backend.** A list of cases run against the fake *and* against a local copy of the server keeps the two from drifting apart. The [task manager](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/task_manager) and the [showcase](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase) both have one, `backend_contract_test.dart`. - **Testing a query without widgets.** A query's options are plain values: in a unit test, `await client.query(productListQuery(fake))` runs the query function against the fake and returns what the screen would receive. - **Another call style.** The harness does not care which one the screen uses; the tests on this page drive a `context.query` screen, a `QueryBuilder` one and a `QueryMixin` form through the same harness. > **Note: In React Query** > > The same advice as React Query's testing guide: a new `QueryClient` per test, > `retry: false`, and a mocked network layer. Flutter's fake clock replaces > `waitFor`: time is stepped rather than awaited. ## See also - [Testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md) — the teardown snippet on its own, and why each line of it is there. - [Caching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md) — `staleTime` and `gcTime`, which the stale-time test steps through. - [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) — the result a refused save leaves behind. --- # Next to Riverpod, Bloc or Provider > Keep server state in query_kit and app state in your state-management package, and connect the two through QueryController without keeping a second copy. **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` that follows one query while something listens to it. [Does this replace state management?](https://dualmeta-gmbh.github.io/query_kit/docs/guides/does-this-replace-state-management.md) 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. `lib/data/project_queries.dart`: ```dart QueryObserverOptions projectQuery(String id) => QueryObserverOptions( queryKey: ProjectKeys.detail(id), queryFn: (context) => projectApi.get(id, signal: context.signal), staleTime: const StaleTime.duration(Duration(seconds: 30)), ); QueryObserverOptions> 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. `lib/features/projects/projects_view_model.dart`: ```dart 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> 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(); } @override void dispose() { projects.dispose(); super.dispose(); } } ``` The screen listens to both parts of the model: the filter, and the controller's results. `lib/features/projects/projects_page.dart`: ```dart @override Widget build(BuildContext context) => ListenableBuilder( // Rebuilds for either kind of state: a new filter, or a new result. listenable: Listenable.merge([model, model.projects]), builder: (context, _) => Column( children: [ SegmentedButton( segments: const >[ ButtonSegment(value: 'open', label: Text('Open')), ButtonSegment(value: 'archived', label: Text('Archived')), ], selected: {model.filter}, onSelectionChanged: (selection) => model.showFilter(selection.single), ), for (final project in model.projects.value.dataOrNull ?? const []) ListTile(title: Text(project.name)), ], ), ); ``` The view model is created in a `State` with `QueryClientProvider.read(context)` and disposed with it. [Dependency injection](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/dependency-injection.md) 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. `lib/app/query_client.dart`: ```dart final queryClientProvider = Provider((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. `lib/features/projects/project_notifier.dart`: ```dart final projectProvider = NotifierProvider.autoDispose .family, String>(ProjectNotifier.new); class ProjectNotifier extends Notifier> { ProjectNotifier(this.id); final String id; @override QueryResult 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 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. `lib/features/projects/project_cubit.dart`: ```dart class ProjectCubit extends Cubit> { factory ProjectCubit(QueryClient client, String id) => ProjectCubit._(QueryController.create(client, projectQuery(id))); ProjectCubit._(this._project) : super(_project.value) { _project.addListener(_publish); } final QueryController _project; void _publish() => emit(_project.value); Future refresh() => _project.refetch(); @override Future close() { _project ..removeListener(_publish) ..dispose(); return super.close(); } } ``` `lib/features/projects/project_page.dart`: ```dart BlocProvider( create: (context) => ProjectCubit(QueryClientProvider.read(context), projectId), child: BlocBuilder>( 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. `lib/features/projects/project_page.dart`: ```dart ChangeNotifierProvider( create: (context) => QueryController.create( QueryClientProvider.read(context), projectQuery(projectId), ), child: const ProjectHeader(), ) // Anywhere below: final project = context.watch>().value; ``` ## Steps 1. Write each query's options once, as a function of its inputs (`lib/data/…_queries.dart`). 2. Put one `QueryClientProvider` above the app, even when your package holds the client. The four built-in call styles need it. 3. For each query your store needs, create one `QueryController` in the store's unit (Notifier, Cubit, ChangeNotifierProvider). Dispose it together with that unit. 4. Keep only the inputs in the store. When an input changes, call `setOptions` with the new options. Do not copy the data into the store. ## Traps - **Copying the result into the store.** A `state = controller.value` that also saves `data` in a second field creates two truths. The copy does not change when a background refetch does. Publish the `QueryResult` itself. - **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 that `watch`es something that changes, or a `create` that runs more than once) starts over with an empty cache. The client is created once, per app or [per signed-in user](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/sign-out-and-multi-account.md). - **Mutations through the store.** A write can be a store method that calls `client.invalidateQueries(...)` afterwards, or a [mutation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md) read with `context.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](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md) 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.create` takes options without a `select` only. The controller then publishes the selected value, and [render optimisations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md) 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. Live demo: [Four call styles](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/four-call-styles), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/four_call_styles)). The same query through context, builder, mixin and controller. > **Note: In React Query** > > 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](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Offline first, and surviving a restart > Save chosen queries and unsent writes to disk, restore them before the first frame, and let paused writes go out when the network comes back. **The problem.** A notes app is used on a train. The user opens it without a connection and expects yesterday's notes, not a spinner. They add a note, and it must reach the server eventually, even if the app is killed first. When the app restarts on a good connection, the old list shows at once and then refreshes. **What query_kit gives you, and what it does not.** The cache is in memory. There is no built-in persister and no `dehydrate`/`hydrate`. What the library provides are the points to connect one to: - `setQueryData(key, data, updatedAt: …)` puts saved data back **with the date it was fetched**, so staleness works as it did before the restart. - `queryCache.subscribe` reports every successful fetch, which tells you when to save. - `mutationCache.build(client, options, state: …)` restores an unsent write as a paused mutation, and `resumePausedMutations()` sends it. This recipe connects them to a key-value store in about a hundred lines. ## The client `lib/app/query_client.dart`: ```dart QueryClient buildClient() => QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults( // Try once even offline — the HTTP layer may answer from its own // disk cache — and pause instead of retrying. networkMode: NetworkMode.offlineFirst, // Keep unobserved entries for a day, so a restored snapshot is // still there when the screen that reads it opens. gcTime: GcTime.duration(Duration(hours: 24)), ), mutations: MutationDefaults( // Said out loud: the query default above does not reach mutations. // `online` pauses a write made offline and sends it on reconnect. networkMode: NetworkMode.online, ), ), ); ``` Query and mutation defaults are separate. A `networkMode` set for queries does not apply to mutations, so this client sets both. [Network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md) covers all three modes. ## The store Anything that stores a string under a name can back this: shared_preferences, a file, a database. `lib/data/snapshot_store.dart`: ```dart /// Where a snapshot lives: shared_preferences, a file, a database. abstract interface class SnapshotStore { Future read(String name); Future write(String name, String value); } ``` With shared_preferences (2.3 or later), the adapter is a few lines: `lib/data/prefs_store.dart`: ```dart class PrefsStore implements SnapshotStore { PrefsStore(this._prefs); final SharedPreferencesAsync _prefs; @override Future read(String name) => _prefs.getString('query_kit.$name'); @override Future write(String name, String value) => _prefs.setString('query_kit.$name', value); } ``` ## Saving and restoring queries A `PersistedQuery` names one key and knows how to turn its data into JSON and back. The cache only ever sees your typed models. `lib/data/persisted_query.dart`: ```dart /// One query that survives a restart, and how its data becomes JSON. class PersistedQuery { const PersistedQuery({ required this.name, required this.key, required Object? Function(T data) toJson, required T Function(Object? json) fromJson, }) : _toJson = toJson, _fromJson = fromJson; final String name; final QueryKey key; final Object? Function(T data) _toJson; final T Function(Object? json) _fromJson; Object? save(Query query) => { 'savedAt': query.state.dataUpdatedAt!.millisecondsSinceEpoch, 'data': _toJson(query.state.data as T), }; void restore(QueryClient client, Object? saved, {required Duration maxAge}) { if (saved is! Map) return; final savedAt = DateTime.fromMillisecondsSinceEpoch(saved['savedAt']! as int); if (DateTime.now().difference(savedAt) > maxAge) return; // Dated when it was fetched, not now: staleTime counts from there, so // a screen that reads it refetches old data as usual. client.setQueryData(key, _fromJson(saved['data']), updatedAt: savedAt); } } ``` The persister restores a snapshot before the app starts, then records every new success of a listed key. It writes at most once a second. `lib/data/query_persister.dart`: ```dart class QueryPersister { QueryPersister(this.client, this.store, this.queries); final QueryClient client; final SnapshotStore store; final List> queries; final Map _snapshot = {}; void Function()? _unsubscribe; Timer? _flush; /// Before `runApp`: put what was saved back into the cache. Future restore({Duration maxAge = const Duration(days: 1)}) async { final raw = await store.read('queries'); if (raw == null) return; final saved = jsonDecode(raw) as Map; _snapshot.addAll(saved); for (final query in queries) { query.restore(client, saved[query.name], maxAge: maxAge); } } /// Then: save every new success of a listed key, at most once a second. void start() { _unsubscribe = client.queryCache.subscribe((event) { if (event case QueryUpdated(:final query, action: QuerySuccessAction())) { for (final persisted in queries) { if (persisted.key == query.queryKey) { _snapshot[persisted.name] = persisted.save(query); _flush ??= Timer(const Duration(seconds: 1), _write); } } } }); } void _write() { _flush = null; store.write('queries', jsonEncode(_snapshot)).ignore(); } void stop() { _unsubscribe?.call(); _flush?.cancel(); } } ``` ## Saving and restoring writes The mutation has a key so that its pending entries can be found. The new note's id is created on the device. A write the server received just before the app died will be sent again, and the server needs to recognise it as the same note, not a second one. `lib/data/note_mutations.dart`: ```dart final QueryKey addNoteKey = QueryKey(['addNote']); MutationOptions addNoteMutation(QueryClient client) => MutationOptions.simple( mutationKey: addNoteKey, mutationFn: notesApi.add, onSuccess: (_, __, ___) => client.invalidateQueries(filters: QueryFilters(queryKey: notesKey)), ); ``` Functions cannot be saved, so only the variables are. On restore, each variable becomes a pending mutation again, rebuilt with the same options created in code. `lib/data/paused_writes.dart`: ```dart class PausedWrites { PausedWrites(this.client, this.store); final QueryClient client; final SnapshotStore store; String? _last; /// Before `runApp`: rebuild every write that had not gone through. Each /// comes back pending and paused, with the options built in code again — /// a function cannot be saved, and does not have to be. Future restore() async { final raw = await store.read('addNote'); if (raw == null) return; for (final json in jsonDecode(raw) as List) { client.mutationCache.build( client, client.defaultMutationOptions(addNoteMutation(client)), state: MutationState( status: MutationStatus.pending, variables: NewNote.fromJson(json! as Map), hasVariables: true, ), ); } // Online, they go now; offline, each waits for the reconnect. client.resumePausedMutations().ignore(); } /// Then: keep the list of unfinished writes on disk. void start() { client.mutationCache.subscribe((_) { final waiting = [ for (final mutation in client.mutationCache.findAll( filters: MutationFilters(mutationKey: addNoteKey), )) if (mutation.state.status == MutationStatus.pending) (mutation.state.variables! as NewNote).toJson(), ]; final json = jsonEncode(waiting); if (json == _last) return; _last = json; store.write('addNote', json).ignore(); }); } } ``` ## Wiring it up in `main` `lib/main.dart`: ```dart Future main() async { WidgetsFlutterBinding.ensureInitialized(); final store = MemoryStore(); // your shared_preferences adapter final client = buildClient(); final persister = QueryPersister(client, store, >[ PersistedQuery>( name: 'notes', key: notesKey, toJson: (notes) => [for (final n in notes) n.toJson()], fromJson: (json) => [ for (final n in json! as List) Note.fromJson(n! as Map), ], ), ]); final writes = PausedWrites(client, store); await persister.restore(); await writes.restore(); persister.start(); writes.start(); runApp(QueryClientProvider(client: client, child: const NotesApp())); } ``` ## Steps 1. Choose which keys survive a restart. Usually these are the lists a user opens first, not every detail entry. 2. Restore **before** `runApp`. A screen that mounts first starts its own fetch, and the restored data arrives too late to help. 3. Keep `gcTime` at least as long as a snapshot's `maxAge`. A restored entry has no observer until its screen opens, and after `gcTime` without one it is garbage collected. 4. For writes, give each one an id created on the device and make the server treat a repeated id as the same write. ## Traps - **Restoring with `updatedAt: now`.** The data then counts as just fetched. Every screen trusts it for its whole `staleTime` and does not refetch yesterday's list. Always pass the saved date. - **Persisting everything.** A snapshot of every entry includes other users' data after an account switch, and errors are not worth restoring. Save successes of listed keys only, and delete the snapshot at sign-out (see [Sign out and multiple accounts](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/sign-out-and-multi-account.md)). - **Calling `mount()` and expecting it to resume.** A mounted client resumes paused mutations on reconnect and on focus, not when it is mounted. The `restore` above calls `resumePausedMutations()` itself. When the device is online at that moment, the writes go out immediately. Otherwise each one waits, because the call only runs mutations that may run now. - **`offlineFirst` for writes.** It makes one attempt even when the device is offline, and only a *retry* waits for the network. Mutations do not retry by default, so a POST that fails that way ends in an error instead of waiting. Writes that should wait for the network belong in `online`. ## Variations - **Restoring through the cache instead of `setQueryData`.** `client.queryCache.build(client, client.defaultQueryOptions(options), state: QueryState(...))` restores a whole `QueryState`, including its error count. The cache ignores `state` when the key already exists. - **Several mutation kinds.** Give each kind its own key and its own saved list. On restore, rebuild each kind with its own options. - **Nothing to persist, only offline.** When the app only has to survive going offline, the client's defaults are enough. [Connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md) connects a connectivity stream so that the client knows when it is offline. ## See it run In the offline demo, switch *Online* off, type a todo and press *Add todo*. The write pauses and waits, and the list keeps its data. Switch it back on and the write goes out, followed by the refetch its success triggers. Live demo: [Offline](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/offline), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/offline)). Network modes, paused mutations, and coming back online. > **Note: In React Query** > > TanStack Query ships `persistQueryClient`, storage persisters and > `dehydrate`/`hydrate`. query_kit has none of these. It has the parts shown > here: `setQueryData` with `updatedAt`, `QueryCache.build` with a state, > `MutationCache.build` with a state and `resumePausedMutations`, so that the > snapshot format belongs to your app. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Where the client lives > Create the QueryClient once, provide it with QueryClientProvider, reach it with of, maybeOf and read, and register it in get_it when code outside the tree needs it. **The problem.** Every widget that reads a query needs *the* client: one cache, created once. A repository, a push-notification handler or a background sync may need it too, and those are not widgets. Tests need a fresh client each time. Get this wrong and you have two caches that disagree, or a cache that empties on every rebuild. **The recipe.** The client belongs to a `QueryClientProvider` at the root of the tree. Widgets reach it through the provider. Code outside the tree receives the same instance through whatever you already use for injection, and the tree is still wrapped in the provider. ## Let the provider own it `lib/main.dart`: ```dart runApp( QueryClientProvider.create( create: () => QueryClient(defaultOptions: appDefaults), child: const ProjectsApp(), ), ); ``` `QueryClientProvider.create` calls `create` once and keeps the client for as long as the provider is mounted. After the provider unmounts, it clears the client. Rebuilding with a different `create` callback keeps the same client. To replace the client, give the provider a new `key`. The unnamed constructor, `QueryClientProvider(client: …)`, takes a client you created yourself and **never** clears it. Use it when something else owns the client: `main` itself, get_it, a Riverpod provider or a test. Both constructors *mount* the client while they are in the tree. Mounting makes app-lifecycle focus drive refetch-on-focus and defers notifications that arrive during a build. Connectivity is added only if you pass `onlineStatus` (see [Connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md)). ## Three ways to look it up `lib/features/projects/project_card.dart`: ```dart class ProjectCard extends StatefulWidget { const ProjectCard({super.key, required this.id}); final String id; @override State createState() => _ProjectCardState(); } class _ProjectCardState extends State { // `read`: no dependency, so it is allowed in initState and in callbacks. late final QueryController project = QueryController.create( QueryClientProvider.read(context), projectQuery(widget.id), ); @override void dispose() { project.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // `of`: in build, where depending on the provider is what you want. final client = QueryClientProvider.of(context); return ValueListenableBuilder( valueListenable: project, builder: (context, result, _) => ListTile( title: Text(result.dataOrNull?.name ?? '…'), onTap: () => client.invalidateQueries( filters: QueryFilters(queryKey: ProjectKeys.detail(widget.id)), ), ), ); } } ``` | Lookup | Subscribes? | Without a provider | Use in | |---|---|---|---| | `QueryClientProvider.of(context)` | yes | throws | `build` | | `QueryClientProvider.maybeOf(context)` | yes | returns `null` | `build`, in widgets that can do without one | | `QueryClientProvider.read(context)` | no | throws | `initState`, callbacks | `maybeOf` is for code that has to work in an app without query_kit, such as a design-system package: `packages/design_system/lib/fetching_bar.dart`: ```dart /// From a shared design-system package: it shows a thin progress bar under a /// QueryClientProvider, and nothing in an app that has none. class FetchingBar extends StatelessWidget { const FetchingBar({super.key}); @override Widget build(BuildContext context) { final client = QueryClientProvider.maybeOf(context); if (client == null) return const SizedBox.shrink(); return _FetchingBar(client: client); } } ``` ## A client per signed-in user A new key gives a new client. That is the whole implementation of "each user starts with an empty cache": `lib/app/signed_in_scope.dart`: ```dart class SignedInScope extends StatelessWidget { const SignedInScope({super.key, required this.userId, required this.child}); final String userId; final Widget child; @override Widget build(BuildContext context) => QueryClientProvider.create( // Another user is another key: the old provider is disposed and its // client cleared, and the subtree starts over on an empty cache. key: ValueKey(userId), create: () => QueryClient(defaultOptions: appDefaults), child: child, ); } ``` [Sign out and multiple accounts](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/sign-out-and-multi-account.md) builds the rest of the flow around it. ## Code outside the tree: get_it This section targets **get_it 8**. Register the instance, give it a `dispose` that clears it, and hand the same instance to the provider: `lib/main.dart`: ```dart final getIt = GetIt.instance; void main() { getIt.registerSingleton( QueryClient(defaultOptions: appDefaults), dispose: (client) => client.clear(), ); getIt.registerLazySingleton( () => ProjectRepository(getIt()), ); runApp( QueryClientProvider(client: getIt(), child: const ProjectsApp()), ); } ``` A repository can then call `client.query(...)`, `setQueryData` or `invalidateQueries` on the cache that the screens read. For example, a push handler that learns project `p1` changed calls `getIt().invalidateQueries(...)`, and every screen showing that project refetches. The same shape works with injectable, with a Riverpod `Provider` (see [Next to Riverpod, Bloc or Provider](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/riverpod-bloc-provider.md)) or with a plain top-level `final`. The rule is one instance, handed to exactly one `QueryClientProvider` at the root. ## Tests: a client per test Nothing about a test client is special. Create it in the test, hand it to the unnamed constructor, and end with the teardown from the [testing guide](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md), because a client owns `gcTime` timers that outlive the widget tree: `test/project_screen_test.dart`: ```dart testWidgets('the project screen shows the project', (tester) async { // A client per test: nothing cached leaks from one test into the next. final client = QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults(retry: RetryPolicy.never), ), ); await tester.pumpWidget(QueryClientProvider( client: client, child: const MaterialApp(home: ProjectScreen(id: 'p1')), )); await tester.pumpAndSettle(); expect(find.text('3 open tasks'), findsOneWidget); // The testing guide's teardown. await tester.pumpWidget(const SizedBox()); await tester.pumpAndSettle(); client.clear(); await tester.pump(); client.clear(); }); ``` Turning retries off in the test's defaults makes an error case fail at once instead of after the retry backoff. ## Steps 1. Create the client in exactly one place: `QueryClientProvider.create`, `main`, or your injector's registration. 2. Wrap the app in one `QueryClientProvider`, even when an injector holds the client. 3. In widgets, use `of` in `build` and `read` everywhere else. 4. In tests, create a new client per test and tear it down. ## Traps **A client in `build`.** This replaces the cache every time the root rebuilds: `lib/main.dart`: ```dart // Wrong: every rebuild makes a new client — a new, empty cache — and the // screens below lose everything they had loaded. @override Widget build(BuildContext context) => QueryClientProvider(client: QueryClient(), child: const ProjectsApp()); ``` Use `QueryClientProvider.create`, or create the client once outside `build`. - **`of` in `initState`.** It subscribes to the provider, and Flutter does not allow subscribing to an inherited widget from `initState`. `read` does not subscribe. - **Two providers, two clients.** A nested `QueryClientProvider` with its own client hides the outer one from its subtree. That is right for a per-user scope and wrong by accident: a query invalidated through the outer client is not refetched below. - **Expecting the unnamed constructor to clean up.** It does not clear a client it was given. Whoever created it clears it: get_it's `dispose`, the test's teardown, or your sign-out code. > **Note: In React Query** > > This is `QueryClientProvider` and `useQueryClient()`. `of` corresponds to > `useQueryClient`, and `read` has no React counterpart because hooks cannot > run outside render. React's advice to create the client outside the > component, or once in state, is `QueryClientProvider.create` here. See > [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Routing with go_router > Derive query keys from path parameters, prefetch before a route opens, refetch when the user comes back, and read queries in dialogs. **The problem.** `/projects/p1` must open on a cold start from a deep link, on a tap from the project list and on the back button. Each time it should show project `p1` with no extra request when the data is fresh and no spinner when it could have been avoided. When the user comes back to the list after editing, the list should be current. **The recipe.** The URL carries the *id* and the cache carries the *data*. A screen takes its id from the route and reads its query from the id, so a deep link and a tap produce the same screen. The navigation code can warm the cache early and ask for a refresh on return. It never passes the data to the screen as an argument. ## The screen reads by id `lib/features/projects/project_screen.dart`: ```dart class ProjectScreen extends StatelessWidget { const ProjectScreen({super.key, required this.id}); final String id; @override Widget build(BuildContext context) { final project = context.query(projectQuery(id)); return Scaffold( appBar: AppBar(title: Text(project.dataOrNull?.name ?? '')), body: switch (project) { QueryPending() => const Center(child: CircularProgressIndicator()), QueryError(:final error) => Center(child: Text('$error')), QuerySuccess(:final data) => Center(child: Text('${data.openTasks} open tasks')), }, ); } } ``` ## The routes This section targets **go_router 14 or later**. The route passes the path parameter to the screen, and nothing else: `lib/app/router.dart`: ```dart final router = GoRouter( observers: [routeObserver], routes: [ GoRoute( path: '/projects', builder: (context, state) => RefetchOnReturn( queryKey: ProjectKeys.all, child: ProjectListScreen(), ), routes: [ GoRoute( path: ':id', builder: (context, state) => ProjectScreen(id: state.pathParameters['id']!), ), ], ), ], ); ``` Query parameters work the same way. A filter in the URL (`state.uri.queryParameters['filter']`) is an input to the query's key, just like a path parameter. ## Warm the cache before the route opens A tap already knows which project comes next. Start the fetch before the push. The screen's read then joins that fetch, or finds it finished: `lib/features/projects/project_tile.dart`: ```dart class ProjectTile extends StatelessWidget { const ProjectTile(this.project, {super.key}); final Project project; @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); return ListTile( title: Text(project.name), onTap: () { // Start the fetch before the route animates in; the screen's read // joins it, or finds it done. client.query(projectQuery(project.id)).ignore(); Navigator.of(context).push(MaterialPageRoute( builder: (_) => ProjectScreen(id: project.id), )); }, ); } } ``` With go_router, replace the `Navigator.push` with `context.push('/projects/${project.id}')`. `client.query` returns the data, and `.ignore()` is what makes the call a prefetch: a failure is dropped here and the screen reports it when it reads. A second tap within the query's `staleTime` fetches nothing. [Prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md) covers the other places to start one. ## Refresh when the user comes back A pushed route does not unmount the one below it. The list keeps its observers, and nothing refetches it when the detail pops, because popping is neither a focus change nor a remount. A `RouteAware` widget turns "the route above me was popped" into a refetch of stale entries: `lib/app/refetch_on_return.dart`: ```dart final RouteObserver> routeObserver = RouteObserver>(); /// Refetches [queryKey]'s stale entries when the route it sits in becomes /// visible again — when the route pushed over it is popped. class RefetchOnReturn extends StatefulWidget { const RefetchOnReturn( {super.key, required this.queryKey, required this.child}); final QueryKey queryKey; final Widget child; @override State createState() => _RefetchOnReturnState(); } class _RefetchOnReturnState extends State with RouteAware { @override void didChangeDependencies() { super.didChangeDependencies(); final route = ModalRoute.of(context); if (route != null) routeObserver.subscribe(this, route); } @override void dispose() { routeObserver.unsubscribe(this); super.dispose(); } @override void didPopNext() { QueryClientProvider.read(context) .refetchQueries( filters: QueryFilters( queryKey: widget.queryKey, type: QueryTypeFilter.active, stale: true, ), ) .ignore(); } @override Widget build(BuildContext context) => widget.child; } ``` Register `routeObserver` on the navigator: `GoRouter(observers: [...])` as above, `MaterialApp(navigatorObservers: [...])` without go_router. Each `ShellRoute` has its own navigator and takes its own `observers:`. The filter is `stale: true` and `type: active`. A list that is still fresh is left alone, and entries no screen shows are not fetched. ## Dialogs and bottom sheets A dialog is a route of its own. Give it a widget whose `build` reads the query, so that the read belongs to the dialog's context: `lib/features/projects/project_dialog.dart`: ```dart Future showProjectDialog(BuildContext context, String id) => showDialog( context: context, // A widget of its own, reading through its own context: subscribed for // as long as the dialog is open, and rebuilt when the project changes. builder: (_) => ProjectDialog(id: id), ); class ProjectDialog extends StatelessWidget { const ProjectDialog({super.key, required this.id}); final String id; @override Widget build(BuildContext context) { final project = context.query(projectQuery(id)); return AlertDialog( title: Text(project.dataOrNull?.name ?? '…'), content: Text('${project.dataOrNull?.openTasks ?? '–'} open tasks'), ); } } ``` Reading through the *caller's* `context` inside `builder: (_) => …` would attach the read to the screen behind the dialog, and the dialog would not rebuild when the data changes. The rule is that a read belongs to the element whose `context` it goes through. ## Steps 1. Give every screen an id-shaped constructor (`ProjectScreen(id:)`) and let it read its own query. 2. In routes, pass only path and query parameters. 3. Prefetch in tap handlers, with `.ignore()`. 4. Wrap list screens in `RefetchOnReturn`, and register the observer on every navigator that shows one. ## Traps - **Passing the object through `extra`.** `context.push(…, extra: project)` shows the copy made at tap time, even after an edit. It is also missing on a deep link and after a browser refresh, because `extra` is not part of the URL. Pass the id. To show the list's copy while the detail loads, seed the detail entry from the list (see [Initial query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md)). - **Awaiting the prefetch.** `await client.query(…)` before navigating blocks the tap for the length of the request, and throws if the request fails. The point is to start early, not to wait. - **Invalidating on every pop.** `invalidateQueries` in `didPopNext` refetches even fresh data, on every back gesture. Refetch what is stale. - **A `redirect` that fetches.** A top-level `redirect` runs on every navigation. `client.getQueryData(...)` is cheap and synchronous there. `await client.query(...)` in a `redirect` holds up every route change until the request returns. ## Variations - **Tabs with `StatefulShellRoute`.** Every branch keeps its screens mounted, so their queries stay observed. Refetch-on-focus and refetch-on-reconnect apply to all of them, shown or not. Give a heavy tab's query `refetchOnWindowFocus: RefetchOn.never` when that request is not worth making for a tab nobody is looking at, and pair it with `RefetchOnReturn` or a refetch when the tab is selected. - **Scroll position on return.** A pushed route keeps the list below it alive, so popping needs nothing. For tabs, see [Scroll restoration](https://dualmeta-gmbh.github.io/query_kit/docs/guides/scroll-restoration.md). ## See it run In the prefetching demo, press a row's prefetch button and then open that post. It shows at once and costs no request. A post you open without prefetching costs one. Live demo: [Prefetching](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/prefetching), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/prefetching)). Warm the cache before the screen that needs it opens. > **Note: In React Query** > > Router integrations there call `ensureQueryData` or `prefetchQuery` in a > route loader. go_router has no loaders, so the prefetch goes in the tap > handler, and `client.query(options)` covers `fetchQuery`, `prefetchQuery` > and `ensureQueryData` in one method. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Realtime updates over a WebSocket > Let server events write to the cache or invalidate it, keep the socket's lifetime in one widget, and resynchronise after a reconnect. **The problem.** An order-tracking screen shows orders whose status changes on the server: packing, shipped, delivered. The server pushes those changes over a WebSocket. Polling every few seconds would waste requests and still be late. But the screens already read orders through queries, and a second data path from socket to widget would duplicate the loading, error and caching logic. **The recipe.** The socket does not feed widgets. It feeds **the cache**. Every event becomes one of two cache operations: - **Write** when the event carries the whole entity: `updateQueryData` puts it in place, and every reader of that key rebuilds with no request. - **Invalidate** when the event only says *something changed*, or when the change affects entries the client cannot recompute (which lists contain the order, and in what position). Invalidation refetches what is on screen and marks the rest stale. Queries stay the only way widgets read. The socket only keeps them current. ## The events Parse the socket's frames into a sealed type, so that the handler below is an exhaustive `switch`: `lib/data/server_events.dart`: ```dart sealed class ServerEvent { const ServerEvent(); } /// An order changed, and the event carries all of it. final class OrderChanged extends ServerEvent { const OrderChanged(this.order); final Order order; } /// An order is gone. final class OrderRemoved extends ServerEvent { const OrderRemoved(this.id); final String id; } /// Events may have been missed — the socket reconnected. final class Resync extends ServerEvent { const Resync(); } ``` ## The query `lib/data/order_queries.dart`: ```dart QueryObserverOptions orderQuery(String id) => QueryObserverOptions( queryKey: OrderKeys.detail(id), queryFn: (context) => ordersApi.get(id, signal: context.signal), // The socket keeps it fresh. The stale time is only the safety net // for a socket that went quiet without anybody noticing. staleTime: const StaleTime.duration(Duration(minutes: 5)), ); ``` With a socket keeping the entry current, `staleTime` no longer decides how fresh the data is. It remains as a limit on how long a silent socket can go unnoticed before a focus or a remount refetches anyway. ## Events into the cache `lib/data/realtime_sync.dart`: ```dart class RealtimeSync { RealtimeSync(this.client); final QueryClient client; StreamSubscription listen(Stream events) => events.listen(apply); void apply(ServerEvent event) { switch (event) { case OrderChanged(:final order): // The whole order is in the event: write it, unless the cache // already holds the same version or a newer one. client.updateQueryData( OrderKeys.detail(order.id), (cached) => cached != null && cached.version >= order.version ? null : order, ); // Which lists it belongs to, and where, is the server's business. client .invalidateQueries(filters: QueryFilters(queryKey: OrderKeys.lists)) .ignore(); case OrderRemoved(:final id): client .invalidateQueries(filters: QueryFilters(queryKey: OrderKeys.lists)) .ignore(); client .invalidateQueries( filters: QueryFilters(queryKey: OrderKeys.detail(id))) .ignore(); case Resync(): client .invalidateQueries(filters: QueryFilters(queryKey: OrderKeys.all)) .ignore(); } } } ``` The version check matters. An event can arrive while a refetch of the same order is in flight. Whichever lands last wins, and without the check an older event could overwrite newer data. Returning `null` from the updater leaves the cache untouched. ## The socket This section targets **web_socket_channel 3**. The adapter turns frames into `ServerEvent`s and reconnects when the socket closes. After every reconnect it emits a `Resync`, because events sent while it was disconnected are lost. `lib/data/order_socket.dart`: ```dart class OrderSocket { OrderSocket(this.uri); final Uri uri; final _events = StreamController.broadcast(); WebSocketChannel? _channel; bool _closed = false; Stream get events => _events.stream; Future connect({Duration backoff = const Duration(seconds: 1)}) async { var first = true; while (!_closed) { try { final channel = WebSocketChannel.connect(uri); await channel.ready; _channel = channel; if (!first) _events.add(const Resync()); first = false; backoff = const Duration(seconds: 1); await for (final frame in channel.stream) { _events.add(parseEvent(jsonDecode(frame as String))); } } catch (_) { // Fall through to the reconnect below. } if (_closed) break; await Future.delayed(backoff); backoff = backoff * 2 > const Duration(seconds: 30) ? const Duration(seconds: 30) : backoff * 2; } } Future close() async { _closed = true; await _channel?.sink.close(); await _events.close(); } } ``` `parseEvent` maps your server's JSON to the three event classes. ## Scope the subscription to the tree One widget owns the subscription. It goes below the `QueryClientProvider` and above every screen that shows orders: `lib/app/live_orders.dart`: ```dart /// Keeps the cache in step with [events] for as long as it is mounted. Put it /// below the QueryClientProvider and above the screens. class LiveOrders extends StatefulWidget { const LiveOrders({super.key, required this.events, required this.child}); final Stream events; final Widget child; @override State createState() => _LiveOrdersState(); } class _LiveOrdersState extends State { late final StreamSubscription _subscription; @override void initState() { super.initState(); _subscription = RealtimeSync(QueryClientProvider.read(context)).listen(widget.events); } @override void dispose() { _subscription.cancel().ignore(); super.dispose(); } @override Widget build(BuildContext context) => widget.child; } ``` `lib/main.dart`: ```dart final socket = OrderSocket(Uri.parse('wss://api.example.com/orders')); socket.connect().ignore(); runApp( QueryClientProvider.create( create: QueryClient.new, child: LiveOrders(events: socket.events, child: const OrdersApp()), ), ); ``` ## Steps 1. Parse frames into a sealed event type. 2. For each event, decide between writing and invalidating. Write only what the event fully describes. Invalidate the rest. 3. Guard writes with a version or timestamp from the server. 4. After a reconnect, invalidate everything the socket covers. 5. Subscribe in one widget below the provider and cancel in its `dispose`. ## Traps - **Setting list data from an item event.** Inserting a changed order into every cached list means recomputing filters, sort order and paging on the client. Doing that wrong leaves lists that disagree with the server until the next refetch. Invalidate the lists instead: only the ones on screen refetch. - **Forgetting the gap.** A socket that reconnects silently has missed events. Without the `Resync`, the cache stays wrong until each entry goes stale. - **Writing entries nobody reads.** `updateQueryData` on a key with no entry creates one. That is harmless, because an unobserved entry is garbage collected after `gcTime`, and useful, because opening that order is then instant. To avoid it, return `null` when `cached` is `null`. - **`staleTime: StaleTime.infinite` with a socket.** It looks right, since the socket keeps data fresh. But when the socket is down, nothing ever refetches. A finite stale time is the safety net. ## Variations - **Events that carry only ids.** When an event says only "order 42 changed", invalidate `OrderKeys.detail('42')` as well as the lists. The detail refetches if it is on screen and is marked stale otherwise. - **Server-sent events or Firebase.** Any `Stream` fits `LiveOrders`. The adapter is the only part that changes. - **Pausing in the background.** Close the socket when the app is paused and open a new one on resume. Apply a `Resync` when it is open, so that whatever changed in the meantime is refetched. - **Polling instead.** When the server cannot push, see [Polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md), or [Poll until confirmed](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/poll-until-confirmed.md) for a poll that stops by itself. ## See it run The auto-refetching demo shows the alternative this recipe replaces: a query polled on an interval. Pick an interval and watch the fetch count grow with every poll, whether or not anything changed. *Add tick* shows the other path. The write invalidates the list, so it refreshes at once, which is what an event does here, with no request per interval. Live demo: [Auto refetching](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/auto-refetching), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/auto_refetching)). Polling on an interval, in the foreground or not. > **Note: In React Query** > > This is the pattern from TanStack's own WebSocket write-up: events call > `queryClient.setQueryData` or `invalidateQueries`, and the socket lives in one > effect near the root. `updateQueryData` is `setQueryData` with an updater > function. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Poll until a device confirms > A write the server accepts but a device confirms later, with the requested value shown at once, a poll that starts and stops itself, and a clear state for giving up. **The problem.** A switch in the app turns a relay on a smart-home device on or off. The request goes to a server, and the server answers *accepted* (often with HTTP 202) before the device has done anything. The device confirms seconds later, or not at all if it is offline. The user must see their choice at once and see that it is still pending. The app must learn when it is confirmed without polling forever. And if the device never answers, the switch must say so instead of spinning. **The recipe.** Four parts, each doing one job: - The **model** keeps what the device confirmed (`on`) separate from what was requested (`requestedOn`, `pendingSince`). A poll answer cannot overwrite the user's choice, because the two are different fields. - The **query** polls with a `RefetchInterval.dynamic` that reads the data: half a second while a confirmation is outstanding, and nothing otherwise. - The **mutation** writes the requested value optimistically and then writes the server's *accepted* answer into the cache. That write starts the poll. - The **widget** shows four states: on, off, waiting, and gave up. ## The model `lib/data/relay.dart`: ```dart @immutable class Relay { const Relay({ required this.id, required this.on, this.requestedOn, this.pendingSince, }); final String id; /// What the device last confirmed. final bool on; /// What a write asked for and the device has not confirmed; `null` when /// nothing is outstanding. A field of its own, so that a poll answer /// cannot overwrite the value the user just chose. final bool? requestedOn; /// When the server accepted a write the device has not confirmed yet. final DateTime? pendingSince; bool get isPending => pendingSince != null; /// What the switch shows: a requested value outranks the confirmed one. bool get shownOn => requestedOn ?? on; Relay copyWith({bool? requestedOn}) => Relay( id: id, on: on, requestedOn: requestedOn ?? this.requestedOn, pendingSince: pendingSince, ); @override bool operator ==(Object other) => other is Relay && other.id == id && other.on == on && other.requestedOn == requestedOn && other.pendingSince == pendingSince; @override int get hashCode => Object.hash(id, on, requestedOn, pendingSince); } ``` ## The query and when it polls `lib/data/relay_queries.dart`: ```dart const Duration confirmTimeout = Duration(seconds: 30); QueryObserverOptions relayQuery(String id) => QueryObserverOptions( queryKey: relayKey(id), queryFn: (context) => relayApi.get(id, signal: context.signal), refetchInterval: const RefetchInterval.dynamic(pollWhilePending), // A confirmation that lands while the user glances away still counts. refetchIntervalInBackground: true, ); /// Asked again after every poll: half a second while the device owes a /// confirmation, and `null` — stop — once it has answered or given up. Duration? pollWhilePending(Query query) { final relay = query.state.data; if (relay is! Relay || !relay.isPending) return null; if (gaveUp(relay, query.state.consecutiveErrorCount)) return null; return const Duration(milliseconds: 500); } /// Five failed polls in a row, or no confirmation within [confirmTimeout]. bool gaveUp(Relay relay, int consecutiveErrors) => consecutiveErrors >= 5 || DateTime.now().difference(relay.pendingSince!) > confirmTimeout; ``` The function passed to `RefetchInterval.dynamic` is called again whenever the query's state changes, for example when a fetch finishes or the key is written. So the poll starts when the mutation writes a pending relay, stops when a poll brings back a relay that is no longer pending, and stops when `gaveUp` says so. `consecutiveErrorCount` counts failed fetches in a row and a successful fetch resets it, so five unreachable polls stop it as surely as the timeout does. `refetchIntervalInBackground: true` keeps the poll running while the app is not focused. A user who checks the physical device and comes back should find the switch already confirmed. ## The write `lib/data/relay_mutations.dart`: ```dart typedef RelayWrite = ({String id, bool on}); MutationOptions switchRelay(QueryClient client) => MutationOptions( mutationFn: (write) => relayApi.set(write.id, on: write.on), onMutate: (write) async { final key = relayKey(write.id); await client.cancelQueries(filters: QueryFilters(queryKey: key)); final previous = client.getQueryData(key); // The requested value, not the confirmed one — and no pendingSince: // polling starts when the server has accepted, not before. client.updateQueryData( key, (relay) => relay?.copyWith(requestedOn: write.on), ); return previous; }, onError: (_, __, write, previous) { if (previous != null) { client.setQueryData(relayKey(write.id), previous); } }, // The answer says "pending": writing it is what starts the poll. onSuccess: (accepted, write, _) => client.setQueryData(relayKey(write.id), accepted), ); ``` `onMutate` cancels any poll in flight first. A poll answer that left the server before the write would otherwise land after the optimistic value and remove `requestedOn`. ## The switch `lib/features/relay/relay_switch.dart`: ```dart class RelaySwitch extends StatelessWidget { const RelaySwitch({super.key, required this.id}); final String id; @override Widget build(BuildContext context) { final client = QueryClientProvider.of(context); final result = context.query(relayQuery(id)); final write = context.mutation(switchRelay(client)); final relay = result.dataOrNull; if (relay == null) { return ListTile( title: const Text('Relay'), subtitle: Text(result.isError ? 'Unreachable' : 'Loading…'), ); } return SwitchListTile( title: const Text('Relay'), value: relay.shownOn, subtitle: Text(switch (relay) { Relay(isPending: false, on: true) => 'On', Relay(isPending: false) => 'Off', _ when gaveUp(relay, result.consecutiveErrorCount) => 'The device did not confirm', _ => 'Waiting for the device…', }), onChanged: write.value.isPending || relay.isPending ? null : (on) => write.mutate((id: id, on: on)), ); } } ``` The switch is disabled while a write is in flight and while the device owes a confirmation, so a second tap cannot race the first. ## Steps 1. Make the server's answer say *pending*. A `pendingSince` timestamp is enough, and it gives the client a clock to time out against. 2. Keep requested and confirmed values in separate fields. 3. Poll with `RefetchInterval.dynamic`, returning `null` whenever there is nothing to wait for. 4. Write the accepted answer into the cache in `onSuccess`. Do not invalidate it, because the answer *is* the new state. 5. Decide when to give up (errors in a row, a deadline, or both) and show that state. ## Traps - **One field for both values.** When the optimistic write sets `on: true` and a poll returns the device's `on: false`, the switch flickers back until the confirmation arrives. Separate fields prevent it. - **A callback in `enabled` instead of the interval.** Turning the query off when nothing is pending also turns off every refetch on focus, mount and reconnect. The interval is the only thing that should change. - **Trusting two clocks.** `gaveUp` compares the server's `pendingSince` with the device's clock, and a phone whose clock is off by a minute gives up at once or far too late. When that matters, let the server send the deadline or the seconds left instead of a timestamp, or time out from the moment the app received the accepted answer. - **Giving up only on errors.** A device that stays offline does not make the server fail. The server keeps answering *pending*, so the error count stays at zero. The deadline catches that case. - **Invalidating in `onSuccess`.** A refetch costs a request to get what the answer already contained, and until it returns, the cache holds the optimistic value without `pendingSince`, so the poll does not start. ## Variations - **Let the user try again.** After giving up, the relay is still pending, so the switch stays disabled. Offer a *Check again* button that calls `client.refetchQueries(filters: QueryFilters(queryKey: relayKey(id)))`, or let the server expire the pending state so the next fetch clears it. - **Many devices.** Every relay has its own key and its own interval. A device that confirms stops only its own poll. - **Cancel the write.** When the server supports it, a cancel request is another mutation whose answer (no longer pending) is written the same way. The poll stops because the interval function sees `isPending == false`. - **A push channel instead of polling.** When the device's confirmation arrives as an event, write it with `updateQueryData` as in [Realtime updates over a WebSocket](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/realtime-websockets.md). The poll stops as soon as the event clears the pending state, and it remains as a fallback for a lost event. [Polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md) covers `refetchInterval` in general, and [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md) covers the `onMutate`/`onError` pair. ## See it run The task manager app uses this pattern for a task's reminder. Open a task and flip *Reminder*: the switch moves at once and is marked `confirming`. The demo's scheduler confirms after about three seconds, and the poll, every half second, picks that up and stops. Live demo: [Task manager](https://dualmeta-gmbh.github.io/query_kit/demo/task_manager/), the whole example app, running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/task_manager/lib)). > **Note: In React Query** > > This is `refetchInterval` as a function of the query, returning `false` to > stop. Here it returns a `Duration?`, with `null` meaning stop, and > `consecutiveErrorCount` is available on the query's state and on the result. > See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Disconnecting a device > Stop every query for a device the user disconnected, with no request sent to it afterwards and no old reading left in the cache. **The problem.** A thermostat app talks to devices on the local network over Bluetooth or a socket. The device screen polls its status every two seconds. When the user disconnects the kitchen thermostat, every query for it must stop. No request may go to it afterwards: a closed Bluetooth connection throws, and some transports reconnect by themselves if asked. And the cache must not keep an old reading that looks current. The obvious fix, `removeQueries` under the device's prefix, is not enough by itself. An observer follows a **key**, not an entry. A screen still mounted on that key recreates the entry on its next poll tick or rebuild, and fetches again. The order is what matters. **The recipe.** First make the transport refuse and let the screens stop reading. Then remove the entries. ## Keys with one prefix per device `lib/data/device_keys.dart`: ```dart abstract final class DeviceKeys { static final QueryKey all = QueryKey(['devices']); /// Everything about one device lives under this prefix. static QueryKey device(String id) => all.append([id]); static QueryKey status(String id) => device(id).append(['status']); } ``` Everything about one device is under `DeviceKeys.device(id)`, so one filter reaches all of it: status, settings, history. ## A transport that can say no `lib/data/device_connection.dart`: ```dart class DeviceGone implements Exception { const DeviceGone(this.deviceId); final String deviceId; @override String toString() => 'Device $deviceId is disconnected'; } class DeviceConnection { DeviceConnection(this.deviceId); final String deviceId; /// Whether the device may be talked to. Screens rebuild on it. final ValueNotifier open = ValueNotifier(true); Future status({QueryCancelToken? signal}) async { _ensureOpen(); return const DeviceStatus(temperature: 21.5); // the real call goes here } /// From here on every call refuses, before anything goes on the wire. void close() => open.value = false; void _ensureOpen() { if (!open.value) throw DeviceGone(deviceId); } } ``` `close()` flips a `ValueNotifier`, so widgets can rebuild on it. After it, a call throws `DeviceGone` before anything reaches the wire. ## A client that does not retry a gone device `lib/app/query_client.dart`: ```dart QueryClient buildDeviceClient() => QueryClient( defaultOptions: const DefaultOptions( queries: QueryDefaults( // A device on the local network answers whatever the phone's // internet connection says. networkMode: NetworkMode.always, retry: RetryPolicy.when(retryUnlessGone), ), // Not inherited from the query defaults: said again. mutations: MutationDefaults(networkMode: NetworkMode.always), ), ); bool retryUnlessGone(int failureCount, Object error, StackTrace _) => error is! DeviceGone && failureCount < 2; ``` `NetworkMode.always`, because the phone's internet connection has nothing to do with a device on the local network (see [Network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md)). The retry policy retries ordinary failures twice and never retries `DeviceGone`: a closed connection does not come back with a backoff. ## The query takes the connection's state as a value `lib/data/device_queries.dart`: ```dart QueryObserverOptions deviceStatusQuery( DeviceConnection connection, { required bool open, }) => QueryObserverOptions( queryKey: DeviceKeys.status(connection.deviceId), queryFn: (context) => connection.status(signal: context.signal), // Values, not callbacks: a closed connection rebuilds the screen, and // the rebuild hands the observer options that neither fetch nor poll. enabled: open ? Enabled.yes : Enabled.no, refetchInterval: open ? const RefetchInterval.every(Duration(seconds: 2)) : RefetchInterval.off, ); ``` `enabled` and `refetchInterval` are values computed from `open`, not callbacks. A callback over outside state is only read again when something hands the options over, so a value computed in the build makes that handover explicit. When `open` flips, the screen rebuilds, and the rebuild gives the observer options that neither fetch nor poll. ## The screen rebuilds on the connection `lib/features/device/device_screen.dart`: ```dart class DeviceScreen extends StatelessWidget { const DeviceScreen({super.key, required this.connection}); final DeviceConnection connection; @override Widget build(BuildContext context) => ValueListenableBuilder( valueListenable: connection.open, builder: (_, open, __) => DeviceStatusView(connection: connection, open: open), ); } class DeviceStatusView extends StatelessWidget { const DeviceStatusView( {super.key, required this.connection, required this.open}); final DeviceConnection connection; final bool open; @override Widget build(BuildContext context) { final status = context.query(deviceStatusQuery(connection, open: open)); return ListTile( title: Text(connection.deviceId), subtitle: Text(switch (status) { _ when !open => 'Disconnected', QuerySuccess(:final data) => '${data.temperature} °C', QueryError(:final error) => '$error', QueryPending() => 'Connecting…', }), ); } } ``` ## Disconnect: readers first, entries second `lib/data/device_actions.dart`: ```dart Future disconnect(QueryClient client, DeviceConnection connection) async { // 1. The transport refuses, and every screen of the device rebuilds with // its queries disabled. connection.close(); await WidgetsBinding.instance.endOfFrame; // 2. Only now the entries: no reader is left that would fetch them again. // Removing an entry also cancels a fetch it still has in flight. client.removeQueries( filters: QueryFilters(queryKey: DeviceKeys.device(connection.deviceId)), ); } ``` `endOfFrame` waits for the rebuild that `close()` scheduled. When it completes, every reader of the device has switched to disabled options, and nothing fetches or polls the removed entries again. Removing an entry also cancels a fetch it still has in flight, silently, so no separate `cancelQueries` call is needed. A reader that is still mounted and builds again after the removal resolves its key afresh. It brings back an *empty* entry: no data and no request, because its options are disabled. The old reading is gone either way, and the empty entry is garbage collected `gcTime` after its last reader leaves. ## Steps 1. Put all of a device's keys under one prefix. 2. Give the transport a closed state that throws before sending. 3. Derive `enabled` and `refetchInterval` from that state in `build`. 4. On disconnect, close the transport, wait for the frame, then remove the prefix. ## Traps - **Removing first.** The entry is gone for one frame. Then the next poll tick or rebuild of a still-mounted screen creates it again and fetches from a device that is gone. - **An `Enabled.when` callback over the connection.** It is only read when the options are handed over again. Without a rebuild, the observer keeps its previous verdict and the poll continues. See [Troubleshooting](https://dualmeta-gmbh.github.io/query_kit/docs/reference/troubleshooting.md). - **Relying on a navigation pop.** Popping the device screen releases its readers, but other screens may still read the same device: a dashboard tile, a notification badge. Close the transport so that every reader sees it. - **Pending writes.** A mutation already in flight is not a query and is not removed. Find it with `client.mutationCache.findAll(filters: MutationFilters(mutationKey: …))` and call `cancel()` on it. A cancelled mutation fails, and its `onError` runs, so a rollback still happens. See [Cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md). ## Variations - **Leave the screen showing the last reading.** Skip `removeQueries` and let the disabled query keep its data. Show it greyed out with the `dataUpdatedAt` of the last reading. It is garbage collected after `gcTime` once no screen reads it. - **Reconnect.** A new `DeviceConnection` whose `open` is `true` rebuilds the screen with enabled options, and the query fetches as if for the first time. - **Many devices.** Each device has its own prefix and connection, so disconnecting one leaves the others' polls running. ## See it run The invalidation and filters demo shows the mechanism this recipe works around. Press *Remove post 2*: the entry disappears from the cache (`status=absent`) but its reader keeps its last result. *Re-attach post 2* resolves the key again, and the entry comes back with a fetch. Live demo: [Invalidation and filters](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/invalidation-and-filters), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/invalidation_and_filters)). Invalidate, refetch, reset and remove, by prefix, type or predicate. > **Note: In React Query** > > The same order applies there: `removeQueries` while a `useQuery` for that key > is mounted creates the entry again. The Flutter-specific part is waiting for > `endOfFrame`, the point where the rebuild with disabled options has > happened. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Sign out and multiple accounts > Give each signed-in user a fresh cache, empty it at sign-out without a refetch storm, warn about unsent writes, and keep several accounts apart in one client. **The problem.** Alice signs out and Bob signs in on the same phone. For a moment, Bob's inbox shows Alice's messages from the cache. Or the sign-out clears the cache while Alice's screens are still mounted, and they refetch everything with a token that was just revoked, producing a burst of 401s. Or a mail app shows several accounts at once and needs to forget one of them without touching the rest. **The recipe.** Tie the cache's lifetime to the session in the widget tree. When the signed-in subtree goes away, its readers go first and the cache is emptied after them. There are two ways to do that. Choose one: - **A client per user** (preferred): a keyed `QueryClientProvider.create`. Another user is another key, so another client. - **One client, cleared on the way out**: a shell widget that clears the shared client in its `dispose`. Several accounts in one session is a different case. They share a client, and each account's keys have a prefix of their own. ## The gate The app's root decides between signed in and signed out from your session state, wherever that is kept: `lib/app/auth_gate.dart`: ```dart class AuthGate extends StatelessWidget { const AuthGate({super.key, required this.userId}); /// From your session state: `null` while nobody is signed in. final String? userId; @override Widget build(BuildContext context) => switch (userId) { null => const MaterialApp(home: SignInScreen()), final String id => SignedInScope(userId: id, child: const HomeApp()), }; } ``` ## A client per user `SignedInScope` is the keyed provider from [Dependency injection](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/dependency-injection.md): ```dart Widget build(BuildContext context) => QueryClientProvider.create( key: ValueKey(userId), create: () => QueryClient(defaultOptions: appDefaults), child: child, ); // … ``` At sign-out, `userId` becomes `null` and the gate builds the sign-in screen. The signed-in subtree unmounts, children first, and the owned client is cleared after its provider unmounts. At a switch from Alice to Bob, the new key replaces the provider. Bob's screens mount on a new, empty client, and Alice's screens are disposed, and then her client is cleared. `clear()` empties both caches and cancels every fetch still in flight, so no late answer lands in the cache after the user has gone. ## One client, cleared on the way out When the client has to outlive the session (it is registered in get_it, or code outside the tree holds it), clear it when the signed-in subtree goes: `lib/app/signed_in_shell.dart`: ```dart /// Everything a signed-in user sees is below this widget, on a client the /// whole app shares. When it goes, the user's cache goes with it. class SignedInShell extends StatefulWidget { const SignedInShell({super.key, required this.child}); final Widget child; @override State createState() => _SignedInShellState(); } class _SignedInShellState extends State { late final QueryClient _client; @override void initState() { super.initState(); // Looked up while mounted: in dispose, the ancestors are out of reach. _client = QueryClientProvider.read(context); } @override void dispose() { // Flutter disposes children before their parent, so every reader below // is gone and nothing refetches what this empties. _client.clear(); super.dispose(); } @override Widget build(BuildContext context) => widget.child; } ``` The order is Flutter's own: a `State`'s `dispose` runs after its children's. When `clear()` runs, no reader is left to refetch. Clearing from the sign-out button instead would empty the cache while the screens are still mounted, and each of them would create its entry again, and fetch, at its next rebuild, poll or focus change. Put `SignedInShell` in the signed-in branch of the gate, under the provider that holds the shared client. ## Unsent writes A write still pending at sign-out is lost, because `clear()` removes it. Ask first: `lib/features/settings/sign_out_button.dart`: ```dart /// Asks before signing out over writes that have not gone through. Future mayDropWrites(BuildContext context) async { final writing = QueryClientProvider.read(context).isMutating(); if (writing == 0) return true; final drop = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('$writing changes are not saved yet'), content: const Text('Sign out anyway? They will be lost.'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Stay'), ), TextButton( onPressed: () => Navigator.pop(context, true), child: const Text('Sign out'), ), ], ), ); return drop ?? false; } ``` `isMutating()` counts every pending mutation, including paused ones waiting for the network. Call it before changing the session: `if (await mayDropWrites(context)) session.signOut();`. ## Several accounts in one client A mail app that shows two accounts at once keeps both in one cache, each under its own prefix: `lib/data/account_keys.dart`: ```dart abstract final class AccountKeys { static QueryKey account(String userId) => QueryKey(['account', userId]); static QueryKey inbox(String userId) => account(userId).append(['inbox']); } /// Forget one account, and keep the others this client holds. void forgetAccount(QueryClient client, String userId) => client.removeQueries( filters: QueryFilters(queryKey: AccountKeys.account(userId)), ); ``` Removing one account's prefix leaves the other's entries alone. As on [Disconnecting a device](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/device-and-iot-disconnect.md), remove after that account's screens are gone, or they will create their entries again. ## Steps 1. Keep the session (a user id, a token) in your own state and switch the tree on it. 2. Choose the lifetime: a keyed `QueryClientProvider.create` per user, or a `SignedInShell` that clears a shared client. 3. Check `isMutating()` before signing out. 4. For multiple accounts, put every key under an account prefix and remove by prefix. ## Traps - **Clearing in the button's `onPressed`.** The screens are still mounted. Until they go, each reader shows its last result, and the next rebuild creates its entry again and fetches, usually with a token that is already invalid. - **A user id outside the key.** With a client per user, keys do not need the user id, because the whole cache is theirs. With a shared client, a key without it serves Alice's data to Bob. - **Persisted snapshots.** When you save queries to disk (see [Offline first, and surviving a restart](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/offline-first-and-persistence.md)), delete the snapshot at sign-out too, or the next launch restores the previous user's data. - **Tokens in the query function only.** A token change does not refetch anything by itself. A per-user client resolves this, because the new user's queries run on a new client. ## Variations - **Switching accounts without signing out.** Key the scope by the active account id. Each switch then starts a new, empty cache. To keep both accounts warm, use a shared client with account prefixes instead. - **Keep public data across users.** Put public, per-app data (feature flags, a product catalogue) on a client outside the user scope, and personal data on the per-user one. A widget reads from the nearest provider, so pass the outer client explicitly to the reads that need it. > **Note: In React Query** > > The advice there is `queryClient.clear()` at sign-out, or a new > `QueryClient` per user. Both apply here, and the widget tree adds what React > does not have: a `dispose` that runs after the children's, which is the right > moment to clear. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Normalised data or one key per entity > When to cache a list plus one entry per item, when to cache a map by id, and how to keep unchanged items' instances either way. **The problem.** A contacts app shows a list, a detail screen and a favourites strip, and all three show the same contact. Coming from Redux or Apollo, the natural move is to normalise: one map of contacts by id and screens that look up what they need. query_kit caches per key, not per entity. Two questions follow. How do the list and the detail stay in agreement? And does a row rebuild when an unrelated contact changes? **The recipe.** Both shapes work. Choose by where the data comes from: | The API gives you | Cache it as | Why | |---|---|---| | A list endpoint and a detail endpoint | A list key plus one key per entity, the list seeding the details | Every screen reads what its endpoint returns. Invalidation works per key. | | One endpoint answering entities by id | One key holding a `Map` by id, with a sharing hook | The server already normalised. Rows `select` their entity. | | Entities by id *plus* an order | One key holding a class that implements `StructurallyShareable` | Same as the map, and the order survives. | The cache does not normalise across keys. A contact that appears under two keys is two copies. The recipes below keep those copies in agreement where it matters, and keep rebuilds narrow in every case. ## One key per entity `lib/data/contact_queries.dart`: ```dart abstract final class ContactKeys { static final QueryKey all = QueryKey(['contacts']); static final QueryKey list = all.append(['list']); static final QueryKey byId = all.append(['by-id']); static QueryKey detail(String id) => all.append(['detail', id]); } QueryObserverOptions> contactListQuery(QueryClient client) => QueryObserverOptions( queryKey: ContactKeys.list, queryFn: (context) async { final contacts = await contactsApi.list(signal: context.signal); // Every contact is also an entry of its own, seeded fresh, so the // detail screen opens without a request and later list fetches // keep it current. for (final contact in contacts) { client.setQueryData(ContactKeys.detail(contact.id), contact); } return contacts; }, ); QueryObserverOptions contactQuery(String id) => QueryObserverOptions( queryKey: ContactKeys.detail(id), queryFn: (context) => contactsApi.get(id, signal: context.signal), staleTime: const StaleTime.duration(Duration(seconds: 30)), ); ``` The list fetch writes every contact into its own entry with `setQueryData`. The detail screen then opens with data and no request. Each later list fetch updates those entries, so the detail is at most as old as the last list. After an edit, invalidate the contact's detail key and the list key, or invalidate `ContactKeys.all` for both. A seeded entry counts as fresh from the moment it is written. With the detail's 30-second `staleTime`, opening a contact right after the list loads costs nothing, and opening it later refetches as usual. To let the detail show the list's copy without it counting as fresh, seed through `initialData` with the list's `dataUpdatedAt` instead (see [Initial query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md)). ## A map by id Structural sharing keeps a `Map` whole when it is deeply equal and replaces it whole otherwise. One changed contact would replace every contact's instance. A `structuralSharing` hook shares entry by entry instead: `lib/data/contact_queries.dart`: ```dart /// A structuralSharing hook for a normalised map: an unchanged contact keeps /// the instance the cache already holds, and nothing changed at all keeps the /// whole map. Map shareById( Map? previous, Map next, ) { if (previous == null) return next; var changed = previous.length != next.length; final shared = {}; for (final MapEntry(:key, :value) in next.entries) { final kept = previous[key]; if (kept == value) { shared[key] = kept!; } else { shared[key] = value; changed = true; } } return changed ? shared : previous; } QueryObserverOptions> contactsByIdQuery() => QueryObserverOptions( queryKey: ContactKeys.byId, queryFn: (context) async => { for (final contact in await contactsApi.list(signal: context.signal)) contact.id: contact, }, structuralSharing: shareById, ); ``` A row reads its one contact with `select` and rebuilds only when that contact changes: `lib/features/contacts/contact_row.dart`: ```dart class ContactRow extends StatelessWidget { const ContactRow({super.key, required this.id}); final String id; @override Widget build(BuildContext context) { final contact = context.selectQuery( contactsByIdQuery().withSelect((byId) => byId[id]), // A refetch that leaves this contact alone rebuilds nothing here. buildWhen: (previous, current) => previous.dataOrNull != current.dataOrNull, ); return ListTile(title: Text(contact.dataOrNull?.name ?? '…')); } } ``` `select` narrows what the row reads, and `buildWhen` narrows what it rebuilds for. A result carries more than data (its fetch status, its update time), so without `buildWhen` every refetch would still rebuild every row. [Render optimisations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md) explains the difference. ## Entities plus an order Many APIs answer `{ "byId": {…}, "order": [...] }`. A class holding both is a leaf to structural sharing unless it takes part, and taking part is one method: `lib/data/contact_book.dart`: ```dart /// The normalised shape many APIs answer with: entities by id, plus an order. @immutable class ContactBook implements StructurallyShareable { const ContactBook({required this.byId, required this.order}); final Map byId; final List order; // Asked only when the two are not equal: share what did not change. @override ContactBook shareWith(ContactBook previous) => ContactBook( byId: shareById(previous.byId, byId), order: replaceEqualDeep(previous.order, order), ); @override bool operator ==(Object other) => other is ContactBook && mapEquals(other.byId, byId) && listEquals(other.order, order); @override int get hashCode => Object.hash( Object.hashAllUnordered(byId.values), Object.hashAll(order), ); } ``` `shareWith` is only called when the two values are not equal. It reuses the map hook for the entities and the default walk for the order, which keeps the order list's instance when the order did not change. ## Steps 1. Follow the endpoints. A list endpoint and a detail endpoint suggest one key per entity. A by-id endpoint suggests a map. 2. Give every model `==` and `hashCode`. Without them no sharing works, because every fetch looks like a change. 3. For a map, add a `structuralSharing` hook. For a class, implement `StructurallyShareable`. 4. Read rows with `select` plus `buildWhen` on the selected value. ## Traps - **Normalising client-side from list responses.** Building your own entity store from several queries' results moves the invalidation problem into your code. Seed per-entity keys instead, and let each key refetch. - **A map without a hook.** It looks fine, because `==` still holds after an unrelated change. But every contact's instance is new, and anything that compares with `identical` (a memo, a list diff) sees everything as changed. - **A hook that returns `previous` too eagerly.** `shareById` returns `previous` only when every entry and the length are unchanged. Returning it when something changed puts old data in the cache without any error. - **`select` without `buildWhen`.** The row still rebuilds on every refetch, even though its contact did not change. ## Variations - **An infinite list.** Each page is a list, and lists are shared element by element, so a refetched page keeps its unchanged contacts. Seeding per-item keys works from the page loop in the same way. - **Optimistic edits.** With per-entity keys, update the detail key and the list's element in `onMutate`. With a map, update one entry. See [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md). ## See it run The basic demo is the per-entity shape: a list of posts, and one key per post for the detail. Open a post and go back. Its row is marked `cached` because its own key now has an entry. Leave it alone for ten seconds and the mark goes, because that one entry was garbage collected while the list's entry stayed. Live demo: [Basic](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/basic), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/basic)). A list, a detail, and what the cache already knows. > **Note: In React Query** > > TanStack Query does not normalise either, and suggests the same two answers: > seed detail queries from the list, or accept a copy per key. A > `structuralSharing` function works the same way. The Dart-specific part is > that maps and classes are not walked member by member, so a map by id or a > wrapper class needs the hook or `StructurallyShareable`. See [differences > from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Models with freezed and JSON > What a model needs to work well in the cache, how freezed and json_serializable provide it, and why a wrapper class needs StructurallyShareable to keep unchanged items. **The problem.** The app's models are generated by freezed and parsed with json_serializable, as in most Flutter codebases. The queries return them, and everything seems to work. But the rows of a list screen rebuild on every refetch, even when nothing in them changed. What does a model need so that the cache can recognise what did not change? **The answer.** Two things, both easy to get: 1. **Value equality.** `==` and `hashCode` over the fields. Structural sharing compares with `==`, and a class without it is never equal to its previous version, so every fetch counts as a change. freezed generates both. 2. **Taking part in sharing when a class wraps a collection.** A class is a *leaf*: it is kept whole or replaced whole. A `Page(items: [...])` in which one item changed gets new instances for *every* item. Implementing `StructurallyShareable` lets the walk go inside it. ## What freezed generates, written out This is the shape freezed produces, written by hand. It is useful for seeing what the cache relies on: `lib/data/invoice.dart`: ```dart /// What freezed generates, in essence — and what a model needs either way. @immutable class Invoice { const Invoice( {required this.id, required this.customer, required this.cents}); factory Invoice.fromJson(Map json) => Invoice( id: json['id']! as String, customer: json['customer']! as String, cents: json['cents']! as int, ); final String id; final String customer; final int cents; @override bool operator ==(Object other) => other is Invoice && other.id == id && other.customer == customer && other.cents == cents; @override int get hashCode => Object.hash(id, customer, cents); } ``` The freezed version of the same model, for freezed 3 and json_serializable: `lib/data/invoice.dart`: ```dart import 'package:freezed_annotation/freezed_annotation.dart'; part 'invoice.freezed.dart'; part 'invoice.g.dart'; @freezed abstract class Invoice with _$Invoice { const factory Invoice({ required String id, required String customer, required int cents, }) = _Invoice; factory Invoice.fromJson(Map json) => _$InvoiceFromJson(json); } ``` The query parses at the edge, so the cache only ever holds typed models: `lib/data/invoice_queries.dart`: ```dart QueryObserverOptions> invoicesQuery(Dio dio) => QueryObserverOptions( queryKey: QueryKey(['invoices']), queryFn: (context) async { final response = await dio.get>('/invoices'); return [ for (final json in response.data!) Invoice.fromJson(json! as Map), ]; }, ); ``` A `List` in the cache needs nothing more. Lists are shared element by element, so a refetch where one invoice changed keeps every other invoice's instance. ## A wrapper is a leaf Wrap that list in a class, as a paged API suggests, and the sharing stops at the class. The measurement: `test/invoice_sharing_test.dart`: ```dart final before = InvoiceList([invoice('i1', 100), invoice('i2', 200)]); // A refetch in which only i2 changed: final after = replaceEqualDeep( before, InvoiceList([invoice('i1', 100), invoice('i2', 250)]), ); // i1 is equal, but it is not the instance the cache held: expect(identical(after.items[0], before.items[0]), isFalse); ``` Nothing looks broken. `==` still holds for `i1`, and the screen shows the right data. What is lost is `identical`, and with it every rebuild skipped because an item is the same instance as before. ## Letting the walk inside Implement `StructurallyShareable` and hand the walk the list: `lib/data/invoice_page.dart`: ```dart @immutable class InvoicePage implements StructurallyShareable { const InvoicePage({required this.items, required this.total}); final List items; final int total; @override InvoicePage shareWith(InvoicePage previous) => InvoicePage( items: replaceEqualDeep(previous.items, items), total: total, ); @override bool operator ==(Object other) => other is InvoicePage && other.total == total && listEquals(other.items, items); @override int get hashCode => Object.hash(total, Object.hashAll(items)); } ``` With freezed, a private constructor lets the class have members of its own. `copyWith` then keeps every other field as it is: `lib/data/invoice_page.dart`: ```dart @freezed abstract class InvoicePage with _$InvoicePage implements StructurallyShareable { const InvoicePage._(); const factory InvoicePage({ required List items, required int total, }) = _InvoicePage; factory InvoicePage.fromJson(Map json) => _$InvoicePageFromJson(json); @override InvoicePage shareWith(InvoicePage previous) => copyWith(items: replaceEqualDeep(previous.items, items)); } ``` `shareWith` is only called when the two pages are not equal, so it never has to check for "nothing changed" itself. ## Steps 1. Give every model value equality: freezed, `equatable`, or `==` and `hashCode` written by hand. 2. Parse JSON in the query function and cache typed models, never `Map`. 3. Cache lists directly where you can. They are shared element by element with no further work. 4. For a class that wraps a collection, implement `StructurallyShareable`, and check once, as in the measurement above, that an unchanged item keeps its instance. ## Traps - **Comparing freezed collections by identity.** freezed returns its lists wrapped in an unmodifiable view, and the view can be a new object on each access. Compare the items with `identical`, or the lists with `listEquals`. Do not compare `page.items` itself with `identical`. - **Mutable models.** A model with a setter that changes a field in place changes the cached instance, and nothing notifies anyone. Keep models immutable, as freezed does by default, and write changes through `setQueryData` or `updateQueryData`. - **Caching raw JSON.** A `Map` is kept whole or replaced whole, so one changed field replaces everything, and every reader has to parse. Parse once in the query function. - **A `shareWith` that returns `previous` when something changed.** The cache then holds old data and nothing reports it. Build a new value from the shared parts, as above. ## Variations - **A `sealed` result family.** A freezed union (`Loaded`, `Empty`) where one variant wraps a list implements `StructurallyShareable` on the base type. The walk asks only when both values have the same runtime type, so `shareWith` always sees two of the same variant, and a change of variant replaces the value whole. - **A normalised response.** For `byId` maps, see [Normalised data or one key per entity](https://dualmeta-gmbh.github.io/query_kit/docs/cookbook/normalised-vs-per-entity-keys.md). - **Turning sharing off.** For very large payloads that always change, see [Structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md). ## See it run In the select and structural sharing demo, five readers show one cache entry, and each counts its *data builds*: the builds where its value was not `==` to the one before. Press *Refetch*. Equal data comes back, and no count moves. Press *Rename todo 2*, and only the reader that holds the list of texts moves, with the control reader that has no `select`. Then switch *Structural sharing off* and press *Refetch* again. The data is equal, but the list is now a new instance, and the readers that hold a list count a build for nothing. A wrapper class without `StructurallyShareable` has the same effect on every item inside it. Live demo: [Select and structural sharing](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/select-and-sharing), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/select_and_sharing)). What a reader rebuilds on, and what it does not. > **Note: In React Query** > > TanStack Query's structural sharing walks plain objects and arrays, which > covers most JSON responses. A class instance is a leaf there too. The > difference is that Dart models are always classes, so the wrapper case that > is rare in JavaScript is common here, and `StructurallyShareable` is how a > class takes part. See [differences from TanStack > Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # API reference > One page per main surface of query_kit and query_kit_flutter — every option, field and member with its type, default and TanStack Query name — plus where the generated dartdoc lives. These pages list the public surface a user touches, one page per surface, in tables: every option, field and member with its type, its default and what it does, and the name it has in TanStack Query. Each row links to the generated dartdoc, which has the full signature and the longer explanation. | Page | What it covers | |---|---| | [QueryClient](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-client.md) | The client: fetching, reading and writing the cache, operating on many queries at once, defaults, mounting and clearing. | | [Options](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-options.md) | `QueryOptions`, `QueryObserverOptions`, the infinite and mutation options — every field — and the sealed option values (`StaleTime`, `GcTime`, `RetryPolicy`, `RefetchOn`, …). | | [Results](https://dualmeta-gmbh.github.io/query_kit/docs/reference/results.md) | What a reader is handed: `QueryResult` and its three cases, `QueryState`, `InfiniteData` and the paging flags, `MutationResult`, `MutationState`, `CombinedResult`. | | [Widgets and controllers](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md) | The Flutter binding: `QueryClientProvider`, the four call styles, listeners, collections, `OnlineStatus`. | | [Caches and observers](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md) | `QueryCache` and `MutationCache` with their events, `Query` and `Mutation`, the observers, the filters, and the focus, online and notify managers. | | [Errors](https://dualmeta-gmbh.github.io/query_kit/docs/reference/errors.md) | Every error either package throws or records, and every check a debug build runs. | A typical app touches them in that order: it builds a [client](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-client.md) with some defaults, describes its queries with [options](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-options.md), reads [results](https://dualmeta-gmbh.github.io/query_kit/docs/reference/results.md) through [a widget or a controller](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md), and looks at the [caches](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md) and [errors](https://dualmeta-gmbh.github.io/query_kit/docs/reference/errors.md) when something needs explaining. ## The generated dartdoc Every public member of both packages carries a dartdoc comment, and pub.dev builds and hosts the reference for every published version: - [pub.dev/documentation/query_kit](https://pub.dev/documentation/query_kit/latest/) — the core: client, caches, observers, options, results. - [pub.dev/documentation/query_kit_flutter](https://pub.dev/documentation/query_kit_flutter/latest/) — the binding. It re-exports the core, so an app imports only this one. `dart doc` in a package's directory writes the same reference to `doc/api/`; open `doc/api/index.html`. For a package in your pub cache, run it there. ## Where to start reading | If you want | Start at | |---|---| | the whole imperative surface | [`QueryClient`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-client.md) | | what a widget is handed | [`QueryResult`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/results.md#queryresult), and its `QueryPending` / `QuerySuccess` / `QueryError` cases | | every option and what unset means | [the options](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-options.md), then [the option values](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-options.md#option-values) | | paging | [the infinite fields](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-options.md#infinite-query-fields), [`InfiniteData`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/results.md#infinitedata), [`InfiniteQueryObserver`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md#infinitequeryobserver) | | writes | [the mutation fields](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-options.md#mutation-fields), [`MutationResult`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/results.md#mutationresult), [`MutationController`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md#mutationcontroller) | | the caches | [`QueryCache`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md#querycache), [`MutationCache`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md#mutationcache), [filters](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md#filters) | | the Flutter side | [`QueryClientProvider`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md#queryclientprovider) and [the four call styles](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md#the-four-call-styles-at-a-glance) | | widget tests | nothing exported: the teardown is a documented snippet, see [Testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md) | ## Beyond signatures - [Feature matrix](https://dualmeta-gmbh.github.io/query_kit/docs/reference/feature-matrix.md) — what exists, per TanStack Query feature. - [Differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md) — where the behaviour differs, and why. - [Troubleshooting](https://dualmeta-gmbh.github.io/query_kit/docs/reference/troubleshooting.md) — symptoms, causes and fixes. - [Coming from React Query](https://dualmeta-gmbh.github.io/query_kit/docs/coming-from-react-query.md) — the name map. > **Note: In React Query** > > TanStack Query's React reference is generated, one page per function, class > and interface: `useQuery`, `QueryClient`, `QueryCache`, `QueryObserverOptions`, > `QueryObserverSuccessResult` and so on. Here the pages group by surface > instead, and the options and the results have a page each, because the four > call styles share them. --- # QueryClient > Every member of QueryClient — constructor, reads, writes, bulk operations, defaults and lifecycle — with its signature and what it does. [`QueryClient`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient-class.html) owns the caches and everything imperative: fetching, reading and writing cached data, invalidating, cancelling. Create one per app (or one per test) and keep it for as long as the app runs — the cache lives in it. In Flutter it goes into a [`QueryClientProvider`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md#queryclientprovider), which mounts it; in pure Dart you call `mount()` yourself (see [without Flutter](https://dualmeta-gmbh.github.io/query_kit/docs/guides/pure-dart.md)). A typical setup, in `lib/main.dart`, with a cache-wide error hook for the app's logger: ```dart final QueryClient appClient = QueryClient( queryCache: QueryCache( onError: (error, stackTrace, query) => log.warning( 'query ${query.queryKey.debugString} failed', error, stackTrace, ), ), defaultOptions: const DefaultOptions( queries: QueryDefaults( staleTime: StaleTime.duration(Duration(seconds: 30)), ), ), ); ``` Every method that takes filters takes them as a named `filters:` argument, a [`QueryFilters`](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md#filters) (or `MutationFilters`). The empty filter matches everything. Where a member's TanStack Query name differs from the Dart one, the table says so at the end of the row; a member without such a note has the same name in TanStack Query. ## Constructor and fields | Member | Type | Default | What it is | |---|---|---|---| | `QueryClient({queryCache, mutationCache, defaultOptions, focusManager, onlineManager, notifyManager})` | | a fresh instance of each; `defaultOptions` empty | Every collaborator is optional. Pass your own caches to install cache-wide callbacks. TanStack: `new QueryClient(config)`, whose config takes no managers. | | [`queryCache`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/queryCache.html) | `QueryCache` | | Every query, keyed by `QueryKey`. Subscribe to it for [cache events](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md#querycache). TanStack: `getQueryCache()`. | | [`mutationCache`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/mutationCache.html) | `MutationCache` | | Every mutation, in submission order. TanStack: `getMutationCache()`. | | [`focusManager`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/focusManager.html) | `AppFocusManager` | | Whether the app is in the foreground. The Flutter binding drives it from the app lifecycle. TanStack: the module-level `focusManager`. | | [`onlineManager`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/onlineManager.html) | `OnlineManager` | | Whether the device is believed online. The binding drives it from `QueryClientProvider.onlineStatus`. TanStack: the module-level `onlineManager`. | | [`notifyManager`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/notifyManager.html) | `NotifyManager` | | Batches this client's listener notifications. Pass `NotifyManager.shared` to batch across clients. TanStack: the module-level `notifyManager`. | The three managers belong to the client rather than to the module, so two clients — two tests — never share focus, connectivity or batching state. ## Fetching | Method | Returns | What it does | |---|---|---| | [`query(options, {revalidateIfStale = false})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/query.html) | `Future` | Takes a `QueryOptions`. Returns cached data if it is fresh under the options' `staleTime`; otherwise fetches (or joins the fetch in flight), caches and completes with the data. A failed fetch fails the future. **No retries unless `retry` is set** on the options or in the defaults. With `revalidateIfStale`, cached data is returned at once while a stale query refreshes in the background, and the future fails only when nothing is cached. When the call creates the entry or starts a fetch, the options become the query's options (with `retry` unset, the query keeps its own); served from the cache or joining a fetch in flight, they are not applied. | | [`infiniteQuery(options, {revalidateIfStale = false})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/infiniteQuery.html) | `Future>` | The same for an `InfiniteQueryOptions`: the first page, or `options.pages` pages. | | [`observe(options)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/observe.html) | `QueryObserver` | Creates an observer on this client — the same as `QueryObserver(client, options)`. It does nothing until subscribed; the caller owns it. TanStack: `new QueryObserver(client, options)`. | | [`observeInfinite(options)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/observeInfinite.html) | `InfiniteQueryObserver` | The infinite twin of `observe`. TanStack: `new InfiniteQueryObserver(client, options)`. | TanStack Query's `query` and `infiniteQuery` replace its deprecated `fetchQuery`, `prefetchQuery` and `ensureQueryData` (and their infinite twins); this package has only the new pair. To prefetch, `.ignore()` the future; to fetch only when nothing is cached, pass `staleTime: StaleTime.static`; to reshape the result, `await` and map it — there is no `select` here. See [prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md). ## Reading | Method | Returns | What it does | |---|---|---| | [`getQueryData(key)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/getQueryData.html) | `T?` | The cached data, stale or not, or `null`. Neither fetches nor subscribes. Throws `QueryDataTypeError` if the entry holds another type. | | [`getInfiniteQueryData(key)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/getInfiniteQueryData.html) | `InfiniteData?` | `getQueryData` for an infinite query. TanStack: `getQueryData`. | | [`getQueryState(key)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/getQueryState.html) | `QueryState?` | The entry's whole [state](https://dualmeta-gmbh.github.io/query_kit/docs/reference/results.md): status, fetch status, timestamps, counters. Throws `QueryDataTypeError` on a type mismatch. | | [`getQueriesData({required filters})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/getQueriesData.html) | `List<(QueryKey, T?)>` | The data of every matching entry, `null` where one holds none yet. Throws `QueryDataTypeError` if any match holds another type. | | [`isFetching({filters})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/isFetching.html) | `int` | How many matching queries are fetching right now (not paused). A `fetchStatus` in the filters is ignored. | | [`isMutating({filters})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/isMutating.html) | `int` | How many matching mutations are pending, callbacks included. A `status` in the filters is ignored. Takes a `MutationFilters`. | ## Writing | Method | Returns | What it does | |---|---|---| | [`setQueryData(key, data, {updatedAt})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/setQueryData.html) | `T` | Writes `data`, creating the entry if needed; it counts as freshly fetched (`updatedAt`, default now). An existing entry takes any value its type can hold; otherwise `QueryDataTypeError`. Returns what the cache now holds, after structural sharing. A bare `setQueryData(key, null)` writes nothing; name the nullable type to store `null`. TanStack: `setQueryData(key, value)`. | | [`updateQueryData(key, updater, {updatedAt})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/updateQueryData.html) | `T?` | `updater` receives the current data (or `null`) and returns the new data; returning `null` writes nothing and returns `null`. Throws `QueryDataTypeError` when the held data is not a `T` or the entry cannot hold what the updater returned. TanStack: `setQueryData(key, updater)`. | | [`updateQueriesData(updater, {required filters, updatedAt})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/updateQueriesData.html) | `List<(QueryKey, T?)>` | The same over every match, in one batch. Every updater runs, and every type is checked, before anything is written, so an updater that reads another matched key sees it unwritten. TanStack: `setQueriesData`. | See [updates from mutation responses](https://dualmeta-gmbh.github.io/query_kit/docs/guides/updates-from-mutation-responses.md) and [optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md). ## Operating on many queries All five take `filters` (default: every query). The four that return a future report a throwing filter predicate by failing the future, and their refetch failures land in the queries' states, not in the future. | Method | Returns | What it does | |---|---|---| | [`invalidateQueries({filters, refetchType, cancelRefetch = true})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/invalidateQueries.html) | `Future` | Marks the matches stale, then refetches those `refetchType` names — a `RefetchType` (`active`, `inactive`, `all`, `none`); unset, the filter's own `type`, else the active ones. `RefetchType.none` only marks. The matched set is fixed before marking. | | [`refetchQueries({filters, cancelRefetch = true})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/refetchQueries.html) | `Future` | Refetches the matches, stale or not. Skipped: a query none of whose observers is enabled, an unobserved query that has never fetched, and one an observer marks `StaleTime.static`. Does not wait for a fetch paused for the network. | | [`cancelQueries({filters, revert = true, silent = false})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/cancelQueries.html) | `Future` | Cancels matching fetches in flight and completes when they have settled. `revert` puts each query back to its state before the fetch; `silent` cancels without dispatching an error. A failed cancellation never fails the future. | | [`resetQueries({filters, cancelRefetch = true})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/resetQueries.html) | `Future` | Puts the matches back to their initial state (initial data included), then refetches the active ones. | | [`removeQueries({filters})`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/removeQueries.html) | `void` | Removes the matches from the cache, cancelling their fetches silently. For keys nobody watches; an observer stays on the removed entry. | `cancelRefetch: true` restarts a fetch already in flight on a query that holds data, and joins it on one that does not; `false` always joins. See [query invalidation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-invalidation.md) and [filters](https://dualmeta-gmbh.github.io/query_kit/docs/guides/filters.md). ## Defaults | Member | Returns | What it does | |---|---|---| | [`getDefaultOptions()`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/getDefaultOptions.html) | `DefaultOptions` | The client-wide defaults in force. | | [`setDefaultOptions(options)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/setDefaultOptions.html) | `void` | Replaces them. Takes effect wherever options are resolved next; options a query already holds are not changed. | | [`setQueryDefaults(key, defaults)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/setQueryDefaults.html) | `void` | Defaults for every query whose key starts with `key`. The same key again replaces them. | | [`getQueryDefaults(key)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/getQueryDefaults.html) | `QueryDefaults?` | Every registration matching `key`, merged in the order the keys were first registered (registering a key again replaces its defaults but keeps its place), the later winning per field; `null` when none matches. The client-wide defaults are not included. | | [`setMutationDefaults(key, defaults)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/setMutationDefaults.html) | `void` | The mutation twin. Carries no callbacks. | | [`getMutationDefaults(key)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/getMutationDefaults.html) | `MutationDefaults?` | The mutation twin of `getQueryDefaults`. | | [`defaultQueryOptions(options)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/defaultQueryOptions.html) | `DefaultedQueryOptions` | The options with every unset field filled in: what a query actually runs with. Throws `ArgumentError` when both `initialDataUpdatedAt` forms are set. Rarely needed outside a test. | | [`defaultQueryObserverOptions(options)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/defaultQueryObserverOptions.html) | `DefaultedQueryObserverOptions` | The same for observer options, observer fields included. TanStack: `defaultQueryOptions`. | | [`defaultMutationOptions(options)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/defaultMutationOptions.html) | `DefaultedMutationOptions` | The same for mutation options. Throws `ArgumentError` when both `mutationFn` and `mutationFnWithContext` are set. | | [`infiniteObserverOptions(options)`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/infiniteObserverOptions.html) | `QueryObserverOptionsBase, TData>` | Turns infinite observer options into the options an `InfiniteQueryObserver.setOptions` takes; `setInfiniteOptions` on the observer is the shorter way. No TanStack counterpart. | The three defaults classes: | Class | Fields | |---|---| | [`DefaultOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/DefaultOptions-class.html) | `queries` (`QueryDefaults?`), `mutations` (`MutationDefaults?`). | | [`QueryDefaults`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryDefaults-class.html) | `queryFn`, `structuralSharing`, `enabled`, `staleTime`, `gcTime`, `retry`, `retryDelay`, `retryOnMount`, `networkMode`, `refetchOnMount`, `refetchOnWindowFocus`, `refetchOnReconnect`, `refetchInterval`, `refetchIntervalInBackground`, `meta`. TanStack: `DefaultOptions.queries`. | | [`MutationDefaults`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationDefaults-class.html) | `mutationFn`, `retry`, `retryDelay`, `networkMode`, `gcTime`, `scope`, `meta`. TanStack: `DefaultOptions.mutations`, which also takes callbacks. | Each field means what the [option](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-options.md) of the same name means. `queryFn`, `structuralSharing` and `mutationFn` are typed on `Object?`, because one default serves every type under a key prefix; the result is checked against the reader's type and a mismatch throws `QueryDataTypeError`. `initialData`, `placeholderData` and `select` belong to one query and have no default. All three classes are `const`, compare by value (functions by `==`, so a closure equals only itself), and `QueryDefaults` and `MutationDefaults` have `mergedWith(other)`, which lays `other`'s set fields over these. See [default query function](https://dualmeta-gmbh.github.io/query_kit/docs/guides/default-query-function.md). ## Lifecycle | Method | What it does | |---|---| | [`mount()`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/mount.html) | Starts listening to focus and connectivity: focus and reconnect refetches, resuming paused mutations (first, and the queries wait for them). Counted: two mounts need two unmounts. `QueryClientProvider` mounts its client. | | [`unmount()`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/unmount.html) | Stops listening. Does not touch the caches. An unmount without a mount does nothing. | | [`clear()`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/clear.html) | Empties both caches, cancelling fetches and every `gcTime` timer. Observers are not stopped — destroy them first. A pending mutation dropped here fails, and its callbacks run a few microtasks later; see [testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md) for the teardown that allows for it. | | [`resumePausedMutations()`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/resumePausedMutations.html) | Returns a `Future`. Resumes every paused mutation that can run now and completes when they have settled. The gate is each mutation's own network mode; TanStack Query instead skips the whole call while offline. | ## Not here `getQueryCache()` and `getMutationCache()` are fields, and the deprecated `fetchQuery`, `prefetchQuery` and `ensureQueryData` family is covered by `query`. Persistence (`hydrate`, `dehydrate`), `isRestoring` and `queryKeyHashFn` are not in 1.0; see the [feature matrix](https://dualmeta-gmbh.github.io/query_kit/docs/reference/feature-matrix.md). --- # Options reference > Every field of the query, infinite-query and mutation options, with its type, its built-in default and what it does. Every option a query, an infinite query or a mutation takes, with its type, its default and what it does. The guides explain *when* to reach for each one; this page is the table to look things up in. The concept page is [describing a query once](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-options.md). A row names the TanStack Query spelling only where it differs from the Dart one. ## How a field gets its value **`null` means "not configured"** on every field below. A field that `QueryDefaults` or `MutationDefaults` also has takes, when left `null`, in this order: 1. the defaults registered for a matching key with `client.setQueryDefaults` / `client.setMutationDefaults` (several matching prefixes merge in the order their keys were first registered, the later one winning per field), 2. the client-wide `DefaultOptions` passed to `QueryClient(defaultOptions:)` or `setDefaultOptions`, 3. the built-in default in the **Default** column. So `queryFn`, `mutationFn`, `structuralSharing`, `scope` and `meta`, which the defaults layer can also supply, show only the built-in fallback below. The other fields — `initialData` and its two timestamps, `select`, `placeholderData`, the paging fields, `mutationKey`, `mutationFnWithContext` and the mutation callbacks — have no defaults layer: unset is unset. An option that can be switched off says so with a value — `Enabled.no`, `RetryPolicy.never`, `RefetchInterval.off` — never with `null`. The value types are listed [at the end of this page](#option-values). The defaults layer is on the [client reference](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-client.md#defaults). An example, from a shop app's `lib/data/product_queries.dart`: ```dart QueryObserverOptions productQuery(ProductRepository repo, String id) => QueryObserverOptions( queryKey: QueryKey(['products', 'detail', id]), queryFn: (context) => repo.product(id, signal: context.signal), // A price may change, but not every second. staleTime: const StaleTime.duration(Duration(minutes: 2)), // Kept for a while after the detail screen closes, for the back button. gcTime: const GcTime.duration(Duration(minutes: 10)), retry: const RetryPolicy.times(2), refetchOnWindowFocus: RefetchOn.never, ); ``` ## The option classes | Class | Takes | Used by | |---|---|---| | [`QueryOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions-class.html) | the cache fields | `client.query` — fetch once and complete with the data | | [`QueryObserverOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverOptions-class.html) | cache fields + observer fields, no `select` | every widget call style, `client.observe`, `QueryObserver` | | [`QuerySelectOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/QuerySelectOptions-class.html) | the same, `select` required | the same, when the reader sees a projection | | [`InfiniteQueryOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryOptions-class.html) | cache fields + paging fields | `client.infiniteQuery` (and `client.query`) | | [`InfiniteQueryObserverOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryObserverOptions-class.html) | cache + paging + observer fields, no `select` | the infinite call styles, `client.observeInfinite` | | [`InfiniteQuerySelectOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQuerySelectOptions-class.html) | the same, `select` required | the same, with a projection of the pages | | [`MutationOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions-class.html) | the mutation fields | every mutation call style, `MutationObserver` | The observer shapes extend the cache shape, so one options function serves `client.query` and a widget alike. `QueryObserverOptions.withSelect(select)` and `InfiniteQueryObserverOptions.withSelect(select)` turn a plain shape into its select shape with every other field carried over. Every class has `copyWith`, which leaves a field it is not handed as it was (handing one of `initialDataUpdatedAt` and `initialDataUpdatedAtCompute` clears the other) — except `MutationOptions`, which has none. The infinite shapes' `copyWith` throws `ArgumentError` for a `queryFn`, and the observer shapes' for `pages`. None of these classes has value equality, on purpose: options built inline in `build` are handed to the observer on every build, and the observer compares the values they *resolve* to. An inline `queryFn` closure is a new function on every build, so the resolved options differ and the query cache reports a `QueryObserverOptionsUpdated` event; it does not refetch and does not restart the stale or polling timers, which follow the query, `enabled` and the resolved `staleTime` and `refetchInterval`. ## Cache fields On `QueryOptions` and so on every query shape. They describe the cache entry, which every reader of the key shares. | Field | Type | Default | What it does | |---|---|---|---| | [`queryKey`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/queryKey.html) | `QueryKey` | required | The key the entry is cached under. A key holds one exact type; reading it as another throws `QueryDataTypeError`. | | [`queryFn`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/queryFn.html) | `QueryFn?` — `FutureOr Function(QueryFunctionContext)` | none | Fetches the data. With none here or in the defaults, a fetch fails with `MissingQueryFunctionError`, which is never retried. Not on the infinite shapes, which take `pageFn`. | | [`enabled`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/enabled.html) | `Enabled?` | `Enabled.yes` | Whether the query fetches on its own. A disabled query still serves cached data and can be refetched by hand. | | [`staleTime`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/staleTime.html) | `StaleTime?` | `StaleTime.zero` | How long fetched data counts as fresh. | | [`gcTime`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/gcTime.html) | `GcTime?` | five minutes (`GcTime.defaultValue`) | How long the entry stays cached after its last reader leaves. | | [`retry`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/retry.html) | `RetryPolicy?` | `RetryPolicy.times(3)`; for `client.query` with no retry configured anywhere, no retries | Whether and how often a failed fetch is retried. | | [`retryDelay`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/retryDelay.html) | `RetryDelay?` | 1 s doubling, at most 30 s (`RetryDelay.defaultValue`) | The wait between attempts. | | [`networkMode`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/networkMode.html) | `NetworkMode?` | `NetworkMode.online` | How connectivity gates the fetch. See [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md). | | [`initialData`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/initialData.html) | `InitialData?` | none | Seed data written into the cache as if fetched. See [initial data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md). | | [`initialDataUpdatedAt`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/initialDataUpdatedAt.html) | `DateTime?` | none: the seed counts as fetched when written | When the seed was fetched, for the staleness clock. TanStack: `initialDataUpdatedAt` as a number. | | [`initialDataUpdatedAtCompute`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/initialDataUpdatedAtCompute.html) | `DateTime? Function()?` | none | The same, computed only when data is actually seeded; `null` from it means now. Setting both forms throws `ArgumentError` when the options are resolved. TanStack: `initialDataUpdatedAt` as a function. | | [`structuralSharing`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/structuralSharing.html) | `StructuralSharing?` — `TQueryData Function(TQueryData? previous, TQueryData next)` | `replaceEqualDeep` | How new data is reconciled with what is cached. `noStructuralSharing()` turns it off. See [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md). TanStack spells the opt-out `structuralSharing: false`. | | [`meta`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryOptions/meta.html) | `Object?` | none | Free-form data, handed to the query function as `context.meta` and readable off the query. | The query function receives a [`QueryFunctionContext`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryFunctionContext-class.html): `client`, `queryKey`, `meta` and `signal`, a `QueryCancelToken`. Reading `signal` is what makes the fetch cancellable; see [query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md). ## Observer fields On `QueryObserverOptions`, `QuerySelectOptions` and the infinite observer shapes. They describe one reader, so two widgets watching one key may poll, refetch and select differently. | Field | Type | Default | What it does | |---|---|---|---| | [`select`](https://pub.dev/documentation/query_kit/latest/query_kit/QuerySelectOptions/select.html) | `SelectFn` — `TData Function(TQueryData)` | required on the select shapes, absent on the plain ones | Projects the cached data into what this reader sees. While the projection stays equal, `data` keeps its instance (unless `structuralSharing` is switched off); the rest of the result (`fetchStatus`, `dataUpdatedAt`, …) still changes and still notifies — `buildWhen` narrows rebuilds. A throwing `select` makes the result a `QueryError`. See [render optimizations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md). | | [`placeholderData`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverOptionsBase/placeholderData.html) | `PlaceholderData?` | none | Data shown while the query is pending with no data of its own — not while it is in an error state, though a refetch after the error shows it again. Never cached; the result reports `isPlaceholderData`. See [placeholder data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md). | | [`refetchOnMount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverOptionsBase/refetchOnMount.html) | `RefetchOn?` | `RefetchOn.ifStale` | Whether this reader subscribing triggers a refetch. | | [`refetchOnWindowFocus`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverOptionsBase/refetchOnWindowFocus.html) | `RefetchOn?` | `RefetchOn.ifStale` | Whether the app returning to the foreground triggers a refetch. See [app focus refetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md). | | [`refetchOnReconnect`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverOptionsBase/refetchOnReconnect.html) | `RefetchOn?` | `RefetchOn.ifStale`; `RefetchOn.never` under `NetworkMode.always` | Whether the network coming back triggers a refetch. | | [`refetchInterval`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverOptionsBase/refetchInterval.html) | `RefetchInterval?` | `RefetchInterval.off` | Polls while this reader is subscribed and the query is enabled, stale or not, a `StaleTime.static` query included. See [polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md). | | [`refetchIntervalInBackground`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverOptionsBase/refetchIntervalInBackground.html) | `bool?` | `false` | Whether polling continues while the app is not focused. | | [`retryOnMount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverOptionsBase/retryOnMount.html) | `bool?` | `true` | Whether a query in an error state with no data is fetched again when a reader subscribes (one holding data follows `refetchOnMount`). TanStack also takes a function of the query here. | Not here: `notifyOnChangeProps` (whole results are compared; `buildWhen` on every read narrows rebuilds), `throwOnError` (errors are a case of the sealed result), `queryKeyHashFn` (`QueryKey` is a value type), `subscribed` and `experimental_prefetchInRender`. See [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). ## Infinite query fields On `InfiniteQueryOptions` and the two infinite observer shapes, in addition to the cache fields (without `queryFn`) and, on the observer shapes, the observer fields. The data type is `InfiniteData`: `pages` and `pageParams`. See [infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md). | Field | Type | Default | What it does | |---|---|---|---| | [`pageFn`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryOptions/pageFn.html) | `InfinitePageFn` — `FutureOr Function(InfinitePageContext)` | required | Fetches one page. Its context carries a typed `pageParam`, the `direction`, `queryKey`, `client`, `meta` and `signal`. TanStack: `queryFn`. | | [`initialPageParam`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryOptions/initialPageParam.html) | `TPageParam` | required | The param the first page is fetched with. | | [`getNextPageParam`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryOptions/getNextPageParam.html) | `PageParamFn` — `TPageParam? Function(page, pages, pageParam, pageParams)` | required | The param of the page after the last one; `null` means there is none, so `hasNextPage` is false. | | [`getPreviousPageParam`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryOptions/getPreviousPageParam.html) | `PageParamFn?` | none: `hasPreviousPage` is always false | The same, backwards from the first page. | | [`maxPages`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryOptions/maxPages.html) | `int?` | none: every page is kept (`0` too) | How many pages to keep. A page fetch past the limit drops one page from the far end. | | [`pages`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryOptions/pages.html) | `int?` | none: one page into an empty query, every held page on a refetch | How many pages to fetch up front. Only on `InfiniteQueryOptions`, for `client.infiniteQuery` (or `client.query`); the observer shapes refuse it. A count handed to the client stays on the shared query and shapes later refetches that bring no options of their own (`invalidateQueries`, `refetchQueries`) until an observer on the key installs its own options — creating or subscribing one, or any `setOptions`, does that without a fetch. | A refetch of an infinite query — invalidation, focus, polling — requests the held pages again, first to last, each with the param computed from the page before it, and stops early if `getNextPageParam` returns `null`. `hasNextPage`, `fetchNextPage` and their backward twins live on the infinite observer and controller, not on the result; see [results](https://dualmeta-gmbh.github.io/query_kit/docs/reference/results.md). ## Mutation fields On [`MutationOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions-class.html). The three type arguments are what the function returns, what it is called with, and what `onMutate` returns. `MutationOptions.simple(...)` takes the same fields minus `onMutate` and fixes the third type to `void`, so the other two infer from `mutationFn`. See [mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md). | Field | Type | Default | What it does | |---|---|---|---| | [`mutationKey`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/mutationKey.html) | `QueryKey?` | none | Addresses the mutation for filters, `isMutating`, mutation state and `setMutationDefaults`. | | [`mutationFn`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/mutationFn.html) | `MutationFn?` — `FutureOr Function(TVariables)` | none | Performs the write. With none here or in the defaults, a run fails with `MissingMutationFunctionError`, not retried. | | [`mutationFnWithContext`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/mutationFnWithContext.html) | `MutationFnWithContext?` — `FutureOr Function(TVariables, MutationFunctionContext)` | none | The same with a context: `client`, `meta`, `mutationKey`, `onMutateResult` and a `signal` that `cancel()` cancels. Set one of the two functions, never both (an assertion in debug builds, `ArgumentError` when resolved); when set it wins over a default `mutationFn`. TanStack: `mutationFn`'s second argument. | | [`onMutate`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/onMutate.html) | `OnMutate?` — `FutureOr Function(TVariables)` | none | Runs when the mutation is submitted, before the function; its result is handed to the other callbacks, typically a rollback snapshot. A throw fails the mutation without running the function. | | [`onSuccess`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/onSuccess.html) | `OnMutationSuccess?` — `(data, variables, onMutateResult)` | none | Runs after `MutationCache.onSuccess`. Awaited; a throw turns the success into an error. | | [`onError`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/onError.html) | `OnMutationError?` — `(error, stackTrace, variables, onMutateResult)` | none | Runs after `MutationCache.onError`, retries spent. Awaited; a throw is reported to the zone and does not replace the error. | | [`onSettled`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/onSettled.html) | `OnMutationSettled?` — `(data, error, stackTrace, variables, onMutateResult)` | none | Runs last, on success and error alike. The mutation stays pending until a returned future completes. | | [`retry`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/retry.html) | `RetryPolicy?` | `RetryPolicy.never` | Whether a failed attempt is retried. TanStack's default is `retry: 0`. | | [`retryDelay`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/retryDelay.html) | `RetryDelay?` | 1 s doubling, at most 30 s | The wait between attempts. | | [`networkMode`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/networkMode.html) | `NetworkMode?` | `NetworkMode.online` | Offline, an `online` mutation pauses and a mounted client resumes it on reconnect. | | [`gcTime`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/gcTime.html) | `GcTime?` | five minutes | How long a settled mutation stays in the cache once nothing observes it. | | [`scope`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/scope.html) | `MutationScope?` | none: unscoped mutations run in parallel | Mutations with equal scopes run one at a time, in submission order. Fixed for a run once it starts. See [mutation scopes](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-scopes.md). TanStack: `scope: { id }`. | | [`meta`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions/meta.html) | `Object?` | none | Free-form data, readable as `mutation.meta` and `context.meta`. | The callbacks run in this order, each returned future awaited before the next: `MutationCache.onMutate`, `onMutate`; after the function, `MutationCache.onSuccess` (or `onError`), `onSuccess` (or `onError`), `MutationCache.onSettled`, `onSettled`. Callbacks have no default layer: `setMutationDefaults` carries none. ### Per-call callbacks `mutate(variables, callbacks: MutateCallbacks(...))` adds callbacks for one call — closing a dialog, showing a snack bar. [`MutateCallbacks`](https://pub.dev/documentation/query_kit/latest/query_kit/MutateCallbacks-class.html) has `onSuccess`, `onError` and `onSettled` with the signatures above, each optional. They run after the options' callbacks, only while the observer still has a listener, are not awaited, and a throw in one is reported to the zone. TanStack Query: the second argument of `mutate`. ## Option values Each option with modes is a small sealed family of `const` values. A value built inline compares equal to an equal one, so a rebuild with the same value is not a change; the computed forms compare equal when their function is the same (a tear-off, not an inline closure). Each type also has the method the library resolves it with (`resolve`, `shouldRetry`, …); application code rarely calls them. Each case is a public class (`StaleTimeDuration`, `EnabledWhen`, `RetryTimes`, …), so a `switch` can name them. | Type | Spellings | |---|---| | [`StaleTime`](https://pub.dev/documentation/query_kit/latest/query_kit/StaleTime-class.html) | `StaleTime.zero` (default), `StaleTime.duration(d)`, `StaleTime.infinite` (never stale by time, invalidation still works), `StaleTime.static` (never stale; skipped by the mount, focus and reconnect refetches and by `refetchQueries` and invalidation while observed; `refetchInterval` still polls it), `StaleTime.dynamic((query) => StaleTime)`. TanStack: `0`, a number of ms, `Infinity`, `'static'`, a function. | | [`GcTime`](https://pub.dev/documentation/query_kit/latest/query_kit/GcTime-class.html) | `GcTime.duration(d)`, `GcTime.defaultValue` (five minutes), `GcTime.never`; `GcTime.longest(a, b)` picks the longer of two. TanStack: a number of ms, `Infinity`. | | [`Enabled`](https://pub.dev/documentation/query_kit/latest/query_kit/Enabled-class.html) | `Enabled.yes` (default), `Enabled.no`, `Enabled.when((query) => bool)`. TanStack: `true`, `false` (also standing in for `queryFn: skipToken`), a function. | | [`RetryPolicy`](https://pub.dev/documentation/query_kit/latest/query_kit/RetryPolicy-class.html) | `RetryPolicy.never`, `RetryPolicy.always`, `RetryPolicy.times(n)` (n retries, n + 1 attempts), `RetryPolicy.when((failureCount, error, stackTrace) => bool)` — `failureCount` is `0` on the first decision. TanStack: `false`, `true`, a number, a function. | | [`RetryDelay`](https://pub.dev/documentation/query_kit/latest/query_kit/RetryDelay-class.html) | `RetryDelay.defaultValue`, `RetryDelay.exponential({base = 1 s, maximum = 30 s})` — with no arguments the default, `RetryDelay.fixed(d)`, `RetryDelay.dynamic((failureCount, error) => Duration)`. TanStack: the default function, a number of ms, a function. | | [`RefetchOn`](https://pub.dev/documentation/query_kit/latest/query_kit/RefetchOn-class.html) | `RefetchOn.ifStale` (default), `RefetchOn.always`, `RefetchOn.never`, `RefetchOn.when((query) => RefetchOn)`. TanStack: `true`, `'always'`, `false`, a function. | | [`RefetchInterval`](https://pub.dev/documentation/query_kit/latest/query_kit/RefetchInterval-class.html) | `RefetchInterval.off` (default), `RefetchInterval.every(d)`, `RefetchInterval.dynamic((query) => Duration?)` — `null`, zero or a negative duration stops polling. TanStack: `false`, a number of ms, a function. | | [`NetworkMode`](https://pub.dev/documentation/query_kit/latest/query_kit/NetworkMode.html) (an enum) | `online` (default), `always`, `offlineFirst`. | | [`InitialData`](https://pub.dev/documentation/query_kit/latest/query_kit/InitialData-class.html) | `InitialData.value(data)`, `InitialData.compute(() => data?)` — `null` from the callback means no seed. TanStack: a value, a function. | | [`PlaceholderData`](https://pub.dev/documentation/query_kit/latest/query_kit/PlaceholderData-class.html) | `PlaceholderData.keepPrevious()`, `PlaceholderData.value(data)`, `PlaceholderData.compute((previousData, previousQuery) => data?)`. TanStack: `keepPreviousData`, a value, a function. | | [`MutationScope`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationScope-class.html) | `MutationScope(id)` — equal ids are one scope. TanStack: `{ id }`. | `.value(null)` is a seed (or placeholder) of `null` for a nullable type; only a `.compute` returning `null` means "none". That is the one place where Dart's single `null` carries two meanings. --- # Results > Every field of what a reader is handed — QueryResult and its cases, QueryState, InfiniteData and the paging flags, MutationResult and MutationState, and CombinedResult. A reader never gets a bag of booleans. It gets a sealed value: a `QueryResult` for a query, a `MutationResult` for a mutation, a `CombinedResult` for several queries read together. A `switch` over one is exhaustive, and each case carries exactly the fields that exist in it. This page lists every field and getter and which case carries it. A TanStack Query name is given only where it differs from the Dart one, and a member with no counterpart there says so. Where a result comes from — an observer's `currentResult`, a controller's `value`, a builder's argument — is on [widgets and controllers](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md) and [caches and observers](https://dualmeta-gmbh.github.io/query_kit/docs/reference/caches-and-observers.md). For where the two libraries behave differently, see [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). ## QueryResult [`QueryResult`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult-class.html) is what every query observer hands out, and a new one whenever something it reports changes. It is sealed, with three cases: | Case | When | |---|---| | [`QueryPending`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryPending-class.html) | Nothing has resolved: no data, no error. The first load; a query without data fetching again after a failure; a query after a reset; a disabled query that has never fetched (then `fetchStatus` is `idle`). | | [`QuerySuccess`](https://pub.dev/documentation/query_kit/latest/query_kit/QuerySuccess-class.html) | The query holds `data`: fetched, seeded with `initialData`, written with `setQueryData`, or a placeholder. May be fetching at the same time — a background refresh. | | [`QueryError`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryError-class.html) | The last fetch failed after its retries, or the observer's `select` threw. Data from an earlier success stays in `staleData`. | What the query *holds* is the case. What it is *doing* is `fetchStatus` and the flags derived from it, which vary independently of the case. See [queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/queries.md) for how the two axes combine. ### Fields and getters "All" means the field is declared on `QueryResult` and every case carries it. | Name | Type | On which cases | Meaning | |---|---|---|---| | [`status`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/status.html) | `QueryStatus` | all | The case, as an enum: `pending`, `success` or `error`. For storing or comparing rather than matching. | | [`fetchStatus`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/fetchStatus.html) | `FetchStatus` | all | What the query is doing: `fetching`, `paused` or `idle`. | | [`isPending`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isPending.html) | `bool` | all | This is a `QueryPending`. | | [`isSuccess`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isSuccess.html) | `bool` | all | This is a `QuerySuccess`, whether or not a refresh is running. | | [`isError`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isError.html) | `bool` | all | This is a `QueryError`. | | [`isFetching`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isFetching.html) | `bool` | all | `fetchStatus` is `fetching`: a first load or a refetch is in flight. | | [`isPaused`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isPaused.html) | `bool` | all | `fetchStatus` is `paused`: a fetch wants to run but waits for the network (per `networkMode`) or for the app to return to the foreground before its next retry. | | [`isLoading`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isLoading.html) | `bool` | all | Pending **and** fetching: the first load. False for a pending query that is not fetching, such as a disabled one. | | [`isRefetching`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isRefetching.html) | `bool` | all | Fetching and **not** pending: a background refresh of data on screen, including data still held after a failed refetch. A query without data that fetches again is pending, so this is false. | | [`dataOrNull`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/dataOrNull.html) | `TData?` | all | `data` on a success, `staleData` on an error, `null` when pending. TanStack: `data`. | | [`errorOrNull`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/errorOrNull.html) | `Object?` | all | `error` on a `QueryError`, `null` otherwise. TanStack: `error`. | | [`data`](https://pub.dev/documentation/query_kit/latest/query_kit/QuerySuccess/data.html) | `TData` | `QuerySuccess` | The data, after `select` when the observer has one. A placeholder when `isPlaceholderData` is true. | | [`error`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryError/error.html) | `Object` | `QueryError` | What the last attempt of the failed fetch threw, or what `select` threw. A cancelled fetch that was not reverted fails with a `CancelledError`. | | [`stackTrace`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryError/stackTrace.html) | `StackTrace` | `QueryError` | Where `error` was thrown. No TanStack counterpart. | | [`staleData`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryError/staleData.html) | `TData?` | `QueryError` | The data from the last successful fetch or write, kept through the error; `null` when there was none. TanStack: `data`, in the error state. | | [`hasStaleData`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryError/hasStaleData.html) | `bool` | `QueryError` | Whether `staleData` means anything. Tells a real `null` from none when `TData` is nullable. No TanStack counterpart. | | [`isLoadingError`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryError/isLoadingError.html) | `bool` | `QueryError` | The first load failed; there is nothing to show (`!hasStaleData`). | | [`isRefetchError`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryError/isRefetchError.html) | `bool` | `QueryError` | A refetch failed over data that is still on screen (`hasStaleData`). | | [`dataUpdatedAt`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/dataUpdatedAt.html) | `DateTime?` | all | When the data was last written, by a fetch or by hand — what `staleTime` counts from. `null` until something has been. | | [`errorUpdatedAt`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/errorUpdatedAt.html) | `DateTime?` | all | When the query last ended in an error (or `select` last threw). Not cleared by a later success. | | [`failureCount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/failureCount.html) | `int` | all | Failed attempts within the current fetch. Reset when a new fetch starts. | | [`failureReason`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/failureReason.html) | `Object?` | all | What the latest failed attempt threw. Kept while retries continue and after the fetch finally fails; cleared when the next fetch starts or an attempt succeeds. | | [`failureStackTrace`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/failureStackTrace.html) | `StackTrace?` | all | The stack trace of `failureReason`; `null` whenever it is. No TanStack counterpart. | | [`errorUpdateCount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/errorUpdateCount.html) | `int` | all | How many times the query has ended in an error over its whole life. Never goes down. | | [`consecutiveErrorCount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/consecutiveErrorCount.html) | `int` | all | Fetches in a row that ended in an error. Back to zero with the next *fetched* data; a manual write and a cancelled fetch leave it alone. See [polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md). No TanStack counterpart. | | [`isStale`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isStale.html) | `bool` | all | The data is older than this observer's `staleTime`, or was invalidated. A query with no data is stale; a disabled one never is. `StaleTime.static` data is never stale, invalidated or not. | | [`isEnabled`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isEnabled.html) | `bool` | all | This observer's `enabled` currently lets the query fetch on its own. `refetch` runs regardless. | | [`isFetched`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isFetched.html) | `bool` | all | Anything has ever been fetched or written, successfully or not. | | [`isFetchedAfterMount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isFetchedAfterMount.html) | `bool` | all | A fetch or write has completed since this observer attached, as opposed to data already in the cache. | | [`isPlaceholderData`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/isPlaceholderData.html) | `bool` | all (true only on `QuerySuccess`) | `data` is the observer's `placeholderData`, not cached data. See [placeholder data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/placeholder-query-data.md). | | [`refetch`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult/refetch.html) | [`QueryRefetch`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryRefetch.html) | all | `refetch({bool cancelRefetch = true})`: fetches again regardless of `enabled` and `staleTime`, and completes with the result that follows — never with an error. `cancelRefetch: true` cancels a fetch in flight on a query that holds data and starts over; a first load is joined. `false` joins the fetch in flight. | TanStack Query's `isInitialLoading` (a deprecated alias of `isLoading`) and `promise` have no counterpart. > **Note: A throwing select** > > When the observer's `select` throws, the result is a `QueryError` carrying > what it threw, with the last value `select` produced, if any, kept in `staleData`. > The cached data is untouched, and the next successful selection clears the > error. ### Equality Results compare by value. Two results are equal when they are the same case and carry equal data, error and fields; `refetch` and the stack traces take no part. An unchanged result therefore compares equal to the previous one, which is what a `buildWhen` or a `ValueListenable` relies on. ### QueryStatus and FetchStatus [`QueryStatus`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryStatus.html) is what the query holds; [`FetchStatus`](https://pub.dev/documentation/query_kit/latest/query_kit/FetchStatus.html) is what it is doing. Any status combines with any fetch status. | Enum | Value | Meaning | |---|---|---| | `QueryStatus` | `pending` | No data and no error: before the first fetch settles (unless `initialData` seeded the query), after a reset, and while a query without data fetches again after a failure. | | `QueryStatus` | `success` | The query holds data, fetched or seeded. | | `QueryStatus` | `error` | The last fetch failed and its retries are spent. Data from an earlier success is kept alongside. | | `FetchStatus` | `fetching` | A fetch is in flight. | | `FetchStatus` | `paused` | A fetch wants to run but cannot: offline under a `networkMode` that waits, or a retry waiting for the app to return to the foreground. See [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md). | | `FetchStatus` | `idle` | Nothing is happening. | TanStack Query uses the same values as strings. ## QueryState [`QueryState`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState-class.html) is what the cache entry itself holds, before any observer's `select`, `placeholderData` or `staleTime` applies. Read it from `Query.state` — in a cache listener, a `QueryFilters` predicate, or a `RefetchInterval.dynamic` or `StaleTime.dynamic` callback. It is flat rather than sealed, because the counters survive every transition. It is publicly constructible so that a persistence layer can restore an entry (`QueryCache.build`, `Query.setState`). A `success` state must have `hasData: true`, or it is rejected with an `ArgumentError`. The no-argument constructor is the initial state: pending, idle, no data, every counter at zero. | Name | Type | Default | Meaning | |---|---|---|---| | [`status`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/status.html) | `QueryStatus` | `pending` | What the query holds. | | [`fetchStatus`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/fetchStatus.html) | `FetchStatus` | `idle` | What the query is doing. | | [`hasData`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/hasData.html) | `bool` | `false` | Whether `data` is meaningful: true once the query has resolved to data, even `null` data; stays true through a later error. No TanStack counterpart. | | [`data`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/data.html) | `TQueryData?` | `null` | The cached data. Meaningful only while `hasData` is true. | | [`dataUpdateCount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/dataUpdateCount.html) | `int` | `0` | How many times data has been written, by fetches and `setQueryData` alike. | | [`dataUpdatedAt`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/dataUpdatedAt.html) | `DateTime?` | `null` | When `data` was last written. | | [`error`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/error.html) | `Object?` | `null` | Why the last fetch failed. Cleared by the next success, and by the start of a new fetch on a query without data; a query with data keeps it alongside the error while it refetches. | | [`errorStackTrace`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/errorStackTrace.html) | `StackTrace?` | `null` | The stack trace of `error`. No TanStack counterpart. | | [`errorUpdateCount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/errorUpdateCount.html) | `int` | `0` | Errors over the query's whole life. Never goes down. | | [`consecutiveErrorCount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/consecutiveErrorCount.html) | `int` | `0` | Fetches in a row that failed, retries exhausted. Back to zero with the next fetched data; unchanged by a manual write or a cancelled fetch. No TanStack counterpart. | | [`errorUpdatedAt`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/errorUpdatedAt.html) | `DateTime?` | `null` | When the query last ended in an error. Not cleared with `error`. | | [`fetchFailureCount`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/fetchFailureCount.html) | `int` | `0` | Failed attempts inside the current fetch; reset when a new fetch starts. Surfaces as `QueryResult.failureCount`. | | [`fetchFailureReason`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/fetchFailureReason.html) | `Object?` | `null` | What the latest failed attempt threw. Surfaces as `QueryResult.failureReason`. | | [`fetchFailureStackTrace`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/fetchFailureStackTrace.html) | `StackTrace?` | `null` | The stack trace of `fetchFailureReason`. No TanStack counterpart. | | [`fetchMeta`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/fetchMeta.html) | `Object?` | `null` | Whatever the fetch behaviour attached to the current fetch. Infinite queries carry the page direction here. | | [`isInvalidated`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/isInvalidated.html) | `bool` | `false` | Stale regardless of `staleTime`: set by `invalidateQueries` and by a fetch that finally fails; reset by the next successful fetch or data write. | | [`isFetched`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/isFetched.html) | `bool` (getter) | — | `dataUpdateCount + errorUpdateCount > 0`. No TanStack counterpart. | | [`copyWith`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState/copyWith.html) | method | — | This state with fields replaced. Pass `hasData` whenever you pass `data`; `clearData`, `clearError`, `clearFetchFailure` and `clearFetchMeta` set fields back to nothing. No TanStack counterpart. | `QueryState` compares by value; the stack traces take no part. ## Infinite queries An infinite query is an ordinary query whose data is an `InfiniteData`, so its result is an ordinary `QueryResult>` (or whatever `select` makes of it). There is no separate infinite result type. See [infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md). ### InfiniteData [`InfiniteData`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteData-class.html) | Name | Type | Meaning | |---|---|---| | [`pages`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteData/pages.html) | `List` | The pages in order. A forward fetch appends, a backward fetch prepends, `maxPages` drops from the far end. | | [`pageParams`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteData/pageParams.html) | `List` | The param each page was fetched with, index for index with `pages`. | | [`isEmpty`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteData/isEmpty.html) | `bool` | `pages.isEmpty`. A fetched value always holds at least one page; an empty one comes from `initialData` or `setQueryData`. No TanStack counterpart. | | [`flatten`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteData/flatten.html) | `Iterable flatten()` | Every item of every page, when each page is an `Iterable`. A page that is not throws an `ArgumentError` before iteration starts. Name the item type: without it the result is `Iterable`. No TanStack counterpart. | | [`copyWith`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteData/copyWith.html) | method | This value with either list replaced. A replacement list is copied into an unmodifiable one; a list passed back unchanged keeps its identity. No TanStack counterpart. | The constructor refuses lists of different lengths with an `ArgumentError`. A value built by a fetch or by `copyWith` holds unmodifiable lists, so `pages.add(…)` on fetched data read back from the cache throws `UnsupportedError`. A value you build yourself and pass in — as `initialData` or through `setQueryData` — can keep the growable lists you gave it until the next fetch replaces it. Either way, write a new value with `setQueryData` rather than changing the lists in place. Equality is element by element over both lists. ### Paging members `hasNextPage`, `fetchNextPage` and the direction flags are **not** on the result. The result has one shape for every kind of query, so the paging surface lives on [`InfiniteQueryObserver`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryObserver-class.html) and, in Flutter, on [`InfiniteQueryController`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController-class.html), which every infinite read style hands you. A change in any of these flags notifies listeners even when the result itself is unchanged. (TanStack Query puts them on its infinite result object.) | Name | Type | Meaning | |---|---|---| | `hasNextPage` | `bool` | `getNextPageParam` returns a param for the last page held. False before the first page arrives. | | `hasPreviousPage` | `bool` | `getPreviousPageParam` is set and returns a param for the first page held. False before the first page arrives. | | `isFetchingNextPage` | `bool` | The fetch in flight is a `fetchNextPage`. | | `isFetchingPreviousPage` | `bool` | The fetch in flight is a `fetchPreviousPage`. | | `isFetchNextPageError` | `bool` | The result is a `QueryError` that came from a `fetchNextPage`. | | `isFetchPreviousPageError` | `bool` | The result is a `QueryError` that came from a `fetchPreviousPage`. | | `isRefetching` | `bool` | The pages already held are being refetched. Unlike the result's own `isRefetching`, a page being added does not count. | | `isRefetchError` | `bool` | A refetch of the held pages failed, as opposed to a page fetch. | | `fetchNextPage` | `Future> fetchNextPage({bool cancelRefetch = true})` | Fetches the page after the last one and appends it. Does nothing when `hasNextPage` is false; on a query with no pages it loads the first one. Completes with the result, never with an error. | | `fetchPreviousPage` | `Future> fetchPreviousPage({bool cancelRefetch = true})` | The mirror of `fetchNextPage`, prepending. | With `cancelRefetch: true` a fetch already running on a query that holds pages is cancelled; with `false`, or while the first page is loading, the call joins it and adds no page. Check `isFetchingNextPage` first so a scroll listener firing repeatedly does not cancel its own page fetch. ## MutationResult [`MutationResult`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult-class.html) is what a `MutationObserver` reports and what every mutation read style in Flutter hands a widget. It is sealed, with one case per `MutationStatus`: | Case | When | |---|---| | [`MutationIdle`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationIdle-class.html) | Nothing submitted yet, or `reset` since. | | [`MutationPending`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationPending-class.html) | A run is in flight — `onMutate`, the mutation function, or the settling callbacks — or paused (`isPaused`). | | [`MutationSuccess`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationSuccess-class.html) | The last run returned, and its success callbacks have run. | | [`MutationError`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationError-class.html) | The last run failed for good, and its error callbacks have run. A cancelled run fails with a `CancelledError`. | See [mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md). | Name | Type | On which cases | Meaning | |---|---|---|---| | [`status`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/status.html) | `MutationStatus` | all | The case, as an enum. | | [`isIdle`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/isIdle.html) | `bool` | all | This is a `MutationIdle`. | | [`isPending`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/isPending.html) | `bool` | all | This is a `MutationPending`. Handy for disabling a submit button. | | [`isSuccess`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/isSuccess.html) | `bool` | all | This is a `MutationSuccess`. | | [`isError`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/isError.html) | `bool` | all | This is a `MutationError`. | | [`dataOrNull`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/dataOrNull.html) | `TData?` | all | `data` on a success, `null` otherwise. TanStack: `data`. | | [`errorOrNull`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/errorOrNull.html) | `Object?` | all | `error` on an error, `null` otherwise. TanStack: `error`. | | [`data`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationSuccess/data.html) | `TData` | `MutationSuccess` | What the mutation function returned. | | [`error`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationError/error.html) | `Object` | `MutationError` | What the last attempt threw — or what a success callback threw, which counts the same. | | [`stackTrace`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationError/stackTrace.html) | `StackTrace` | `MutationError` | Where `error` was thrown. No TanStack counterpart. | | [`variables`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/variables.html) | `TVariables?` | all | The variables of the run in flight or last finished — what an optimistic UI shows while pending. | | [`hasVariables`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/hasVariables.html) | `bool` | all | Whether `variables` means anything: false while idle; tells a real `null` from none. No TanStack counterpart. | | [`failureCount`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/failureCount.html) | `int` | all | Failed attempts of the current run. Reset when a new run starts. | | [`failureReason`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/failureReason.html) | `Object?` | all | What the latest failed attempt threw. `null` once an attempt succeeds or a new run starts. | | [`isPaused`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/isPaused.html) | `bool` | all (set only while pending) | The run is parked: offline under `NetworkMode.online`, a retry waiting for the foreground, or queued behind another mutation in its `MutationScope`. | | [`submittedAt`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/submittedAt.html) | `DateTime?` | all | When the current run was submitted. `null` while idle. | | [`mutate`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/mutate.html) | `void Function(TVariables)` | all | Starts a new run and returns at once. Errors go to the callbacks and the next result, never to the caller. Takes only the variables, so it passes as a plain callback. | | [`mutateAsync`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/mutateAsync.html) | `Future Function(TVariables)` | all | Starts a new run; completes with its data or throws its error, once its callbacks have run. | | [`reset`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationResult/reset.html) | `void Function()` | all | Detaches from the mutation and goes back to `MutationIdle`. The mutation keeps running and still fires its callbacks. | Not on the result: - **What `onMutate` returned.** It is `MutationState.onMutateResult` (below), handed to the `onSuccess`, `onError` and `onSettled` callbacks. TanStack Query's result spreads the state and so also carries it as `context`. - **Per-call callbacks.** `MutateCallbacks` go through `MutationObserver.mutate(variables, callbacks: …)` or `MutationController.mutate`. - **`cancel`.** It is on `MutationObserver` and `MutationController`; see [cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md). Results compare by value; `mutate`, `mutateAsync`, `reset` and the stack trace take no part. ### MutationStatus [`MutationStatus`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationStatus.html) | Value | Meaning | |---|---| | `idle` | Never run, or reset since. No data, no error, no variables. | | `pending` | Running or paused. Lasts until the run has finished, callbacks included: the cache's and the options' `onSuccess`/`onError` and `onSettled`, and the future `onSettled` returns. The per-call callbacks run after it. | | `success` | The last run resolved; `data` holds what it returned. | | `error` | The last run failed for good; `error` holds why. | ## MutationState [`MutationState`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState-class.html) is what a `Mutation` in the cache holds. Read it from `Mutation.state` — in a `MutationStateObserver`'s `select` (see [mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md)), a `MutationCache` listener, or `MutationCache.findAll`. A persistence layer builds one to restore an offline mutation through `MutationCache.build`. The no-argument constructor is the idle state. | Name | Type | Default | Meaning | |---|---|---|---| | [`status`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/status.html) | `MutationStatus` | `idle` | Where the mutation is in its life. | | [`hasData`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/hasData.html) | `bool` | `false` | Whether `data` is authoritative, so a function that returned `null` still reads as having data. No TanStack counterpart. | | [`data`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/data.html) | `TData?` | `null` | What the last successful run returned. Cleared when a new run starts and when a run fails. | | [`error`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/error.html) | `Object?` | `null` | Why the last run failed; `null` unless `status` is `error`. | | [`errorStackTrace`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/errorStackTrace.html) | `StackTrace?` | `null` | The stack trace of `error`. No TanStack counterpart. | | [`variables`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/variables.html) | `TVariables?` | `null` | The variables of the run in flight or last finished. | | [`hasVariables`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/hasVariables.html) | `bool` | `false` | Whether a run has set `variables`; a `null` value is real once this is true. No TanStack counterpart. | | [`onMutateResult`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/onMutateResult.html) | `TOnMutateResult?` | `null` | What `onMutate` returned for the run in flight or last finished — the rollback handle of an optimistic update. TanStack: `context`. | | [`failureCount`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/failureCount.html) | `int` | `0` | Failed attempts of the current run. Reset on success and when a new run starts; one more when the run settles in error. | | [`failureReason`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/failureReason.html) | `Object?` | `null` | What the last failed attempt threw. Kept while retries continue and after the run fails; cleared on success and when a new run starts. | | [`isPaused`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/isPaused.html) | `bool` | `false` | The run is parked: network, foreground, or its scope. | | [`submittedAt`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/submittedAt.html) | `DateTime?` | `null` | When the current or last run was submitted. | | [`copyWith`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationState/copyWith.html) | method | — | A copy with fields replaced; `clearData`, `clearError` and `clearFailureReason` set fields back to nothing. No TanStack counterpart. | A restored `pending` state needs variables unless `TVariables` is nullable, and a `success` state needs `hasData` unless `TData` is nullable or `void`; anything else is rejected with an `ArgumentError`. `MutationState` compares by value; `errorStackTrace` takes no part. ## CombinedResult [`CombinedResult`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedResult-class.html) is what several `QueryResult`s amount to together, with every source's data run through a combiner you supply. It is sealed, with three cases, decided by these rules in order: 1. A source that is a `QueryError` **without stale data** makes the whole a [`CombinedError`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedError-class.html) — the first such source, in order. It wins over a source that is still loading. 2. Otherwise a `QueryPending` source makes the whole a [`CombinedPending`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedPending-class.html). 3. Otherwise every source has data — a success's, or the stale data of a failed refetch — and the whole is a [`CombinedData`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedData-class.html). A failed refetch shows up as `refetchError`, not as an error state. The combiner runs only in the third case. See [combining queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/combining-queries.md). TanStack Query offers the same idea as `useQueries({ combine })`, whose return value is whatever the combiner builds; none of the fields below has a TanStack name. | Name | Type | On which cases | Meaning | |---|---|---|---| | [`isFetching`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedResult/isFetching.html) | `bool` | all | Any source is fetching. | | [`isPaused`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedResult/isPaused.html) | `bool` | all | Any source is paused. | | [`isPending`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedResult/isPending.html) | `bool` | all | This is a `CombinedPending`. | | [`isError`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedResult/isError.html) | `bool` | all | This is a `CombinedError`. | | [`hasData`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedResult/hasData.html) | `bool` | all | This is a `CombinedData`. | | [`dataOrNull`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedResult/dataOrNull.html) | `T?` | all | The combiner's result on a `CombinedData`, `null` otherwise. | | [`refetch`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedResult/refetch.html) | `Future refetch({bool cancelRefetch = true})` | all | Refetches every source through its own `refetch`, passing `cancelRefetch` on. Two combinations sharing a source each refetch it; pass `false` to one of them to join instead. | | [`retry`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedResult/retry.html) | `Future retry({bool cancelRefetch = true})` | all | Refetches only the sources in error, an `optional()` source whose query failed included. | | [`error`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedError/error.html) | `Object` | `CombinedError` | What the first failed source threw. | | [`stackTrace`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedError/stackTrace.html) | `StackTrace` | `CombinedError` | The stack trace that came with `error`. | | [`data`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedData/data.html) | `T` | `CombinedData` | What the combiner returned. | | [`refetchError`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedData/refetchError.html) | `Object?` | `CombinedData` | What the first source whose background refetch failed threw; its stale data is part of `data`. `null` when none did. | | [`refetchErrorStackTrace`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedData/refetchErrorStackTrace.html) | `StackTrace?` | `CombinedData` | The stack trace that came with `refetchError`. | | [`isPlaceholderData`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedData/isPlaceholderData.html) | `bool` | `CombinedData` | Any source is showing placeholder data. | | [`isStale`](https://pub.dev/documentation/query_kit/latest/query_kit/CombinedData/isStale.html) | `bool` | `CombinedData` | Any source's data is stale by its own `staleTime`. | Two combined results are equal when they are the same case with equal `isFetching`, `isPaused` and payload (the error; or the data, `refetchError`, `isPlaceholderData` and `isStale`). The sources' `refetch` closures take no part. ### Building one A `CombinedResult` is never constructed directly. It comes from these extensions, which only read the results you already have — from observers, controllers or any read style. | Name | On | Signature | Meaning | |---|---|---|---| | [`combine`](https://pub.dev/documentation/query_kit/latest/query_kit/CombineQueryResults2.html) | a record of two to six `QueryResult`s (`CombineQueryResults2` … `CombineQueryResults6`) | `combine(R Function(A a, B b, …) combiner, {CombineMemo? memo, List? keys})` | Each source keeps its own data type; the combiner gets each source's data in record order. | | [`combine`](https://pub.dev/documentation/query_kit/latest/query_kit/CombineQueryResultList.html) | `List>` (`CombineQueryResultList`) | `combine(R Function(List values) combiner, {CombineMemo? memo, List? keys})` | The same rules over a list of one type, such as a `QueriesObserver`'s results. An empty list is data. | | [`combineWith`](https://pub.dev/documentation/query_kit/latest/query_kit/CombineQueryResultList/combineWith.html) | `List>` | `combineWith(QueryResult other, R Function(List values, A other) combiner, {CombineMemo? memo, List? keys})` | The list plus one source of another type — typically the query the list was derived from — as one combination, `other` first in order. | | [`optional`](https://pub.dev/documentation/query_kit/latest/query_kit/OptionalQueryResult/optional.html) | `QueryResult` (`OptionalQueryResult`) | `QueryResult optional()` | Marks a source the combination must neither wait for nor fail with. With data it is the result itself; without, a `QuerySuccess` holding `null` with the same `fetchStatus` and `refetch`, so `isFetching` and `retry()` still see it. | Past six sources, combine a list typed by what the sources have in common — `>[…]` at worst — and cast in the combiner. A `CombinedResult` is not itself a source, so combinations do not nest. ### CombineMemo and keys [`CombineMemo`](https://pub.dev/documentation/query_kit/latest/query_kit/CombineMemo-class.html) remembers the last combination. Keep one per call site (a `State` field, for example) and pass it as `memo:`. The combiner is then skipped while every source's data is the **identical** instance it was last time — the normal case for a refetch that changed nothing, thanks to [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md) — and when it does run, its output is structurally shared with the previous one. A memo cannot see what the combiner captures. With a memo, the combiner must be a function of the sources and of `keys` alone: name everything else it reads in `keys:` (compared with `==`), or do that work after `combine`. `keys` without a `memo` does nothing. --- # Widgets and controllers > Every public widget, controller, extension and mixin of query_kit_flutter, with its parameters, defaults and meaning; the TanStack Query counterpart is named where there is one. This page lists the Flutter binding's public surface: the provider, the four call styles for reading queries and mutations, the side-effect listeners, the collection helpers and the connectivity value. Everything here comes from one import, `package:query_kit_flutter/query_kit_flutter.dart`, which also re-exports the whole core. Where a member has a TanStack Query counterpart, its description names it ("TanStack: `useQuery`"). For the options these widgets take, see [query options](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-options.md); for what they hand back, see [results](https://dualmeta-gmbh.github.io/query_kit/docs/reference/results.md); for the client itself, see [QueryClient](https://dualmeta-gmbh.github.io/query_kit/docs/reference/query-client.md). The guides explain when to reach for what: [four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md), [what rebuilds](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md) and [mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md). ## The four call styles at a glance The binding reads queries, infinite queries and mutations in four equal styles. None is the default, the order of the rows means nothing, and the styles mix freely inside one screen. Each takes the same options objects and each takes a [`buildWhen`](#buildwhen-and-listenwhen) (a controller filters in its listener instead, because it is the notifier). | Style | Query | Infinite query | Mutation | |---|---|---|---| | **Builder widgets** | `QueryBuilder`, `QuerySelectBuilder` | `InfiniteQueryBuilder` | `MutationBuilder` | | **Controllers** | `QueryController`, `QueryController.create` | `InfiniteQueryController` | `MutationController` | | **`BuildContext` extension** | `context.query`, `context.selectQuery` | `context.infiniteQuery` | `context.mutation` | | **`State` mixin** | `watchQuery`, `watchSelectQuery` | `watchInfiniteQuery` | `watchMutation` | What they share: - **One observer per reader, one query per key.** Every builder, controller and keyless read owns its own observer. The query in the cache is shared, so two readers of one key cost one request. - **Options are re-applied whenever the reader is built with them:** on every build for `context.query` and the mixin, and whenever the parent rebuilds a builder widget. An `Enabled.when` over outside state is re-evaluated then; the observer compares the defaulted options by value, and only a real difference reaches the query. A changed key switches the observed query in place for a builder, a controller's `setOptions` and a read with an `id`; a read without an `id` is identified by its key, so a new key is a new observer and the old one is released (see [identity](#identity)). - **No notification for nothing.** A reader is never rebuilt for a notification carrying what it is already showing. Under a `QueryClientProvider`, a result that changes during a build is delivered once the build is over. - **Infinite queries hand out the controller.** Paging lives on `InfiniteQueryController`, so the infinite-query form of each style gives you the controller rather than a bare result. A change of the paging flags alone rebuilds even when the result is equal. - **Mutations hand out the controller.** `mutate` lives on `MutationController`, so the mutation form of each style gives you the controller; its `value` is the `MutationResult`. ## `QueryClientProvider` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider-class.html) · TanStack Query: `QueryClientProvider` A `StatefulWidget` that provides a `QueryClient` to the widgets below it and wires the client to Flutter while it is mounted. Put one above everything that reads a query. ### Constructors | Constructor | Meaning | |---|---| | [`QueryClientProvider.create({key, required create, required child, onlineStatus, observeAppLifecycle, isAppShown})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/create.html) | A static method returning a `Widget`. Calls `create` once and owns the client: it lives as long as the provider and is cleared (`client.clear()`) after the provider unmounts. Rebuilding with a different `create` callback keeps the client; change `key` to replace it. | | [`QueryClientProvider({key, required client, required child, onlineStatus, observeAppLifecycle, isAppShown})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/QueryClientProvider.html) | `const`. Takes a client you made yourself — one configured with `defaultOptions`, shared with code outside the tree, or created in a test — and never clears it. A different `client` on a later build unmounts the old one and mounts the new one. | ### Parameters | Parameter | Type | Default | Meaning | |---|---|---|---| | `create` (`.create` only) | `QueryClient Function()` | required | Makes the owned client, once, in `initState`. `QueryClient.new` is the shortest form. TanStack: `new QueryClient()`. | | [`client`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/client.html) (unnamed only) | `QueryClient` | required | The client every builder, controller and keyless read below runs on unless it names its own. Swapping it recreates every observer below, because each belonged to the old client. TanStack: the provider's `client` prop. | | [`child`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/child.html) | `Widget` | required | The subtree that can reach the client. TanStack: `children`. | | [`onlineStatus`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/onlineStatus.html) | `OnlineStatus?` | `null` | Connectivity, if you bring it: [`OnlineStatus.fixed`](#onlinestatus) or `OnlineStatus.stream`. `null` installs nothing and the client assumes it is online. TanStack: `onlineManager.setEventListener`. | | [`observeAppLifecycle`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/observeAppLifecycle.html) | `bool` | `true` | Whether to map the app's lifecycle onto the client's focus state. Turn it off when you install your own focus source with `client.focusManager.setEventListener(…)`: both write through `setFocused`, so with both the last writer wins. TanStack: `focusManager`, whose default listens to the browser's `visibilitychange`. | | [`isAppShown`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/isAppShown.html) | `bool Function(AppLifecycleState state)?` | `null` (built-in mapping) | Which lifecycle states count as focused. The mapping given on the latest build is in force: it is applied to the current state when it changes, and decides every transition after. | | `key` | `Key?` | `null` | For `.create`, a new key is the way to get a new client. | The built-in focus mapping: `resumed` is focused; `hidden`, `paused` and `detached` are not. `inactive` counts as focused on iOS, Android and Fuchsia, where it is a transient interruption (the notification shade, an incoming call), and as unfocused on macOS, Windows and Linux, where it means the window lost focus. The state the app is already in at mount is mapped too, not only later transitions. See [app focus refetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md). At mount the provider also installs a notify scheduler on the client: a notification arriving during a frame's build, layout or paint is deferred to a post-frame callback, and — in debug builds — one arriving during a build outside a frame (the root's first build in `runApp`) to a microtask, so a query resolving mid-build cannot call `setState` during that build. (The assertion that guards against that is debug-only; in release such a notification runs at once.) Several providers may share one client; the scheduler stays until the last of them goes. ### Static members | Member | Type | Meaning | |---|---|---| | [`of(context)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/of.html) | `QueryClient` | The nearest client above `context`, subscribing the caller to a change of client. For `build`. Throws a `FlutterError` when there is no provider. TanStack: `useQueryClient()`. | | [`maybeOf(context)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/maybeOf.html) | `QueryClient?` | The same, `null` without a provider. Subscribes like `of`. | | [`read(context)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/read.html) | `QueryClient` | Like `of`, without subscribing. For callbacks, `initState` and anywhere outside `build`. Throws a `FlutterError` when there is no provider. | ## Builder widgets The `StreamBuilder` shape. Each widget creates its controller on its first build (nothing is fetched before that), owns it until it is disposed, and rebuilds its own subtree. Do not dispose a controller a builder hands you. All four take `client`: `null` uses the nearest `QueryClientProvider`'s. What counts is the client resolved, so naming the provider's own client changes nothing; a different resolved client on a later build — this field's or the provider's — recreates the controller. Changed options on the same client are applied in place. ### `QueryBuilder` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryBuilder-class.html) · TanStack Query: `useQuery` `QueryBuilder` — a query without `select`. The type argument comes from `queryFn`'s return type or is written out; an options literal with neither is refused by an assertion in debug builds. | Parameter | Type | Default | Meaning | |---|---|---|---| | [`options`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryBuilder/options.html) | [`QueryObserverOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverOptions-class.html) | required | The query. Re-applied whenever the parent rebuilds this widget. | | [`builder`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryBuilder/builder.html) | `Widget Function(BuildContext context, QueryResult result)` | required | Builds the subtree from the sealed [`QueryResult`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryResult-class.html). Called on the first build, then for each changed result `buildWhen` lets through. | | [`buildWhen`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryBuilder/buildWhen.html) | `BuildWhen>?` | `null` (every change) | Whether a change from the result last built to the current one rebuilds. TanStack: `notifyOnChangeProps`, a different mechanism. | | [`client`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryBuilder/client.html) | `QueryClient?` | `null` (provider's) | The client to observe on. TanStack: the hook's `queryClient` argument. | | `key` | `Key?` | `null` | The widget's key. | ### `QuerySelectBuilder` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QuerySelectBuilder-class.html) · TanStack Query: `useQuery` with `select` `QuerySelectBuilder` — the cache holds `TQueryData`, the builder sees `TData`. The parameters are those of `QueryBuilder`, with these types: | Parameter | Type | Default | Meaning | |---|---|---|---| | `options` | [`QuerySelectOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/QuerySelectOptions-class.html) | required | The query and its required `select`, which lets Dart infer `TData`. | | `builder` | `Widget Function(BuildContext context, QueryResult result)` | required | Built from the *selected* result. | | `buildWhen` | `BuildWhen>?` | `null` | Compares selected results. | | `client` | `QueryClient?` | `null` | As for `QueryBuilder`. | | `key` | `Key?` | `null` | The widget's key. | A `select` that returns an equal value keeps the previous instance, but the rest of the result (`fetchStatus`, `dataUpdatedAt`) still changes; that is what `buildWhen` is for. ### `InfiniteQueryBuilder` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryBuilder-class.html) · TanStack Query: `useInfiniteQuery` `InfiniteQueryBuilder` — no type arguments at the call site; inference reads all three off the options. | Parameter | Type | Default | Meaning | |---|---|---|---| | `options` | [`InfiniteQueryObserverOptionsBase`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryObserverOptionsBase-class.html) (`InfiniteQueryObserverOptions` or `InfiniteQuerySelectOptions`) | required | Key, page function and paging functions. Re-applied whenever the parent rebuilds this widget. | | `builder` | `Widget Function(BuildContext context, InfiniteQueryController query)` | required | Given the controller, not a bare result: the pages are in `query.value`, paging is `query.fetchNextPage` and its siblings. | | `buildWhen` | `BuildWhen>?` | `null` | Compares the controller's results. A change of the paging flags alone rebuilds regardless. | | `client` | `QueryClient?` | `null` | As for `QueryBuilder`. TanStack: the hook's `queryClient` argument. | | `key` | `Key?` | `null` | The widget's key. | ### `MutationBuilder` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationBuilder-class.html) · TanStack Query: `useMutation` `MutationBuilder` — the type arguments come from the options; `MutationOptions.simple` infers them from `mutationFn`. | Parameter | Type | Default | Meaning | |---|---|---|---| | `options` | [`MutationOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationOptions-class.html) | required | The mutation function and callbacks. Re-applied whenever the parent rebuilds this widget, so the next run uses the latest ones; a run still in flight takes the new callbacks, `meta` and `gcTime`, and a retry calls the new mutation function, but it keeps the `retry`, `retryDelay`, `networkMode` and `scope` it started with. A different `mutationKey` (both set) resets the reader to idle. | | `builder` | `Widget Function(BuildContext context, MutationController mutation)` | required | Given the controller: the result is `mutation.value`, and `mutate` or `mutateAsync` starts a run. | | `buildWhen` | `BuildWhen>?` | `null` | The only filter a mutation reader has; it has no `select`. | | `client` | `QueryClient?` | `null` | The client to run on. TanStack: the hook's `queryClient` argument. | | `key` | `Key?` | `null` | The widget's key. | Disposing the widget does not cancel a run in flight: the mutation finishes and its options' callbacks run. Call `MutationController.cancel` first when it should not. ## Controllers Plain `ValueListenable`s, usable without widgets: in view models, with `ValueListenableBuilder` or `ListenableBuilder`, or in any state-management package that reads a listenable. You create one, listen to it and dispose it. Do not create one in `build`: every rebuild would leak an observer and its timers. Every controller here is **subscribed only while listened to**: the first listener subscribes it to its observer (which is when a query fetches, if it needs to), the last one leaving unsubscribes it. It notifies only when something a reader can see changed, and under a `QueryClientProvider` never in the middle of a build. ### `QueryController` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController-class.html) · TanStack Query: `useQuery` (over a `QueryObserver`) `QueryController` extends `ChangeNotifier` and implements `ValueListenable>`. | Member | Type | Default | Meaning | |---|---|---|---| | [`QueryController(client, options)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/QueryController.html) | constructor; `options` is `QueryObserverOptionsBase` | — | Creates the observer. Takes either options shape, a `QuerySelectOptions` included. Nothing is fetched until the first listener. Asserts in debug builds that `TData` is not a top type. TanStack: `new QueryObserver(client, options)`. | | [`QueryController.create(client, options)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/create.html) | static, returns `QueryController`; `options` is `QueryObserverOptions` | — | The form without `select`, with one type argument. | | [`QueryController.observing(client, observer)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/QueryController.observing.html) | constructor; `observer` is `QueryObserver` | — | Wraps an observer built elsewhere and owns it from then on. Holds no options of its own, so `value` before the first listener is the observer's current result. The same top-type assertion as the unnamed constructor. | | [`client`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/client.html) | `QueryClient` | — | The client the observer runs on. | | [`value`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/value.html) | `QueryResult` | — | The current result. While nobody listens it is the *optimistic* result — `fetching` for a query that will fetch on subscribe — which is what every widget style shows on its first build. | | [`setOptions(options)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/setOptions.html) | `void`; `QueryObserverOptionsBase` | — | Replaces the options in place; a new key switches the observed query. Does not notify by itself: a listener hears of the change only when the observer's result changes, as for any other notification. When the observer refuses the options, nothing is kept. TanStack: `observer.setOptions`. | | [`refetch({cancelRefetch})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/refetch.html) | `Future>` | `cancelRefetch: true` | Refetches. `true` cancels a fetch in flight and starts again; `false` joins it. TanStack: `refetch`. | | [`observer`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/observer.html) | `QueryObserver` | — | The observer underneath, for what the controller does not mirror, such as `currentQuery`. | | [`isDisposed`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/isDisposed.html) | `bool` | — | Whether `dispose` has run. | | [`observedState`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/observedState.html) | `Object?` | — | What a notification is compared on before a reader is told: `value` for a plain query; for an infinite one, `value` together with its six paging flags. The binding's readers use it; application code rarely needs it. | | [`optimisticValue`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryController/optimisticValue.html) | `QueryResult` (`@protected`) | — | The result the observer would report on subscribing now; what `value` reports while nobody listens. For subclasses. | | `addListener` / `removeListener` | `void` | — | The first listener subscribes, the last one leaving unsubscribes. | | `dispose()` | `void` | — | Destroys the observer. Runs once. | ### `InfiniteQueryController` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController-class.html) · TanStack Query: `useInfiniteQuery` (over an `InfiniteQueryObserver`) `InfiniteQueryController` extends `QueryController, TData>`, so `client`, `value`, `refetch`, `observer`, `isDisposed` and `dispose` are inherited. Its notifications also cover the paging flags: two fetches in opposite directions leave the result equal and still notify. | Member | Type | Default | Meaning | |---|---|---|---| | [`InfiniteQueryController(client, options)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/InfiniteQueryController.html) | constructor; `options` is `InfiniteQueryObserverOptionsBase` | — | Creates an `InfiniteQueryObserver`. Same contract as `QueryController`. TanStack: `new InfiniteQueryObserver(client, options)`. | | [`setInfiniteOptions(options)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/setInfiniteOptions.html) | `void`; `InfiniteQueryObserverOptionsBase` | — | Replaces the options, paging half included. Does not notify by itself, as for `QueryController.setOptions`. TanStack: `observer.setOptions`. | | [`setOptions(options)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/setOptions.html) | `void`; `QueryObserverOptionsBase, TData>` | — | Accepts only options that carry the paging behaviour; plain observer options throw an `UnsupportedError` in every build mode. TanStack: `observer.setOptions`. | | [`hasNextPage`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/hasNextPage.html) | `bool` | — | `getNextPageParam` returns a param for the pages held. False before the first page. TanStack: `hasNextPage`. | | [`hasPreviousPage`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/hasPreviousPage.html) | `bool` | — | `getPreviousPageParam` returns a param. Always false without one. TanStack: `hasPreviousPage`. | | [`isFetchingNextPage`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/isFetchingNextPage.html) | `bool` | — | The fetch in flight is a `fetchNextPage`. TanStack: `isFetchingNextPage`. | | [`isFetchingPreviousPage`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/isFetchingPreviousPage.html) | `bool` | — | The fetch in flight is a `fetchPreviousPage`. TanStack: `isFetchingPreviousPage`. | | [`isFetchNextPageError`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/isFetchNextPageError.html) | `bool` | — | The result's error came from a `fetchNextPage`; the pages held are still there. TanStack: `isFetchNextPageError`. | | [`isFetchPreviousPageError`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/isFetchPreviousPageError.html) | `bool` | — | The error came from a `fetchPreviousPage`. TanStack: `isFetchPreviousPageError`. | | [`isRefetching`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/isRefetching.html) | `bool` | — | The pages held are being refetched, and no `fetchNextPage` or `fetchPreviousPage` is in flight. TanStack: `isRefetching`. | | [`isRefetchError`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/isRefetchError.html) | `bool` | — | A refetch of the held pages failed, as opposed to a page fetch. TanStack: `isRefetchError`. | | [`fetchNextPage({cancelRefetch})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/fetchNextPage.html) | `Future>` | `cancelRefetch: true` | Fetches the page after the ones held. TanStack: `fetchNextPage`. | | [`fetchPreviousPage({cancelRefetch})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/fetchPreviousPage.html) | `Future>` | `cancelRefetch: true` | Fetches the page before the ones held. TanStack: `fetchPreviousPage`. | | [`infiniteObserver`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryController/infiniteObserver.html) | `InfiniteQueryObserver` | — | `observer`, typed with the paging half visible. | ### `MutationController` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController-class.html) · TanStack Query: `useMutation` (over a `MutationObserver`) `MutationController` extends `ChangeNotifier` and implements `ValueListenable>`. | Member | Type | Default | Meaning | |---|---|---|---| | [`MutationController(client, options)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/MutationController.html) | constructor; `options` is `MutationOptions` | — | Creates a `MutationObserver`. Idle until a run starts. TanStack: `new MutationObserver(client, options)`. | | [`client`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/client.html) | `QueryClient` | — | The client the observer runs on. | | [`value`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/value.html) | `MutationResult` | — | The current result. | | [`mutate(variables, {callbacks})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/mutate.html) | `void`; `callbacks` is `MutateCallbacks?` | `callbacks: null` | Fire and forget: the result lands in `value`, errors never reach the caller. TanStack: `mutate`. | | [`mutateAsync(variables, {callbacks})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/mutateAsync.html) | `Future` | `callbacks: null` | Completes with the data or throws. The per-call callbacks run after the options' own for as long as the controller is not disposed, listened to or not. Called after `dispose`, the mutation still runs with its options' callbacks, nothing lands in `value`, and the per-call callbacks are dropped. TanStack: `mutateAsync`. | | [`setOptions(options)`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/setOptions.html) | `void`; `MutationOptions` | — | Replaces the options the next run uses; a run still in flight takes the new callbacks, `meta` and `gcTime`, and a retry calls the new mutation function, but it keeps the `retry`, `retryDelay`, `networkMode` and `scope` it started with. Does not notify by itself — except that a different `mutationKey` (both set) resets the controller to idle, which listeners hear like any other change. TanStack: `observer.setOptions`. | | [`reset()`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/reset.html) | `void` | — | Back to idle, detaching from the mutation being observed. TanStack: `reset`. | | [`cancel()`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/cancel.html) | `void` | — | Fails the run this controller shows with a `CancelledError`; its error callbacks run. With nothing running it does nothing, and earlier runs are not touched. See [cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md). | | [`observer`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/observer.html) | `MutationObserver` | — | The observer underneath, for its defaulted `options`, say. Run mutations through the controller: `observer.mutate` on a controller nobody listens to drops the per-call callbacks. | | [`isDisposed`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationController/isDisposed.html) | `bool` | — | Whether `dispose` has run. | | `addListener` / `removeListener` | `void` | — | The first listener subscribes the controller to its observer, the last one leaving unsubscribes it. A run started by `mutate` or `mutateAsync` holds its own subscription until it settles, so its per-call callbacks run on a controller nobody listens to. | | `dispose()` | `void` | — | Detaches the observer from whatever mutation it ran, so that mutation can be collected. Does **not** cancel a run in flight. | ## `context.query` and its siblings [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryContext.html) · TanStack Query: `useQuery`, `useInfiniteQuery`, `useMutation` `QueryContext` is an extension on `BuildContext`. It works in a `StatelessWidget`, needs no wrapper widget, and rebuilds per reader: only the widgets that read a query rebuild when it changes. There is no `client:` parameter: it always reads the nearest `QueryClientProvider`'s client, and without a provider every member throws a `FlutterError`. | Member | Returns | Parameters | Meaning | |---|---|---|---| | [`query(options, {id, buildWhen})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryContext/query.html) | `QueryResult` | `QueryObserverOptions options`, `Object? id`, `BuildWhen>? buildWhen` | The query's current result; this widget rebuilds when it changes. TanStack: `useQuery`. | | [`selectQuery(options, {id, buildWhen})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryContext/selectQuery.html) | `QueryResult` | `QuerySelectOptions options`, `Object? id`, `BuildWhen>? buildWhen` | `query` with a `select`: the cache holds `TQueryData`, this widget sees `TData`. TanStack: `useQuery` with `select`. | | [`infiniteQuery(options, {id, buildWhen})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryContext/infiniteQuery.html) | `InfiniteQueryController` | `InfiniteQueryObserverOptionsBase options`, `Object? id`, `BuildWhen>? buildWhen` | The infinite query's controller, owned by this widget. TanStack: `useInfiniteQuery`. | | [`mutation(options, {id, buildWhen})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryContext/mutation.html) | `MutationController` | `MutationOptions options`, `Object? id`, `BuildWhen>? buildWhen` | A mutation's controller, owned by this widget. TanStack: `useMutation`. | The controllers `infiniteQuery` and `mutation` return belong to the reading widget and are disposed for you; do not dispose them. `id` and `buildWhen` default to `null`. ### Identity - **A query read** is identified by its key and its types, not by call order, so a read inside an `if` is fine. Two readers of one key share the query in the cache but not an observer. - **`id`** takes the key's place in the read's identity (the types stay part of it). A read with an `id` keeps its observer when its key changes, which is what keeping the previous key's data on screen with a placeholder needs. `id` also tells apart two reads of one key with different selectors of the same output type; two such reads without one are caught in debug builds. - **A mutation read** is identified by `id`, else by its `mutationKey`, each with its three types, else by the types alone. Two reads of one identity in one build share one controller, so without an `id` a debug assertion fires when they differ in the mutation function (`mutationFn` or `mutationFnWithContext`), in `onMutate`, `onSuccess`, `onError` or `onSettled`, or in `scope`, `retry`, `retryDelay`, `networkMode` or `gcTime`. Those five compare by value, except `RetryPolicy.when` and `RetryDelay.dynamic`, which compare by variant only. `meta` is not compared. Only reads in a `StatelessWidget`'s or `State`'s own build are compared; a nested builder re-reading through the outer `context`, and a read through a `LayoutBuilder`'s context, are not. A function literal is a new function on every build, so keep it in a field or read the mutation once. ### Which element a read belongs to, and when it is released A read belongs to the element whose `context` it went through, rebuilds that element, and lives as long as that element's own builds keep reading it. | Situation | What happens | |---|---| | A key read in the last build but not in this one | Released after the frame. A mutation too. | | The widget unmounts | Everything it read is released. Releasing a mutation's controller does not cancel a run in flight. | | A widget stops reading altogether | No signal Flutter can see: its last observers stay until it unmounts. Put a conditional read in a small widget of its own. | | A read outside `build` (a tap handler) | Creates an observer that is only matched up at the next build. Read in `build`, act in the handler. | | A read through the outer `context` inside a `ValueListenableBuilder`, `AnimatedBuilder` or `LayoutBuilder` callback | Additive: it releases nothing the widget's own build read. A key the callback stops reading stays until the widget's next own build that reads, or its unmount. A `build` that reads nothing itself never starts over; those keys stay until the parent rebuilds the widget or it unmounts. | | A read through the `context` a `LayoutBuilder`, `SliverLayoutBuilder` or `OrientationBuilder` hands its builder | Starts over whenever that builder provably runs: its constraints changed, its parent rebuilt it, or one of its own reads notified. A rebuild caused only by an `InheritedWidget` it depends on carries no signal, so a key picked from an inherited value stays until the next of those. | | A read from a `showDialog` or bottom-sheet builder through the page's `context` | Rebuilds the page, not the dialog, and the page's next build releases it while the dialog may still show it. Read through the builder's own `context`, or in a widget inside the dialog. | | A read through the `context` a `ListView.builder`, `GridView.builder`, `PageView.builder` or another lazily built list hands its item builder | **Debug builds throw a `FlutterError`** naming the fix. That context belongs to the whole list, not the row. In release builds the reads are additive: no row on screen loses its subscription, and rows scrolled away stay subscribed until the list is rebuilt or unmounts. Give each row a widget of its own and read in its `build`. | | The provider's client changes | Every observer read through it is released, and the readers rebuild and recreate what they read on the new client. | ## `QueryMixin` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryMixin-mixin.html) · TanStack Query: `useQuery`, `useInfiniteQuery`, `useMutation` `mixin QueryMixin on State`. Reads like a hook, flat in a `State`'s `build`, and everything it creates belongs to the `State`: disposed with it, and recreated when its client changes. | Member | Returns | Parameters | Meaning | |---|---|---|---| | [`queryClient`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryMixin/queryClient.html) | `QueryClient` (getter) | — | The client every read runs on; `QueryClientProvider.of(context)` by default. Override it to read from another client, `widget.client` say. Looked up again at every read: a different client releases everything held and recreates it on the new one. A `build` that reads nothing needs no provider. TanStack: `useQueryClient()`. | | [`watchQuery(options, {id, buildWhen})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryMixin/watchQuery.html) | `QueryResult` | `QueryObserverOptions options`, `Object? id`, `BuildWhen>? buildWhen` | Subscribes this `State` to the query and returns its current result. TanStack: `useQuery`. | | [`watchSelectQuery(options, {id, buildWhen})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryMixin/watchSelectQuery.html) | `QueryResult` | `QuerySelectOptions options`, `Object? id`, `BuildWhen>? buildWhen` | `watchQuery` with a `select`. TanStack: `useQuery` with `select`. | | [`watchInfiniteQuery(options, {id, buildWhen})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryMixin/watchInfiniteQuery.html) | `InfiniteQueryController` | `InfiniteQueryObserverOptionsBase options`, `Object? id`, `BuildWhen>? buildWhen` | The controller belongs to the `State`; do not dispose it. TanStack: `useInfiniteQuery`. | | [`watchMutation(options, {id, buildWhen})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryMixin/watchMutation.html) | `MutationController` | `MutationOptions options`, `Object? id`, `BuildWhen>? buildWhen` | A mutation owned by this `State`, not shared. TanStack: `useMutation`. | The mixin also overrides `dispose`, releasing everything the `State` read. Identity is the same as for [`context.query`](#identity): key and types, or `id`; for a mutation `id`, else `mutationKey`, with the same debug assertion. Release differs in one way, because a `State` is one reader: - A key read in the previous build but not in this one is released after the frame; everything goes when the `State` is disposed. A `State` that stops reading altogether keeps its last observers until it is disposed. - A `watchQuery` inside a nested builder callback — a `ValueListenableBuilder`, `LayoutBuilder`, `AnimatedBuilder`, or a `ListView.builder`'s `itemBuilder` — reads for this `State` and is additive. It is **not** refused in debug builds. A key the callback stops reading stays until an own `build` of this `State` that reads, or its disposal. For a long list, give each row a widget of its own. - A read always rebuilds this `State`. A dialog builder calling `watchQuery` is not rebuilt by a change; give a dialog a reader of its own. ## `BuildWhen` and `ListenWhen` ### `BuildWhen` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/BuildWhen.html) · TanStack Query: `notifyOnChangeProps` (a different mechanism) `typedef BuildWhen = bool Function(T previous, T current)` Whether a reader rebuilds for a change from `previous` to `current`. Every builder widget, `context` read and mixin read takes one. - It is asked only when the value really changed. A notification carrying what the reader already shows never rebuilds, with or without a predicate. - `previous` is what is on screen: the result the reader last *built* from. When the predicate returns `false`, that stays `previous`, and the next change is compared against it. - `select` narrows the data a reader sees; `buildWhen` narrows when it rebuilds. It is the tool for a change `select` cannot see: a background refetch moves `fetchStatus` and `dataUpdatedAt`, both part of a result's `==`. - Each keyless read has its own predicate, and any one of them letting a change through rebuilds the whole widget or `State`. - For infinite queries it compares results; a change of the paging flags alone rebuilds regardless. - Controllers, `QueriesBuilder` and `QueriesController` take none. A controller is the notifier, and a predicate on it would impose one listener's filter on every listener. See [`buildWhen`](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md#buildwhen). ### `ListenWhen` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/ListenWhen.html) `typedef ListenWhen = bool Function(T previous, T next)` Whether a transition runs a listener widget's side effect. It sees every transition, and a rejected one still becomes the `previous` of the next. ## Listeners [`QueryListener`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryListener-class.html), [`InfiniteQueryListener`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/InfiniteQueryListener-class.html), [`MutationListener`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationListener-class.html) · TanStack Query: — (a `useEffect` over the result) Run a side effect — a snackbar, a navigation, a log line — when a controller's result changes, without rebuilding `child`. Each listens to a controller you own and never disposes it. Its listening counts like any other: a controller nobody listened to is subscribed when the listener mounts, and a query fetches then if it needs to. See [side effects](https://dualmeta-gmbh.github.io/query_kit/docs/guides/side-effects.md). | Parameter | Type | Default | Meaning | |---|---|---|---| | `controller` | `QueryController` / `InfiniteQueryController` / `MutationController` | required | The controller to listen to. Borrowed: whoever created it disposes it. A different controller on a later build is listened to from then on, and transitions of the old one still queued are dropped. | | `listener` | `void Function(BuildContext context, T result)` | required | The side effect. `T` is `QueryResult` for the two query listeners and `MutationResult` for `MutationListener`. | | `listenWhen` | `ListenWhen?` | `null` (every change) | Which transitions run `listener`. | | `child` | `Widget` | required | Returned unchanged; a transition never rebuilds it. | | `key` | `Key?` | `null` | The widget's key. | When `listener` runs: - **Not on mount.** Only a later change of the controller's value is a transition. - **Outside the build phase**, in a microtask after the notification, so it may show a dialog, navigate or call `setState`. Each transition is delivered with the value it carried, even if a later one has arrived by then. `listenWhen` is asked in the same microtask. - **Once per notification.** Two cache writes inside one `notifyManager.batch` are one transition, to the second value. - **For `InfiniteQueryListener`, only on a change of the result.** A paging flag changing on its own is not a transition. - An error thrown by `listener` is reported through `FlutterError`, not thrown into the tree. For a side effect of one particular call, the per-call `callbacks` of `MutationController.mutate` are the alternative; `MutationListener` hears every run of the controller. ## Collections ### `QueriesBuilder` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueriesBuilder-class.html) · TanStack Query: `useQueries` `QueriesBuilder` builds from a list of queries of one type that may change length or order. It owns a `QueriesController`. | Parameter | Type | Default | Meaning | |---|---|---|---| | `queries` | `List>` | required | The queries, in the order their results are handed to `builder`. Re-applied whenever the parent rebuilds this widget; an unchanged list moves nothing. TanStack: `queries`. | | `builder` | `Widget Function(BuildContext context, List> results)` | required | Built from one result per entry of `queries`, first and again whenever a result or the list changes. | | `client` | `QueryClient?` | `null` (provider's) | As for `QueryBuilder`. TanStack: the hook's `queryClient` argument. | | `key` | `Key?` | `null` | The widget's key. | - **Observers are reused by key and occurrence**, so reordering starts no requests. Duplicate keys share one cache entry and keep their own options. - **Each query fails and settles on its own.** - **No `buildWhen`.** A predicate over the whole list would say nothing about which query changed; read each query on its own to filter per query. The widget still never rebuilds for results it already shows, compared element by element. - For queries of different types, read each one and combine them; see [combining queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/combining-queries.md). ### `QueriesController` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueriesController-class.html) · TanStack Query: `useQueries` (over a `QueriesObserver`) `QueriesController` is a `ValueListenable>>`, the same collection outside a widget. | Member | Type | Default | Meaning | |---|---|---|---| | `QueriesController(client, queries)` | constructor; `List> queries` | — | Creates the collection. Nothing fetches until the first listener; then every enabled query that needs to does. TanStack: `new QueriesObserver(client, queries)`. | | `client` | `QueryClient` | — | The client every query in the collection uses. | | `value` | `List>` | — | The results, in order. Before the first listener, the optimistic list. | | `setQueries(queries)` | `void` | — | Replaces the list, reusing observers by key occurrence. A new key fetches like any new query. TanStack: `observer.setQueries`. | | `observer` | `QueriesObserver` | — | The core observer, including its underlying observers. | | `addListener` / `removeListener` | `void` | — | The first listener subscribes the collection, the last one leaving unsubscribes it. | | `dispose()` | `void` | — | Destroys every observer. | Listeners are told only when a result changed, compared element by element; each result's `refetch` target is compared too, so replacing or reordering equal results still notifies. ### `IsFetchingController` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/IsFetchingController-class.html) · TanStack Query: `useIsFetching` A `ValueListenable`: how many queries matching the filters are fetching right now. A background refetch counts. Subscribed to the query cache only while something listens, and notifies only when the count changes. | Member | Type | Default | Meaning | |---|---|---|---| | `IsFetchingController(client, {filters})` | constructor | `filters: const QueryFilters()` (all queries) | Creates the count over `client`'s query cache. TanStack: `useIsFetching(filters)`. | | `client` | `QueryClient` | — | The client whose queries are counted. | | `filters` | [`QueryFilters`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryFilters-class.html) | all queries | Which queries count. Fixed for the controller's life; other filters are another controller. TanStack: `filters`. | | `value` | `int` | — | `client.isFetching(filters: filters)`. | | `addListener` / `removeListener` | `void` | — | The first listener subscribes to the query cache, the last one leaving unsubscribes. | | `dispose()` | `void` | — | Unsubscribes. | `QueryClient.isFetching` is the same count as a one-off snapshot. For mutations in flight, use a `MutationStateController` filtered on `MutationStatus.pending`; its list length is the count. ### `MutationStateController` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationStateController-class.html) · TanStack Query: `useMutationState` `MutationStateController` is a `ValueListenable>`: a value selected from every mutation in the cache that matches the filters, one entry per mutation, in the order they were added. For showing mutations somewhere other than where they started — a "saving" badge, a pending row for a write started on another screen. See [mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md). | Member | Type | Default | Meaning | |---|---|---|---| | `MutationStateController(client, {filters, required select})` | constructor; `select` is `MutationStateSelect`, a `TSelected Function(Mutation mutation)` | `filters: const MutationFilters()` | A selection over the mutation cache. `select` runs over every matching mutation on every cache change, so keep it cheap. TanStack: `useMutationState({ filters, select })`. | | [`MutationStateController.typed(client, {filters, required select})`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/MutationStateController/typed.html) | static; `select` is `TypedMutationStateSelect`, a `TSelected Function(Mutation mutation)` | `filters: const MutationFilters()` | Selects from mutations of one type only, typed. The types usually come from `select`'s parameter; a type left as `Object?` matches anything. The type test stays through a later `setOptions`. | | `client` | `QueryClient` | — | The client whose mutations are selected. | | `value` | `List` | — | The current selection, as an unmodifiable list. Read while nobody listens, it is recomputed from the cache. | | `setOptions({filters, select})` | `void`; `MutationFilters?`, `MutationStateSelect?` | both `null` (unchanged) | Replaces the filters and/or selector. The selection is recomputed at once, and listeners are told when it changed. | | `addListener` / `removeListener` | `void` | — | The first listener subscribes to the mutation cache, the last one leaving unsubscribes. | | `dispose()` | `void` | — | Destroys the observer. | Subscribed to the mutation cache only while something listens; listeners are told only when the selection changed, element by element. ## `OnlineStatus` [Dartdoc](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/OnlineStatus-class.html) · TanStack Query: `onlineManager.setEventListener` A sealed value passed as `QueryClientProvider.onlineStatus`: what the client should believe about the network, and where later changes come from. Connectivity is opt-in — nothing is installed by default, no connectivity package is a dependency, and a client with no status assumes it is online. See [connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md) for a `connectivity_plus` example. | Variant | Class | Fields | Meaning | |---|---|---|---| | `OnlineStatus.fixed(bool online)` | [`OnlineStatusFixed`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/OnlineStatusFixed-class.html) | `online` | This is the state, with no source of changes. For a test, a desktop build or a developer's offline switch. | | `OnlineStatus.stream(Stream changes, {required bool initial})` | [`OnlineStatusStream`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/OnlineStatusStream-class.html) | `changes`, `initial` | Follow `changes`, assuming `initial` until the first event. `initial` is required because a `Stream` has no current value. | Both are `const` and compare by value. The base class exposes [`initial`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/OnlineStatus/initial.html) (`bool`: the whole story for `fixed`, the starting assumption for `stream`) and [`changes`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/OnlineStatus/changes.html) (`Stream?`, `null` for `fixed`). The classes are public so a `switch` can name them. How the provider feeds it to `client.onlineManager` (through `setOnline`): - **`initial` is applied** whenever a client is given this status: at mount, to a client that arrives on a later build, and on any later build that changes the status — unless the old and the new status are both `OnlineStatus.stream`: a changed `initial` on the same stream, like a different stream, keeps the client's current verdict. Not when a stream delivers while it is being listened to (a synchronous controller's `onListen`): that event is believed over `initial`. - **Every stream event** is passed to the provider's current client. - **A new client under the same stream** starts from that stream's last event, or from `initial` if it has said nothing yet. - **One stream swapped for another** keeps the client's current verdict rather than rewinding it to `initial`, so a stream rebuilt in `build` does not flicker. A `fixed` status has no stream, so a changed one reaches the client on the rebuild that changes it and works as a live switch. - **Taking the status away** (`null` on a later build) **or disposing the provider** puts the client back online, but only once no other provider has a status for that client; a replacement provider on the same client keeps its own verdict. A client the provider leaves for another client is left as it was. - **A stream** is listened to once per stream object. A broadcast stream always works. A single-subscription stream works only while exactly one provider listens to it once; a second listen throws a `FlutterError` pointing to `asBroadcastStream()`. A stream error is reported through `FlutterError.reportError`, not thrown. While the client believes it is offline, what a query or mutation does is its [network mode](https://dualmeta-gmbh.github.io/query_kit/docs/guides/network-mode.md). ## Testing Widget tests need a teardown step, because a `QueryClient` outlives the tree and owns `gcTime` timers. It is a documented snippet rather than an export; see [testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md). --- # Caches and observers > The query and mutation caches, their entries and events, the five observers, the filters that select entries, and the three managers — every public member a user touches. A `QueryClient` owns two caches — one `Query` per key, one `Mutation` per run — and three managers that tell it about focus, connectivity and when to deliver notifications. Observers are what follow an entry and report it as a result. Most code reaches all of this through the client and the widgets; this page is for when you go underneath them: global callbacks, logging, devtools of your own, a [pure-Dart](https://dualmeta-gmbh.github.io/query_kit/docs/guides/pure-dart.md) program. A TanStack Query name is given only where it differs from the Dart one, and a member with no counterpart there says so. Where a member behaves differently, the row says how; the full list is in [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). ## `QueryCache` [`QueryCache`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryCache-class.html) holds every query of one client under its `QueryKey`. `QueryClient()` makes an empty one; pass your own as `QueryClient(queryCache: …)` to install the cache-wide callbacks. It is reachable as `client.queryCache`. ### Constructor `QueryCache({onSuccess, onError, onSettled})`. All three are optional and final — there is no setter to swap one later. A handler that has to change while the app runs closes over something you own. See [global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md). | Callback | Signature | Default | Meaning | |---|---|---|---| | `onSuccess` | `void Function(Object? data, Query query)` | unset | Runs once per successful fetch, however many observers share it, after the data is in the cache. A manual write (`setQueryData`) does not run it. | | `onError` | `void Function(Object error, StackTrace stackTrace, Query query)` | unset | Runs once per fetch that fails for good (retries exhausted), after the error is in the query's state. Data from an earlier success is still in `query.state.data`, which tells a failed background refresh from a failed first load. A cancel that is neither silent nor reverting runs it with a `CancelledError`. | | `onSettled` | `void Function(Object? data, Object? error, StackTrace? stackTrace, Query query)` | unset | Runs right after `onSuccess` or `onError`, once per fetch. After a failure `data` is what the query still holds, or `null`. Skipped when the hook before it threw; a cancel that records no error runs neither. | A throw from any of the three is reported to the current zone and does not change the fetch's outcome. ### Members | Member | Signature | Meaning | |---|---|---| | [`subscribe`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryCache/subscribe.html) | `void Function() subscribe(void Function(QueryCacheEvent event) listener)` | Calls `listener` with every [event](#query-cache-events); returns the function that unsubscribes. Each listener is isolated: a throw is reported to the zone and the others still run. | | `hasListeners` | `bool` | Whether any listener is subscribed. | | `queries` | `List>` | Every query, as a copy — safe to iterate while removing. TanStack: `getAll()`. | | [`findAll`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryCache/findAll.html) | `List> findAll({QueryFilters filters = const QueryFilters()})` | Every query matching `filters`, in insertion order. The key matches as a **prefix** unless `exact: true`. | | [`find`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryCache/find.html) | `Query? find({required QueryFilters filters})` | The first match, or `null`. An unset `exact` means an **exact** key match here. | | `get` | `Query? get(QueryKey queryKey)` | The query stored under exactly this key, or `null`. Throws `QueryDataTypeError` when it holds a different type — a subtype or the nullable type included. TanStack: `get(queryHash)`, by hash rather than key. | | `build` | `Query build(QueryClient client, DefaultedQueryOptions options, {QueryState? state})` | The query for the key, created if missing. `state` restores a saved entry and is used only on creation; a restored `fetchStatus` is set back to `idle`, since no fetch survives the process it ran in. A `success` state without data throws `ArgumentError`. | | `add` | `void add(Query query)` | Puts a hand-built query in and emits `QueryAdded`; a key that already has a query keeps it. A query that was removed throws `StateError`. | | `remove` | `void remove(Query query)` | Takes the query out, cancels its fetch silently, stops its collection timer, emits `QueryRemoved`. | | `clear` | `void clear()` | Removes every query, one `QueryRemoved` each. Prefer `client.clear()`, which also clears the mutation cache inside one batch. | | `notify` | `void notify(QueryCacheEvent event)` | Delivers an event to every listener. The cache calls it; rarely useful from outside. | | `onFocus` | `void onFocus({bool refetchQueries = true})` | Every query reacts to the app returning to the foreground. A mounted client calls it. `refetchQueries: false` lets paused fetches continue without starting new ones. | | `onOnline` | `void onOnline()` | Every query reacts to the device coming back online. A mounted client calls it. | | `onSuccess`, `onError`, `onSettled` | see above | The constructor's hooks, readable. TanStack: read from `config`. | ### Query cache events [`QueryCacheEvent`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryCacheEvent-class.html) is sealed, so a listener can `switch` over it exhaustively. Every event carries `query`, typed `Query` because a cache listener sees every key. The observer an event names is a [`QueryObserverRef`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserverRef-class.html): an identity to hold and compare, not something to drive. | Event | Carries | Emitted when | |---|---|---| | `QueryAdded` | `query` | A query was created — `build` or `add`, the first time anything used its key. TanStack: `type: 'added'`. | | `QueryRemoved` | `query` | A query left the cache: collected after `gcTime` without observers, or removed by `removeQueries`, `remove` or `clear`. TanStack: `type: 'removed'`. | | `QueryUpdated` | `query`, `action` | The query's state changed. Emitted for every [action](#query-actions). TanStack: `type: 'updated'`. | | `QueryObserverAdded` | `query`, `observer` | An observer attached. This is also what cancels the query's pending garbage collection. TanStack: `type: 'observerAdded'`. | | `QueryObserverRemoved` | `query`, `observer` | An observer detached. When it was the last one, the fetch in flight has already been told to stop retrying and the collection timer is armed. TanStack: `type: 'observerRemoved'`. | | `QueryObserverOptionsUpdated` | `query`, `observer` | An observer's options changed. A key change emits `QueryObserverRemoved` on the old query, `QueryObserverAdded` on the new one, then this on the new one. TanStack: `type: 'observerOptionsUpdated'`. | | `QueryObserverResultsUpdated` | `query` | An observer delivered a new result, after its listeners ran. TanStack: `type: 'observerResultsUpdated'`. | The [debugging guide](https://dualmeta-gmbh.github.io/query_kit/docs/guides/debugging.md) shows a listener that logs them. ### Query actions `QueryUpdated.action` is a [`QueryAction`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryAction-class.html), also sealed. Actions are read-only from outside: only a `Query` dispatches one. | Action | Fields | Meaning | |---|---|---| | `QueryFetchAction` | `meta` (`Object?`) | A fetch started. Resets the failure count, records `meta` as `state.fetchMeta`, and moves `fetchStatus` to `fetching` — or `paused` when the network mode forbids starting. TanStack: `type: 'fetch'`. | | `QueryFailedAction` | `failureCount`, `error`, `stackTrace` | One attempt failed and will be retried. `status` and data are untouched. TanStack: `type: 'failed'`. | | `QuerySuccessAction` | `data`, `dataUpdatedAt` (`DateTime?`), `manual` (`bool`) | Data arrived, fetched or written with `setQueryData` (`manual: true`). A manual write leaves a fetch in flight alone. TanStack: `type: 'success'`. | | `QueryErrorAction` | `error`, `stackTrace` | A fetch failed for good, or was cancelled with neither `silent` nor `revert`. Existing data is flagged as invalidated. TanStack: `type: 'error'`. | | `QueryPauseAction` | — | The fetch was suspended: offline, or a retry waiting for the foreground. TanStack: `type: 'pause'`. | | `QueryContinueAction` | — | A paused fetch resumed. TanStack: `type: 'continue'`. | | `QueryInvalidateAction` | — | `invalidateQueries` marked the query stale. Only `isInvalidated` changes. TanStack: `type: 'invalidate'`. | | `QuerySetStateAction` | `state` | The whole state was replaced: a reset, a revert after a cancel, or `Query.setState`. TanStack: `type: 'setState'`. | ## `MutationCache` [`MutationCache`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationCache-class.html) holds every mutation the client has run, in submission order. Mutations are never shared by key: every `mutate` call adds a new `Mutation`, which stays while it runs and for its `gcTime` after the last observer leaves. It is reachable as `client.mutationCache`. ### Constructor `MutationCache({onMutate, onSuccess, onError, onSettled})`, all optional and final. Each hook runs **before** the mutation's own option callback of the same name, and a returned future is awaited before the next callback starts. The mutation stays `pending` until all of them are done; the per-call `MutateCallbacks` passed to `mutate` run after that. | Callback | Signature | Default | Meaning | |---|---|---|---| | `onMutate` | `FutureOr Function(Object? variables, Mutation mutation)` | unset | Runs when any mutation is submitted. What it returns is ignored. A throw fails the mutation without running its function. | | `onSuccess` | `FutureOr Function(Object? data, Object? variables, Object? onMutateResult, Mutation mutation)` | unset | Runs after any mutation succeeds. A throw turns the success into an error. | | `onError` | `FutureOr Function(Object error, StackTrace stackTrace, Object? variables, Object? onMutateResult, Mutation mutation)` | unset | Runs after any mutation fails for good. A throw is reported to the zone and the remaining callbacks still run. | | `onSettled` | `FutureOr Function(Object? data, Object? error, StackTrace? stackTrace, Object? variables, Object? onMutateResult, Mutation mutation)` | unset | Runs after any mutation settles, success or failure. | TanStack Query passes a trailing function context to each hook; here the mutation is the last argument, and the error comes with its stack trace. ### Members | Member | Signature | Meaning | |---|---|---| | [`subscribe`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationCache/subscribe.html) | `void Function() subscribe(void Function(MutationCacheEvent event) listener)` | Calls `listener` with every [event](#mutation-cache-events); returns the unsubscribe function. Listeners are isolated, as on the query cache. | | `hasListeners` | `bool` | Whether any listener is subscribed. | | `mutations` | `List>` | Every mutation in submission order, as a copy. TanStack: `getAll()`. | | `findAll` | `List> findAll({MutationFilters filters = const MutationFilters()})` | Every match, in submission order. The key matches as a prefix unless `exact: true`. | | `find` | `Mutation<…>? find({required MutationFilters filters})` | The first match, or `null`. An unset `exact` means an exact match here. | | `build` | `Mutation build(QueryClient client, DefaultedMutationOptions options, {MutationState? state})` | Creates a mutation with the next id and adds it. `state` restores an offline mutation; a restored `pending` state is set to paused, and one without variables (unless `null` is a valid variables value) throws `ArgumentError`. | | `add` | `void add(Mutation<…> mutation)` | Appends a hand-built mutation and emits `MutationAdded`. Adding the same instance twice does nothing; a removed one throws `StateError`. | | `remove` | `void remove(Mutation<…> mutation)` | Takes the mutation out and emits `MutationRemoved`. A running one is told to stop retrying: the attempt in flight still settles, a backoff is cut short, and a paused one fails with `CancelledError`. In TanStack Query a removed mutation keeps retrying. | | `clear` | `void clear()` | Removes every mutation, stopping retries as `remove` does. Never starts a mutation queued in a scope. | | `resumePaused` | `Future resumePaused()` | Continues every paused mutation that can run now; completes when they have settled. Errors stay with each mutation. `QueryClient.resumePausedMutations` calls it. TanStack: `resumePausedMutations`, which resumes nothing while offline; here each mutation's network mode decides. | | `notify` | `void notify(MutationCacheEvent event)` | Delivers an event to every listener. | | `onMutate`, `onSuccess`, `onError`, `onSettled` | see above | The constructor's hooks, readable. TanStack: read from `config`. | ### Mutation cache events [`MutationCacheEvent`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationCacheEvent-class.html) is sealed. Every event carries `mutation`, typed `Mutation`. | Event | Carries | Emitted when | |---|---|---| | `MutationAdded` | `mutation` | A mutation was created and appended — the moment `mutate` is called, or a restored one is built. TanStack: `type: 'added'`. | | `MutationRemoved` | `mutation` | A mutation left the cache: collected, or removed by `remove` or `clear`. TanStack: `type: 'removed'`. | | `MutationUpdated` | `mutation`, `action` | The mutation's state changed. Emitted for every [action](#mutation-actions). TanStack: `type: 'updated'`. | | `MutationObserverAdded` | `mutation`, `observer` | An observer attached; also cancels the pending collection. TanStack: `type: 'observerAdded'`. | | `MutationObserverRemoved` | `mutation`, `observer` | An observer detached; when it was the last one, the collection timer is armed. TanStack: `type: 'observerRemoved'`. | | `MutationObserverOptionsUpdated` | `mutation`, `observer` | An observer's options changed while it stayed on the same mutation. TanStack: `type: 'observerOptionsUpdated'`. | ### Mutation actions `MutationUpdated.action` is a sealed [`MutationAction`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationAction-class.html). | Action | Fields | Meaning | |---|---|---| | `MutationPendingAction` | `variables`, `onMutateResult`, `isPaused` | A run started: status `pending`, fresh `submittedAt`. Dispatched a second time once `onMutate` has run, when it produced a result or the pause changed. TanStack: `type: 'pending'`. | | `MutationFailedAction` | `failureCount`, `error`, `stackTrace` | One attempt failed and will be retried. TanStack: `type: 'failed'`. | | `MutationSuccessAction` | `data` | The function resolved and every success callback ran. TanStack: `type: 'success'`. | | `MutationErrorAction` | `error`, `stackTrace` | The run failed for good — the last attempt's error, a callback's, or a `CancelledError` after `cancel`. TanStack: `type: 'error'`. | | `MutationPauseAction` | — | The run was suspended: offline, in the background, or queued behind its scope. TanStack: `type: 'pause'`. | | `MutationContinueAction` | — | A paused run resumed, or a restored one was run. TanStack: `type: 'continue'`. | ## `Query` [`Query`](https://pub.dev/documentation/query_kit/latest/query_kit/Query-class.html) is one cache entry. You never construct one; you meet it in `find`/`findAll`, in every cache event and cache callback, in a `QueryFilters.predicate`, and in callbacks such as `StaleTime.dynamic`. It has one type parameter, the data type the cache stores. | Member | Signature | Meaning | |---|---|---| | `queryKey` | `QueryKey` | The key it is stored under. | | `dataType` | `Type` | The exact data type it holds. A cache listener sees `Query`; this recovers the real type. No TanStack counterpart. | | `state` | `QueryState` | The current [state](#querystate). Replaced on every action. | | `options` | `DefaultedQueryOptions` | The options in force, fully resolved. | | `meta` | `Object?` | The options' `meta`, for cache callbacks and listeners. | | `client` | `QueryClient` | The client it belongs to. No TanStack counterpart. | | `observers` | `List` | The attached observers, read-only. | | `observersCount` | `int` | How many observers are attached. Zero makes it collectable after `gcTime`. TanStack: `getObserversCount()`. | | `future` | `Future?` | The fetch in flight, shared by every caller, or `null`. TanStack: `promise`. | | `resetState` | `QueryState` | The state `reset` returns to: pending, or the `initialData` seed. | | `gcTime` | `GcTime?` | How long the query may sit without observers before it is collected: the longest `gcTime` of any options applied to it, five minutes when none set one. Only applying options moves it. | | `isStale()` | `bool isStale()` | With observers: whether any observer's current result is stale. Without: no data, or invalidated. | | `isStaleByTime(staleTime)` | `bool isStaleByTime(StaleTime staleTime)` | Whether the data is older than `staleTime`. `StaleTime.static` outranks an invalidation; `StaleTime.infinite` does not. | | `isActive()` | `bool isActive()` | Whether any observer's `enabled` resolves to true. What `QueryTypeFilter.active` selects. | | `isDisabled()` | `bool isDisabled()` | With observers: none is enabled. Without: nothing has ever been fetched. | | `isStatic()` | `bool isStatic()` | Whether an attached observer uses `StaleTime.static`. | | `isFetched()` | `bool isFetched()` | Whether a fetch or a manual write has ever settled. | | `fetch` | `Future fetch({DefaultedQueryOptions? options, FetchOptions? fetchOptions})` | Fetches now, joining a fetch in flight unless `fetchOptions.cancelRefetch` is set on a query with data. Completes with the data, or throws the fetch's error. Usually reached through `client.query` or an observer; see [`FetchOptions`](#fetchoptions) below. | | `invalidate` | `void invalidate()` | Marks the data stale; refetches nothing. | | `cancel` | `Future cancel({bool revert = false, bool silent = false})` | Cancels the fetch in flight. Both `false`: the fetch fails with `CancelledError`, recorded and reported to `onError`. `revert`: back to the state before the fetch, no error. `silent`: no error, state left alone, and `fetchStatus` returns to `idle` when no new fetch follows. See [errors](https://dualmeta-gmbh.github.io/query_kit/docs/reference/errors.md#cancellederror). In TanStack Query a silent cancel with no successor stays `fetching`. | | `reset` | `void reset()` | Back to `resetState`, cancelling any fetch; refetches nothing. | | `setState` | `void setState(QueryState state)` | Replaces the **whole** state, for a persistence layer or a devtools panel. `fetchStatus` is installed as given. A `success` state without data throws `ArgumentError`. TanStack's `setState` merges a partial state instead. | ### `QueryState` [`QueryState`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryState-class.html) is what `query.state` and `client.getQueryState` return. Observers turn it into a `QueryResult`; see [results](https://dualmeta-gmbh.github.io/query_kit/docs/reference/results.md). | Field | Type | Default | Meaning | |---|---|---|---| | `status` | `QueryStatus` | `pending` | `pending`, `success` or `error`. | | `fetchStatus` | `FetchStatus` | `idle` | `fetching`, `paused` or `idle`. | | `hasData` | `bool` | `false` | Whether `data` is meaningful — true even when the data is `null`. No TanStack counterpart; there `data !== undefined` says the same. | | `data` | `TQueryData?` | `null` | The cached data. | | `dataUpdatedAt` | `DateTime?` | `null` | When data was last written; `staleTime` counts from here. | | `dataUpdateCount` | `int` | `0` | Writes so far, fetched and manual. | | `error` | `Object?` | `null` | Why the last fetch failed; cleared by the next success. | | `errorStackTrace` | `StackTrace?` | `null` | The stack trace of `error`. No TanStack counterpart. | | `errorUpdatedAt` | `DateTime?` | `null` | When the query last ended in an error. | | `errorUpdateCount` | `int` | `0` | Errors over the query's whole life; never goes down. | | `consecutiveErrorCount` | `int` | `0` | Fetches in a row that ended in an error. Reset only by fetched data; a manual write or a cancel leaves it. No TanStack counterpart. | | `fetchFailureCount` | `int` | `0` | Failed attempts inside the current fetch. | | `fetchFailureReason` | `Object?` | `null` | What the latest failed attempt threw. | | `fetchFailureStackTrace` | `StackTrace?` | `null` | Its stack trace. No TanStack counterpart. | | `fetchMeta` | `Object?` | `null` | What the fetch carried; an infinite query's page direction. | | `isInvalidated` | `bool` | `false` | Stale regardless of `staleTime`: set by an invalidation or a failed fetch. | | `isFetched` | `bool` (getter) | — | Whether anything has ever been fetched or written. No TanStack counterpart. | ### `FetchOptions` [`FetchOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/FetchOptions-class.html) are the per-call overrides `Query.fetch` takes. All three are unset by default, which leaves the fetch as the query's options describe it. | Field | Type | Default | Meaning | |---|---|---|---| | `cancelRefetch` | `bool?` | `null` — join | Cancel a running fetch and start over, on a query that holds data. Without data the call joins the running fetch, so a first load is never restarted. | | `meta` | `Object?` | `null` | Carried into `state.fetchMeta` for the length of the fetch; infinite queries put the page direction here. | | `retry` | `RetryPolicy?` | `null` — the options' `retry` | A retry policy for this fetch only; the query's options are not changed. `client.query` uses it for its no-retries rule. No TanStack counterpart. | ## `Mutation` [`Mutation`](https://pub.dev/documentation/query_kit/latest/query_kit/Mutation-class.html) is one run of a mutation function. `MutationObserver.mutate` builds it; you meet it in cache events, in `findAll`, in a `MutationFilters.predicate` and in a `MutationStateObserver`'s `select`. | Member | Signature | Meaning | |---|---|---| | `mutationId` | `int` | Unique within the cache, in submission order. | | `state` | `MutationState` | The current state. | | `options` | `DefaultedMutationOptions` | The options in force. Retry, delay, network mode and scope are fixed for each run. | | `meta` | `Object?` | The options' `meta`. | | `client` | `QueryClient` | The client it belongs to. No TanStack counterpart. | | `observers` | `List` | The attached observers, read-only. | | `gcTime` | `GcTime?` | How long the mutation stays after its last observer leaves and it has settled: the longest `gcTime` of any options applied to it, five minutes when none set one. | | [`cancel`](https://pub.dev/documentation/query_kit/latest/query_kit/Mutation/cancel.html) | `void cancel()` | Fails the run in flight with a `CancelledError`: the signal is cancelled, no further attempt is made, `onError` and `onSettled` run. A paused, queued or restored run fails without its function running. Once the function has returned, it does nothing. See [cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md). No TanStack counterpart. | | `continueMutation` | `Future continueMutation()` | Releases a paused mutation, or runs a restored one with its saved variables; completes when it settles, callbacks included, or throws the error it settled on. A settled mutation is not run again. `resumePaused` is the usual caller. TanStack: `continue()`. | | `execute` | `Future execute(TVariables variables)` | Runs the mutation once, callbacks included. The observer calls it; you call `mutate`. | `MutationStatus` is `idle`, `pending`, `success` or `error`. A mutation stays `pending` until its callbacks have run, `onSettled`'s future included. | `MutationState` field | Type | Default | Meaning | |---|---|---|---| | `status` | `MutationStatus` | `idle` | Where the mutation is in its life. | | `variables` | `TVariables?` | `null` | The variables of the run in flight or last finished. | | `hasVariables` | `bool` | `false` | Whether `variables` was set by a run — `null` is then a real value. No TanStack counterpart. | | `data` | `TData?` | `null` | What the last successful run returned. | | `hasData` | `bool` | `false` | Whether `data` is meaningful. No TanStack counterpart. | | `error` | `Object?` | `null` | Why the last run failed. | | `errorStackTrace` | `StackTrace?` | `null` | Its stack trace. No TanStack counterpart. | | `onMutateResult` | `TOnMutateResult?` | `null` | What `onMutate` returned. TanStack: `context`. | | `failureCount` | `int` | `0` | Failed attempts of the current run. | | `failureReason` | `Object?` | `null` | What the last failed attempt threw. | | `isPaused` | `bool` | `false` | Parked: offline, in the background, or queued behind its scope. | | `submittedAt` | `DateTime?` | `null` | When the run was submitted. | ## Observers An observer follows one entry — or a list of them, or a selection over the mutation cache — and reports it to listeners. Every observer has the same lifecycle: construct it (nothing is fetched), `subscribe` a listener (the first one attaches it and fetches what is due), read `currentResult` at any time, and `destroy` it when done. A listener is **not** called with the result that was current when it subscribed; read that from `currentResult`. Each `subscribe` returns its own unsubscribe function, which works once. The Flutter binding wraps each observer in a controller, so widget code rarely holds one; see [widgets and controllers](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md). In a [pure-Dart](https://dualmeta-gmbh.github.io/query_kit/docs/guides/pure-dart.md) program you own them directly. ### Creating one from the client | Member | Signature | Same as | |---|---|---| | [`observe`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/observe.html) | `QueryObserver observe(QueryObserverOptionsBase options)` | `QueryObserver(client, options)` TanStack: `new QueryObserver(client, options)`. | | [`observeInfinite`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryClient/observeInfinite.html) | `InfiniteQueryObserver observeInfinite<…>(InfiniteQueryObserverOptionsBase options)` | `InfiniteQueryObserver(client, options)` TanStack: `new InfiniteQueryObserver(client, options)`. | The caller owns the observer's lifetime: unsubscribe, or `destroy`, when done. ### `QueryObserver` [`QueryObserver`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserver-class.html) follows one query. `TQueryData` is what the cache holds, `TData` what the observer reports — the same type unless the options have a `select`. | Member | Signature | Meaning | |---|---|---| | constructor | `QueryObserver(QueryClient client, QueryObserverOptionsBase options)` | Resolves the options against the client's defaults and builds or joins the query. Fetches nothing. Throws `ArgumentError` when there is no `select` and a `TQueryData` is not a `TData`. | | [`subscribe`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserver/subscribe.html) | `void Function() subscribe(void Function(QueryResult result) listener)` | The first listener attaches the observer, fetches if due (no data, or stale data and `refetchOnMount`) and arms the stale and polling timers. The last one leaving detaches it. A throw from an option callback during the first subscribe propagates and leaves nothing registered. In TanStack Query the listener stays registered after such a throw. | | `hasListeners` | `bool` | Whether anyone is subscribed ("mounted"). | | `currentResult` | `QueryResult` | The latest result; readable right after construction. TanStack: `getCurrentResult()`. | | `currentQuery` | `Query` | The entry followed now. TanStack: `getCurrentQuery()`. | | `options` | `DefaultedQueryObserverOptions` | The options in force, resolved. | | [`setOptions`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserver/setOptions.html) | `void setOptions(QueryObserverOptionsBase options)` | Replaces the options, moving to another query if the key changed. Equal options are cheap and notify nobody. While subscribed, it fetches when it lands on stale or missing data, or `enabled` turned true over stale data. `enabled` is compared with what it resolved to the last time options were applied, so an `Enabled.when` over outside state takes effect on the next `setOptions`. In TanStack Query both sides are evaluated at the same instant. | | `getOptimisticResult` | `QueryResult getOptimisticResult(QueryObserverOptionsBase options)` | The result these options would give now, the fetch about to start included — the right read for a first build. Fetches nothing and does not apply the options, but the result becomes `currentResult` until the next update. Throws as the constructor does for mismatched types. | | [`refetch`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryObserver/refetch.html) | `Future> refetch({bool cancelRefetch = true})` | Fetches whether or not the data is stale, `enabled` ignored. Completes with the result; never with an error. TanStack's `refetch` also takes `throwOnError`; this one never throws. | | `updateResult` | `void updateResult()` | Recomputes the result and notifies if it changed. The observer calls it itself. | | `destroy` | `void destroy()` | Clears listeners and timers and leaves the query, which starts its `gcTime` clock. | ### `InfiniteQueryObserver` [`InfiniteQueryObserver`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryObserver-class.html) is a `QueryObserver` over `InfiniteData` with paging on top. Everything in the table above applies. The paging flags live on the observer, not on the result, so the sealed result keeps one shape; a change in any of them notifies listeners even when the result itself did not change. See [infinite queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md). | Member | Signature | Meaning | |---|---|---| | constructor | `InfiniteQueryObserver(QueryClient client, InfiniteQueryObserverOptionsBase options)` | The paging half of the options becomes the query's fetch behaviour. | | `infiniteOptions` | `InfiniteQueryOptions` | The paging functions in force. No TanStack counterpart. | | `setInfiniteOptions` | `void setInfiniteOptions(InfiniteQueryObserverOptionsBase options)` | The typed form of `setOptions`. TanStack: `setOptions`. | | `setOptions` | inherited | Takes only options that carry the paging behaviour (`client.infiniteObserverOptions(…)`); plain observer options throw `UnsupportedError`. | | `getOptimisticInfiniteResult` | `QueryResult getOptimisticInfiniteResult(InfiniteQueryObserverOptionsBase<…> options)` | The typed form of `getOptimisticResult`. TanStack: `getOptimisticResult`. | | [`fetchNextPage`](https://pub.dev/documentation/query_kit/latest/query_kit/InfiniteQueryObserver/fetchNextPage.html) | `Future> fetchNextPage({bool cancelRefetch = true})` | Fetches the page after the last one and appends it. Does nothing when `getNextPageParam` returns `null`; loads the first page when there is none. With `cancelRefetch`, a fetch already running on a query with pages is cancelled — check `isFetchingNextPage` first. Never completes with an error. | | `fetchPreviousPage` | `Future> fetchPreviousPage({bool cancelRefetch = true})` | The mirror: prepends the page before the first one. | | `hasNextPage` | `bool` | Whether `fetchNextPage` would fetch anything. False before the first page. TanStack: on the infinite result. | | `hasPreviousPage` | `bool` | Whether `fetchPreviousPage` would. TanStack: on the infinite result. | | `isFetchingNextPage` | `bool` | A `fetchNextPage` is in flight. TanStack: on the infinite result. | | `isFetchingPreviousPage` | `bool` | A `fetchPreviousPage` is in flight. TanStack: on the infinite result. | | `isFetchNextPageError` | `bool` | The query's error came from `fetchNextPage`; the held pages are still there. TanStack: on the infinite result. | | `isFetchPreviousPageError` | `bool` | The error came from `fetchPreviousPage`. TanStack: on the infinite result. | | `isRefetching` | `bool` | The held pages are being refetched — a page being added does not count. TanStack: on the infinite result. | | `isRefetchError` | `bool` | A refetch failed, not a page fetch. TanStack: on the infinite result. | ### `QueriesObserver` [`QueriesObserver`](https://pub.dev/documentation/query_kit/latest/query_kit/QueriesObserver-class.html) follows a list of queries of one type, one `QueryObserver` per entry, and reports the list of results in input order. For a fixed handful of different types, observe them separately and combine the results; see [combining queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/combining-queries.md). | Member | Signature | Meaning | |---|---|---| | constructor | `QueriesObserver(QueryClient client, List> queries)` | Builds or joins each query; fetches nothing. Throws `ArgumentError` when an entry has no `select` and the two types differ. TanStack's constructor also takes a `combine` option; here the results are combined afterwards. | | `subscribe` | `void Function() subscribe(void Function(List>) listener)` | The first listener subscribes every member, which fetches each one due; the last one leaving unsubscribes them. | | `hasListeners` | `bool` | Whether anyone is subscribed. | | `currentResult` | `List>` | The latest results, read-only, in input order. TanStack: `getCurrentResult()`. | | `observers` | `List>` | The members, read-only; their lifetime belongs to this observer. TanStack: `getObservers()`. | | `getOptimisticResult` | `List> getOptimisticResult()` | Each member's optimistic result for its current options. | | `setQueries` | `void setQueries(List> queries)` | Replaces the list. Members are matched by key and occurrence and handed their new options; new keys get new members; members left over are destroyed. If an entry throws, the list stays as it was — new members are destroyed, none is removed — though members before the failing entry keep the options they were just given. | | `destroy` | `void destroy()` | Removes every listener and destroys every member. | ### `MutationObserver` [`MutationObserver`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationObserver-class.html) runs mutations and reports the latest one's state as a `MutationResult`. Each `mutate` builds a new `Mutation`; the observer follows the newest. | Member | Signature | Meaning | |---|---|---| | constructor | `MutationObserver(QueryClient client, MutationOptions options)` | An idle observer. Both `mutationFn` and `mutationFnWithContext` set fails an assertion in the `MutationOptions` constructor in a debug build, and throws `ArgumentError` here when the options are resolved. | | `subscribe` | `void Function() subscribe(void Function(MutationResult result) listener)` | The first listener re-attaches to the mutation being watched; the last one leaving detaches, starting its `gcTime` clock. | | `hasListeners` | `bool` | Whether anyone is subscribed. Per-call callbacks only run while this is true. | | `currentResult` | `MutationResult` | Idle until the first `mutate`, then the observed mutation's state. TanStack: `getCurrentResult()`. | | `options` | `DefaultedMutationOptions` | The options in force, resolved. | | `setOptions` | `void setOptions(MutationOptions options)` | Replaces the options. A changed `mutationKey` resets the observer; otherwise a mutation still in flight takes the new callbacks, `meta` and `gcTime`, and a retry calls the new mutation function, but it keeps the `retry`, `retryDelay`, `networkMode` and `scope` it started with. | | [`mutate`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationObserver/mutate.html) | `void mutate(TVariables variables, {MutateCallbacks? callbacks})` | Starts a run and returns at once. Errors reach the callbacks and the result, never the zone. | | `mutateAsync` | `Future mutateAsync(TVariables variables, {MutateCallbacks<…>? callbacks})` | Starts a run; completes with the data or throws the error, after the callbacks ran. TanStack: `mutate`, which returns a promise. | | `cancel` | `void cancel()` | Cancels the run this observer shows — see `Mutation.cancel`. No TanStack counterpart. | | `reset` | `void reset()` | Back to idle. The mutation keeps running and firing its own callbacks. | | `destroy` | `void destroy()` | Drops every listener and leaves the mutation, so it can be collected. No TanStack counterpart. | ### `MutationStateObserver` [`MutationStateObserver`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationStateObserver-class.html) selects a value from every matching mutation in the cache — not only the ones one observer started. See [mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md). | Member | Signature | Meaning | |---|---|---| | constructor | `MutationStateObserver(QueryClient client, {MutationFilters filters = const MutationFilters(), required TSelected Function(Mutation mutation) select})` | A selection, readable at once. TanStack: `useMutationState({ filters, select })`. | | [`typed`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationStateObserver/typed.html) | `static MutationStateObserver typed(QueryClient client, {MutationFilters filters, required TSelected Function(Mutation) select})` | Only mutations **declared** with these types, handed to `select` typed. The type test runs before `filters.predicate`, which may therefore cast. A mutation whose types were never written or inferred is `Mutation` and drops out. No TanStack counterpart. | | `currentResult` | `List` | The selected values in submission order. Read without listeners, it refreshes from the cache. TanStack: the return value of `useMutationState`. | | `hasListeners` | `bool` | Whether it follows cache events. No TanStack counterpart. | | `subscribe` | `void Function() subscribe(void Function(List) listener)` | Follows the mutation cache while anyone listens. No initial snapshot. Equal selections (compared deeply) do not notify. No TanStack counterpart. | | `setOptions` | `void setOptions({MutationFilters? filters, MutationStateSelect? select})` | Replaces either and recomputes. A `typed` observer keeps its type test. No TanStack counterpart. | | `destroy` | `void destroy()` | Removes listeners and the cache subscription. No TanStack counterpart. | ## Filters Filters select entries for the client's bulk operations and the caches' `find`/`findAll`. Every field is optional, `null` means "do not filter on this", and an entry must match every field that is set — so empty filters match everything. See [filters](https://dualmeta-gmbh.github.io/query_kit/docs/guides/filters.md). ### `QueryFilters` [`QueryFilters`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryFilters-class.html) is taken as `filters:` by `invalidateQueries`, `refetchQueries`, `cancelQueries`, `resetQueries`, `removeQueries`, `isFetching`, `getQueriesData`, `updateQueriesData`, `QueryCache.find` and `findAll`, and the binding's `IsFetchingController`. | Field | Type | Default | Meaning | |---|---|---|---| | `queryKey` | `QueryKey?` | `null` — every key | The key to match, as a prefix: `QueryKey(['todos'])` matches `['todos']` and `['todos', 3]`. | | `exact` | `bool?` | `null` — prefix for `findAll` and the bulk operations, exact for `find` | Whether `queryKey` must be the whole key. | | `type` | `QueryTypeFilter?` | `null` — same as `all` | Observed queries, unobserved ones, or both. | | `stale` | `bool?` | `null` | `true` matches queries whose `isStale()` is true, `false` fresh ones. | | `fetchStatus` | `FetchStatus?` | `null` | `fetching`, `paused` or `idle`. `isFetching` ignores it and always counts `fetching`. | | `status` | `QueryStatus?` | `null` | `pending`, `success` or `error`. No TanStack counterpart. | | `predicate` | `bool Function(Query query)?` | `null` | Your own test, run last, on queries that passed the other fields. | | [`matches`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryFilters/matches.html) | `bool matches(Query query, {bool exactByDefault = false})` | — | Whether `query` passes every set field. `exactByDefault` is what an unset `exact` means: the caches pass `true` for `find`. For a filter of your own over `queries`. | [`QueryTypeFilter`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryTypeFilter.html) is `all` (every query), `active` (at least one enabled observer) or `inactive` (no enabled observer: none at all, or every one disabled). [`RefetchType`](https://pub.dev/documentation/query_kit/latest/query_kit/RefetchType.html) is what `invalidateQueries(refetchType: …)` refetches after marking: | Value | Refetches | |---|---| | `active` | invalidated queries with an enabled observer | | `inactive` | invalidated queries nobody observes | | `all` | every invalidated query | | `none` | nothing; observers pick it up on their next trigger | | unset | the filters' `type`, else `active` (TanStack: `refetchType` left unset) | `refetchType` is a parameter of `invalidateQueries`, not a field of the filters as in TanStack Query. The matched set is fixed before invalidating, so a filter over state (`stale: false`, say) still refetches what it marked. ### `MutationFilters` [`MutationFilters`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationFilters-class.html) is taken by `MutationCache.find` and `findAll`, `QueryClient.isMutating`, `MutationStateObserver` and the binding's `MutationStateController`. | Field | Type | Default | Meaning | |---|---|---|---| | `mutationKey` | `QueryKey?` | `null` — every mutation | The key to match, as a prefix. A mutation without a key never matches a key filter. | | `exact` | `bool?` | `null` — prefix for `findAll` and `isMutating`, exact for `find` | Whether `mutationKey` must be the whole key. | | `status` | `MutationStatus?` | `null` | `idle`, `pending`, `success` or `error`. `isMutating` ignores it and always counts `pending`. | | `predicate` | `bool Function(Mutation mutation)?` | `null` | Your own test, run last. | | [`matches`](https://pub.dev/documentation/query_kit/latest/query_kit/MutationFilters/matches.html) | `bool matches(Mutation mutation, {bool exactByDefault = false})` | — | Whether `mutation` passes every set field, as `QueryFilters.matches` does. | ## Managers Each client owns one of each manager, as `client.focusManager`, `client.onlineManager` and `client.notifyManager`; pass your own to the `QueryClient` constructor to share or configure one. In TanStack Query they are module-level singletons. A client reacts to the first two only while it is **mounted**: `client.mount()` subscribes to both, and on focus or reconnect it first resumes paused mutations, then lets the queries refetch. The Flutter binding's `QueryClientProvider` mounts its client and drives both managers; see [connectivity](https://dualmeta-gmbh.github.io/query_kit/docs/guides/connectivity.md) and [app focus refetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md). ### `AppFocusManager` [`AppFocusManager`](https://pub.dev/documentation/query_kit/latest/query_kit/AppFocusManager-class.html) tracks whether the app is in the foreground. Pure Dart has no notion of focus, so the default is "focused". | Member | Signature | Meaning | |---|---|---| | constructor | `AppFocusManager({Duration refetchMinBackgroundDuration = Duration.zero})` | Throws `ArgumentError` for a negative duration. No TanStack counterpart. | | `refetchMinBackgroundDuration` | `Duration` | An absence shorter than this does not start refetches on return; paused work still resumes. No TanStack counterpart. | | `isFocused()` | `bool isFocused()` | The value last set, or `true` when nothing is set. | | [`setFocused`](https://pub.dev/documentation/query_kit/latest/query_kit/AppFocusManager/setFocused.html) | `void setFocused(bool? focused)` | Sets the state by hand; a change notifies the listeners. `null` forgets the value, and `isFocused()` answers `true`. | | [`setEventListener`](https://pub.dev/documentation/query_kit/latest/query_kit/AppFocusManager/setEventListener.html) | `void setEventListener(FocusSetup setup)` | Replaces the source of focus events; see the contract below. | | `onFocus` | `void onFocus({bool refetchQueries = true})` | Notifies every listener with the current state. | | `shouldRefetchOnFocus` | `bool` | Whether the current notification allows new refetches. No TanStack counterpart. | | `subscribe` | `void Function() subscribe(void Function(bool focused) listener)` | Listens to changes; a mounted client is such a listener. | | `hasListeners` | `bool` | Whether anyone listens. | `FocusSetup` is `void Function() Function(void Function(bool? focused) setFocused)`. ### `OnlineManager` [`OnlineManager`](https://pub.dev/documentation/query_kit/latest/query_kit/OnlineManager-class.html) tracks whether the device believes it is online. The default is "online". Under `NetworkMode.online`, the default, fetches and mutations pause while offline. | Member | Signature | Meaning | |---|---|---| | constructor | `OnlineManager()` | Reports online until told otherwise. No TanStack counterpart. | | `isOnline()` | `bool isOnline()` | The value last set, or `true`. | | [`setOnline`](https://pub.dev/documentation/query_kit/latest/query_kit/OnlineManager/setOnline.html) | `void setOnline(bool online)` | Sets the state by hand. A change notifies; setting the same value does nothing. | | [`setEventListener`](https://pub.dev/documentation/query_kit/latest/query_kit/OnlineManager/setEventListener.html) | `void setEventListener(OnlineSetup setup)` | Replaces the source of connectivity events; see below. | | `subscribe` | `void Function() subscribe(void Function(bool online) listener)` | Listens to changes. | | `hasListeners` | `bool` | Whether anyone listens. | `OnlineSetup` is `void Function() Function(void Function(bool online) setOnline)`. ### The `setEventListener` contract Both managers follow the same rules: - `setup` is called **at once** with a callback, and returns a cleanup function. - The previous source's cleanup, if any, runs first. - When the manager's last listener unsubscribes — the client unmounts — the cleanup runs. When a listener subscribes again, `setup` is called again. - A `setup` that throws throws out of `setEventListener`. When it throws on a later re-subscribe, the throw is reported to the zone instead, and the next subscribe tries again. - The focus callback takes `bool?`: `true` or `false` sets the state through `setFocused`, `null` re-announces the current state without changing it. - An adapter writes through the same `setFocused`/`setOnline` as a manual call, so the last writer wins. In Flutter, installing your own focus adapter goes with `QueryClientProvider(observeAppLifecycle: false)`, and a connectivity source is better given as the provider's `onlineStatus`. ### `NotifyManager` [`NotifyManager`](https://pub.dev/documentation/query_kit/latest/query_kit/NotifyManager-class.html) batches notifications so a cascade of cache writes produces one round of listener calls. Most apps never touch it; the Flutter binding installs a build-phase-aware scheduler on the client's. | Member | Signature | Meaning | |---|---|---| | constructor | `NotifyManager()` | An independent queue with the microtask scheduler. No TanStack counterpart. | | `shared` | `static final NotifyManager shared` | One process-wide instance, for batching across clients. Not the default. TanStack: the module-level `notifyManager`. | | [`batch`](https://pub.dev/documentation/query_kit/latest/query_kit/NotifyManager/batch.html) | `T batch(T Function() callback)` | Runs `callback`, holding what is scheduled inside it until the outermost batch ends. | | `batchCalls` | `void Function(A) batchCalls(void Function(A) callback)` | Wraps `callback` so each call is scheduled instead of run — the way to defer and batch a cache subscription. | | `schedule` | `void schedule(void Function() callback)` | Queues `callback` for the next flush, or hands it to the scheduler at once when no batch is open. | | `flush` | `void flush()` | Delivers what is queued. `batch` calls it; rarely needed by hand. No TanStack counterpart. | | `setScheduler` | `void setScheduler(ScheduleFunction fn)` | Replaces when a batch runs. Default: `scheduleMicrotask`. TanStack's default is `setTimeout(0)`. | | `scheduler` | `ScheduleFunction` | The scheduler in force, to put back later. No TanStack counterpart. | | `setNotifyFunction` | `void setNotifyFunction(NotifyFunction fn)` | Wraps the delivery of each notification; must call its callback exactly once. | | `setBatchNotifyFunction` | `void setBatchNotifyFunction(BatchNotifyFunction fn)` | Wraps the delivery of a whole batch; must call its callback exactly once. | Only callbacks submitted through `schedule` or `batchCalls` are deferred. Direct observer subscriptions and cache listeners still run synchronously for each change. A callback that throws inside a flushed batch is reported to the zone and does not discard the rest of the batch. --- # Errors > Every error query_kit and query_kit_flutter throw or record, every debug-build check you can trip, and what to do about each. Errors reach you in three ways, and which one decides where you handle it: - **Thrown synchronously** from the call you made — a cache read with the wrong type, an option combination that cannot work, a widget without a provider. These are programming errors: fix the call. - **Recorded as a fetch or mutation error** — in the result's `error`, in the cache's `onError` hook, and thrown from `client.query` or `mutateAsync`. These go through the retry policy unless the table says otherwise. - **Reported to the zone** — a callback or listener that throws. Nothing around it is interrupted; the error goes to the zone's error handler, which in Flutter is `FlutterError.onError` or `PlatformDispatcher.onError`. Debug-build checks (asserts and debug-only `FlutterError`s) are listed at the end. They cost nothing in a release build — and do not protect you there either. A TanStack Query name is given only where it differs from the Dart one. For symptoms rather than error names, see [troubleshooting](https://dualmeta-gmbh.github.io/query_kit/docs/reference/troubleshooting.md). The [debugging guide](https://dualmeta-gmbh.github.io/query_kit/docs/guides/debugging.md) shows how to watch errors as they happen. ## Exported error types | Type | Package | Carries | |---|---|---| | [`QueryDataTypeError`](https://pub.dev/documentation/query_kit/latest/query_kit/QueryDataTypeError-class.html) | `query_kit` | `queryKey` (`QueryKey?`), `expected` (`Type`), `actual` (`Type`). No TanStack counterpart. | | [`MissingQueryFunctionError`](https://pub.dev/documentation/query_kit/latest/query_kit/MissingQueryFunctionError-class.html) | `query_kit` | `queryKey` (`QueryKey`). TanStack: a plain `Error` with a message. | | [`MissingMutationFunctionError`](https://pub.dev/documentation/query_kit/latest/query_kit/MissingMutationFunctionError-class.html) | `query_kit` | `mutationKey` (`QueryKey?`). TanStack: a plain `Error` with a message. | | [`CancelledError`](https://pub.dev/documentation/query_kit/latest/query_kit/CancelledError-class.html) | `query_kit` | `revert` (`bool`, default `false`), `silent` (`bool`, default `false`) | All four implement `Exception`, so `on Exception catch` sees them and an `Error`-only handler does not. The binding exports no error type of its own: it throws Flutter's `FlutterError` with a message that names the call and the fix. ## `QueryDataTypeError` One key holds one exact data type. A read or write that names another type — a subtype, a supertype, or the non-nullable form of a nullable one — throws `QueryDataTypeError` instead of casting. See [one key, one exact type](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md#one-key-one-exact-type). **Thrown synchronously from:** | Call | When | |---|---| | `QueryCache.get`, `client.getQueryData`, `getInfiniteQueryData`, `getQueryState` | The type argument differs from the type the entry holds. | | `client.getQueriesData` | Any matching entry holds another type. | | `client.setQueryData` | The value is not something the entry's own type can hold. An inferred type argument alone does not throw: an entry of `List?` takes a `List`. | | `client.updateQueryData` | The entry holds data that is not a `TQueryData`, so the updater cannot be handed it, or the updater returns a value the entry's own type cannot hold. | | `client.updateQueriesData` | The same, for any matching entry. Every updater runs before anything is written, so a throw writes nothing. | | `client.query`, `client.infiniteQuery` | The key's entry holds another type. Thrown before any future exists, so it is not a rejected future. | | `QueryObserver` constructor, `setOptions`, `getOptimisticResult` | The options' type does not match the entry. This is what surfaces from `context.query`, `QueryController` and the other binding reads. | **Recorded as a fetch or mutation error** when a default registered for many keys returns a value of the wrong type: | Source | `queryKey` | Retried | |---|---|---| | a `queryFn` from `setQueryDefaults` | the key | yes, per the query's `retry` | | a `structuralSharing` hook from the defaults | `null` | no — it fails after the data arrived | | a `mutationFn` from `setMutationDefaults` | `null` | per the mutation's `retry`, which defaults to never | **Fix.** Use one type per key, or name the type argument. When `actual` is the nullable form of `expected`, the message says so and names the type argument to write: an entry of `String?` read as `getQueryData(…)` wants `getQueryData(…)`. A default that serves many keys with different types should check the key, or be split into several defaults. ## `MissingQueryFunctionError` A fetch started for a query that has no `queryFn` in its options and none registered with `setQueryDefaults` for its key. Typical causes: a `client.query` with options that were only meant to read, or a refetch of a key whose data was only ever written with `setQueryData`. It is the fetch's error: the query goes to `error`, the cache's `onError` runs, and `client.query` rejects with it. It is **never retried** — no number of attempts will produce a function. TanStack Query retries it like any other failure. **Fix.** Give the options a `queryFn`, or register one for the key prefix with `client.setQueryDefaults`. See the [default query function](https://dualmeta-gmbh.github.io/query_kit/docs/guides/default-query-function.md) guide. ## `MissingMutationFunctionError` A mutation ran with neither `mutationFn` nor `mutationFnWithContext` set and no default registered for its key with `setMutationDefaults`. `mutationKey` is `null` for an unkeyed mutation. It is the mutation's error and is **never retried**. Nothing is sent. The error callbacks run — the cache's, the options', then the per-call ones — the result shows `status: error`, and `mutateAsync` throws it. **Fix.** Give the options a `mutationFn`, or register one with `client.setMutationDefaults` for the mutation's key. Both type errors and a missing mutation function can be tried in the diagnostics screen below. "Read as int" reads the counter with its own type; "Read as String" reads it as the wrong type and shows the `QueryDataTypeError`; "Write a String" tries to write the wrong type and leaves the entry unchanged; "Mutate without a function" fails with `MissingMutationFunctionError` and sends nothing; after "Register a default mutationFn", the same mutation succeeds. Live demo: [Diagnostics](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/diagnostics), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/diagnostics)). What the library throws, and when: the wrong type, the missing function. ## `CancelledError` A fetch or mutation that was stopped fails with `CancelledError`. Its two flags say what the cancel asked for, and whether you see it at all depends on them. | Where it comes from | `revert` / `silent` | What you see | Retried | |---|---|---|---| | `client.cancelQueries` (default `revert: true`) | `true` / `false` | The state goes back to what it was before the fetch; no error is recorded and `onError` does not run. A caller awaiting the fetch gets the data the query held, or the `CancelledError` when it held none. | no | | `Query.cancel()`, or `cancelQueries(revert: false)` | `false` / `false` | Recorded as the query's error; the cache's `onError` and `onSettled` run with it. | no | | a new fetch with `cancelRefetch` over a running one | `false` / `true` | Nothing: the callers of the cancelled fetch ride on the new one. | no | | the last observer leaving while the query function used its signal, or while a first fetch is paused | `true` / `false` | As with `cancelQueries`: the state is put back. | no | | `Query.fetch` on a query already removed from the cache | `false` / `true` | The returned future fails with it; the query's state is untouched. | no | | `Mutation.cancel`, `MutationObserver.cancel`, `MutationController.cancel` | `false` / `false` | The mutation fails with it: `status: error`, `onError` and `onSettled` run, `mutateAsync` throws it. Nothing is reverted — undo optimistic updates in `onError`. | no | | a paused mutation removed from the cache, or the cache cleared | `false` / `false` | The mutation fails with it. | no | A cancellation never counts toward `consecutiveErrorCount`. A `CancelledError` the query function throws **for its own reasons** — from some other token — is an ordinary failure and is retried like any other. One thrown by `QueryCancelToken.throwIfCancelled` after its own signal was cancelled changes nothing: the fetch had already ended when the signal fired. **Fix.** Usually none: handle it as "not an error" where you display errors, by testing `error is CancelledError`. See [query cancellation](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-cancellation.md) and [cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md). ## Other errors from `query_kit` These are Dart's own error types, thrown synchronously from a call whose arguments cannot work. None of them is retried; none reaches a result. | Error | Thrown by | When | Fix | |---|---|---|---| | `ArgumentError` | `QueryObserver` constructor, `setOptions`, `getOptimisticResult` | No `select`, and the cached type is not the reported type. | Add a `select`, or make the two types the same. | | `ArgumentError` | `QueriesObserver` constructor, `setQueries` | "QueriesObserver requires select when data types differ." A throwing `setQueries` leaves the list as it was, except that members before the failing entry keep their new options. | Give every entry whose types differ a `select`. | | `ArgumentError` | `client.defaultQueryOptions`, and so every query read | Both `initialDataUpdatedAt` and `initialDataUpdatedAtCompute` are set. | Set one. | | `ArgumentError` | `client.defaultMutationOptions`, and so `MutationObserver` and every mutation | Both `mutationFn` and `mutationFnWithContext` are set, counting defaults. The `MutationOptions` constructor also asserts it in debug builds. | Set one. | | `ArgumentError` | `QueryCache.build(state:)`, `Query.setState` | The state is inconsistent — `success` without data, say. | Build the state with the constructors `QueryState` offers, or restore what was saved unchanged. | | `ArgumentError` | `MutationCache.build(state:)` | A `pending` state without variables (where `null` is not a valid variables value), or a `success` state without data. | As above, for `MutationState`. | | `StateError` | `QueryCache.add`, `MutationCache.add` | The entry was removed from its cache before. | Build a new entry; a removed one cannot come back. | | `ArgumentError` | `InfiniteData` constructor | `pages` and `pageParams` differ in length. | Keep them paired. | | `ArgumentError` | `InfiniteData.flatten` | A page is not an `Iterable`. | Name the element type the pages really hold, or flatten with your own `expand`. | | `ArgumentError` | `copyWith` on `InfiniteQueryOptions`, `InfiniteQueryObserverOptions`, `InfiniteQuerySelectOptions` | `queryFn:` passed — an infinite query's function is `pageFn`. On the two observer option types, also `pages:` — an observer refetches as many pages as the query holds. | Change `pageFn`; pass a page count to `client.infiniteQuery` instead. | | `UnsupportedError` | `InfiniteQueryObserver.setOptions`, `getOptimisticResult` | Plain observer options were passed. | Use `setInfiniteOptions` with options from `client.infiniteObserverOptions`. | | `ArgumentError` | `AppFocusManager` constructor | `refetchMinBackgroundDuration` is negative. | Pass zero or more. | ## Errors from callbacks and listeners What happens when your own code throws depends on where it runs. **Reported to the zone, outcome unchanged:** - a `QueryCache` or `MutationCache` listener (`subscribe`); - an observer or controller listener; - a `QueryCache` hook — `onSuccess`, `onError`, `onSettled`; - a mutation's `onError` or `onSettled` after a failure, from the cache or the options; - a per-call `MutateCallbacks` callback; - a callback registered with a cancel token's `onCancel`; - a callback queued on the `NotifyManager`; - in the binding, the callbacks of `QueryListener`, `InfiniteQueryListener` and `MutationListener` — these go to `FlutterError.reportError`, which calls `FlutterError.onError`, rather than to the zone. **Change the outcome:** - a mutation's `onMutate` (cache or options) that throws fails the mutation with that error; the mutation function never runs; - a mutation's `onSuccess`, or `onSettled` after a success, that throws turns the run into an error with that error. See [global callbacks](https://dualmeta-gmbh.github.io/query_kit/docs/guides/global-callbacks.md) for where each hook runs. ## Errors from `query_kit_flutter` | Error | Build modes | When | Fix | |---|---|---|---| | [`QueryClientProvider.of`](https://pub.dev/documentation/query_kit_flutter/latest/query_kit_flutter/QueryClientProvider/of.html), `.read`: `FlutterError` "No QueryClientProvider found above this widget…" | all | No `QueryClientProvider` above the context. | Put one above the widget, or pass `client:` to the builder or controller. `QueryClientProvider.maybeOf` returns `null` instead of throwing. | | `context.query`, `selectQuery`, `infiniteQuery`, `mutation`: `FlutterError` | all | The same, with a message that names `context.query()` whichever of these was called. `QueryMixin` reads and builders without `client:` throw through `of`. | As above. | | `QueryClientProvider`: `FlutterError` "QueryClientProvider could not listen to its onlineStatus" | all | `onlineStatus` is a single-subscription stream and a second provider, or a replaced one, listened to it again. | Pass `stream.asBroadcastStream()`. | | `onlineStatus` stream errors | all | The stream emits an error, or cancelling it throws. Reported through `FlutterError.reportError`, not thrown; the online state is unchanged. | Handle errors in the stream. | ## Debug-build checks These run only in debug builds. In a release build the same code runs on without the check, with the behaviour described. | Check | Raised by | When | Release behaviour | Fix | |---|---|---|---|---| | `FlutterError` "… was called with the context an item builder was given." | `context.query` and the other context reads | The context is one a `ListView.builder`, `GridView.builder`, `PageView.builder`, `SliverList` builder, `ListWheelScrollView` or two-dimensional scroll view handed to its item builder. `selectQuery` reports as `context.query`. | The read is kept until the list's parent rebuilds or the list unmounts, so rows scrolled away keep their queries alive. | Make the row its own widget and read in its `build`. | | `FlutterError` "This widget read the query … twice in one build with options that produce different results" (or "This State …") | `context.query`, `selectQuery`, `infiniteQuery`; `watchQuery`, `watchSelectQuery`, `watchInfiniteQuery` | One build reads the same key twice with options that would give different results — two `select`s, say. | The two reads share one reader, and the later options win. | Pass a distinct `id:` to each read. | | `FlutterError` for two different mutations | `context.mutation`, `watchMutation` | Two reads without `id` in the reader's own build differ in the function, a callback, `scope`, `retry`, `retryDelay`, `networkMode` or `gcTime`. `meta` is not compared; `RetryPolicy.when` and `RetryDelay.dynamic` are compared by kind only. | Both reads share one mutation observer with the later options. | Pass a distinct `id:` to each read. | | `AssertionError` on a top type | `QueryController`, `InfiniteQueryController`, `QueryController.observing` | The data type is `dynamic` or `Object?` — usually an inferred type argument. | The controller works, untyped. | Name the data type. | | `AssertionError` on the mutation function | `MutationOptions` | Both `mutationFn` and `mutationFnWithContext` are set. Resolving the options throws `ArgumentError` in every mode. | `ArgumentError` when the options are resolved. | Set one. | | `AssertionError` on a key part | `QueryKey` | A part of the key has no value equality — a class without `==` and `hashCode`, a closure — or a map in it is keyed by a collection. | The key compares by identity and a new instance per build misses the cache. | Use strings, numbers, records, lists, maps, or classes with value equality. See [query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md). | ## How this differs from TanStack Query - A missing query function is never retried; TanStack Query retries it. - The cache's `onError` takes the stack trace as its second argument. - Reading or writing a key with the wrong type throws `QueryDataTypeError`; TypeScript's types vanish at runtime and nothing checks there. - A mutation can be cancelled, and fails with `CancelledError`; TanStack Query has no mutation cancel. The full list is in [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Feature matrix > What is here, and what is deliberately not in 1.0. Every feature of TanStack Query's core, and where to find it — or why it is not here. The names in the right-hand column are on the [API reference](https://dualmeta-gmbh.github.io/query_kit/docs/reference/api.md) pages, with their types and defaults. ## Here | | | |---|---| | Queries, staleness, background refetching | `StaleTime`, `RefetchOn`, `RefetchInterval` | | Request deduplication across observers | one fetch for N readers of one key | | Retries with backoff | `RetryPolicy`, `RetryDelay`, and `failureCount` / `failureReason` on the result | | Cancellation | `QueryCancelToken`, `client.cancelQueries` | | Garbage collection | `GcTime` | | Mutations, optimistic updates, rollback | `onMutate` → `onError`/`onSettled` | | Mutation scopes (serialised writes) | `MutationScope` | | Infinite queries, both directions, `maxPages` | `InfiniteQueryOptions` | | `initialData` and `placeholderData` | including `keepPrevious` | | `select` and structural sharing | `QuerySelectOptions`, a second options shape with `select` required; plus `buildWhen` on the builders and the keyless reads | | `select` from a shared options factory | `withSelect(select)` keeps every other field | | A list of queries | `QueriesObserver` / `QueriesBuilder` | | Combining results of different types | `(a, b).combine(…)` over a record, `combine` over a `List`, `combineWith`, `optional()`, `CombineMemo` | | How many queries are fetching, how many mutations are running (`useIsFetching`, `useIsMutating`) | `client.isFetching()` / `IsFetchingController`, `client.isMutating()` | | Cancelling a mutation, a context for its function | `Mutation.cancel()`, `MutationController.cancel()`, `mutationFnWithContext` | | Giving up after N failures | `consecutiveErrorCount` on `QueryState` and `QueryResult` | | Structural sharing into your own classes | `StructurallyShareable` | | Cache-wide mutation state | `MutationStateObserver` / `MutationStateController`, and `.typed` for one mutation type | | Cache events and global callbacks | `queryCache.subscribe`, `mutationCache.subscribe`, `meta` | | Offline behaviour | `NetworkMode`, paused mutations, `resumePausedMutations` | | Per-client focus / online / notify managers | not module-level singletons | | A Flutter binding with four equal call styles | and no dependency beyond Flutter | | Widget tests | the teardown is a documented snippet, not an export — see [Testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md) | ## Deliberately not in 1.0 | TanStack Query | Here | |---|---| | Persistence and hydration (`hydrate`, `dehydrate`, `persister`, `isRestoring`) | not in 1.0; `Query.setState` is the door a persister would use | | `notifyOnChangeProps`, `trackResult` | `select`, plus `buildWhen` on the builders and the keyless reads, mutations included | | `throwOnError` | errors live in the sealed result (`QueryError`) | | `queryKeyHashFn` | `QueryKey` is a value type | | `structuralSharing` via `replaceEqualDeep` | deep value equality for lists, maps and sets, `==` for everything else, plus an optional `structuralSharing` hook; a class that implements `StructurallyShareable` is walked into | | `useQueries`' heterogeneous tuple and its `combine` step | `QueriesObserver` is homogeneous. Different data types are combined with `combine` on a **record of results** — `(a, b).combine((a, b) => …)` gives a `CombinedResult` (pending / error / data with `refetchError`), with an optional `CombineMemo`; the same `combine` over a `List`, `combineWith` for a list plus a source of another type, `optional()` for a source the screen can do without | | `streamedQuery` | not ported | | `experimental_prefetchInRender`, Suspense, `fetchOptimistic` | React-only, not ported | | `select` on `fetchQuery` | map the future | | `initialDataUpdatedAt` as a function | `initialDataUpdatedAtCompute`, a `DateTime? Function()` evaluated only when the data is actually seeded | | SSR: `isServer`, `environmentManager`, `timeoutManager` | not ported | | `MutationFunctionContext` | `mutationFn` takes its variables only; `mutationFnWithContext: (variables, context)` is the two-argument form. Its context adds the typed `onMutateResult` and a `signal` to `client`, `meta` and `mutationKey` — and `cancel()` on a mutation, which TanStack Query does not have, cancels it | | Callbacks in `setMutationDefaults` | not ported | | Devtools | none — see [debugging](https://dualmeta-gmbh.github.io/query_kit/docs/guides/debugging.md) | ## The differences Where query_kit behaves differently rather than not at all — one key, one exact type; whole-result comparison with `buildWhen`; two options shapes; named `filters:` — see [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). --- # Troubleshooting > Symptom first — what you see, why it happens, and what to do instead. Symptom first. Most of these are TanStack Query behaviour that query_kit keeps on purpose, so the mechanism is worth knowing even once the fix is in. ## My polling never resumes after I paused it in a callback **Symptom.** A `RefetchInterval.dynamic` (or an `Enabled.when`) reads state from *outside the cache* — "is a write in flight", a connection flag, a notifier — and returns "off" while it is set. The flag clears; polling stays off. **Mechanism.** Those callbacks are evaluated when **the query** has an event — a fetch settling, data written — or when the observer is **handed its options**, which every rebuild of the reading widget does. Nothing tells the observer that your flag changed. If the widget does not rebuild when the flag flips, the last answer — off — stands, and with polling off no query event will come along to ask again. With a mutation it is sharper still: `onSettled` runs while the mutation still counts as pending, so a callback asked *then* still says "writing". **Fix.** Make the flag rebuild the widget that reads the query — a `ValueListenableBuilder`, a `ListenableBuilder`, a `MutationStateController` for "a write is in flight", `setState`. The rebuild hands the observer its options again and both callbacks are asked again. Outside widgets, `observer.setOptions(options)` with the very same options does the same. Better still, put outside state into the options as a **value**; then there is no callback to go stale: ```dart QueryObserverOptions> polledTasks({required bool writing}) => QueryObserverOptions( queryKey: tasksKey, queryFn: (context) => api.listTasks(signal: context.signal), // A value, not a callback: the widget rebuilds when `writing` flips, // hands over new options, and the observer sees that they changed. refetchInterval: writing ? RefetchInterval.off : const RefetchInterval.every(Duration(seconds: 1)), ); ``` Keep the callbacks for what they can see: the query's own state — "stop polling once the job reports `done`". The rebuild is enough in every call style: `context.query`, `watchQuery`, the builders and a controller's `setOptions` all hand the options over again, and the observer compares against what it last committed — so an invalidation or a write that lands between the flip and the rebuild does not hide it. A `StaleTime.dynamic` over outside state is re-evaluated the same way, and a shortened stale time takes effect on the next rebuild. What stays true: *something* has to rebuild when the outside state flips. TanStack Query differs here. It compares the old and the new `enabled` *at the same instant*, so there even a rebuild does not help an `enabled` callback over outside state: both sides see the same world. And one consequence runs the other way: a predicate over the query itself whose answer changes between two rebuilds refetches on the next rebuild if the data is stale, which TanStack Query does not. ## I removed a device's queries and it is being fetched again **Symptom.** `removeQueries` under a prefix, on disconnect. A moment later the entries are back and requests go out to a device that is gone. **Mechanism.** An observer owns a **key**, not an entry. While a widget that reads the key is still mounted, its next options update, poll tick or an optimistic rollback builds the entry again and fetches. TanStack Query does exactly this. **Fix.** Remove the readers before the entries: take the device's screens out of the tree (or give their queries `Enabled.no`) and *then* cancel and remove. ```dart void disconnect(QueryClient client, QueryKey deviceKey) { final filters = QueryFilters(queryKey: deviceKey); client.cancelQueries(filters: filters).ignore(); client.removeQueries(filters: filters); } ``` If the order cannot be guaranteed, make the transport refuse: an API object that is closed and throws an error your `retry` policy does not retry costs one failed fetch instead of a request on the wire. ## The first of two writes lost its state and its callbacks **Symptom.** Two `mutate` calls on one `MutationController`. The first one's `isPending`, its result, and the `onSuccess`/`onError` passed *to that call* never show up. **Mechanism.** A mutation controller observes its **latest** run. A second `mutate` moves it on, and per-call callbacks only fire for the run the controller is still watching — also when the controller was disposed in between. Callbacks on the **options** always run; they belong to the mutation, not to who watches it. `useMutation` is the same. **Fix.** Side effects that must happen go in the options' callbacks. Feedback for one particular write comes from awaiting `mutateAsync`. To show every write in flight, not just the last, read the cache with a `MutationStateController`. ## `QueryDataTypeError` for a list that "is" the right type **Symptom.** A key holds a `List`; something reads it as `List`, or as a subtype's list, and gets a `QueryDataTypeError` rather than data. **Mechanism.** One key, one exact data type. The cache checks the type it is asked for against what it holds instead of casting blindly, and a `List` is not accepted where the entry was created as `List` (or the reverse) — see [type safety in Dart](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md#one-key-one-exact-type). **Fix.** Give each type its own key, or store a wrapper type that says what the entry is. `updateQueriesData` over a prefix that spans entries of different types throws `QueryDataTypeError`, before writing anything, when a matched entry holds data that is not the updater's `T?`, or when the updater returns a value some matched entry cannot hold. Beyond that the write is lenient, as `setQueryData`'s is. Filter narrowly. ## A mutation that awaits another mutation never finishes **Symptom.** Inside a mutation with a `MutationScope`, `await other.mutateAsync(...)` — and `other` has the **same scope**. Both hang. **Mechanism.** A scope runs its mutations one at a time. The inner one queues behind the outer one, which is waiting for the inner one. By design, as in TanStack Query, and there is no warning. **Fix.** Give the inner mutation no scope (or a different one), or do the inner work as a plain call inside the outer mutation function. ## My offline-tolerant app still pauses its writes **Symptom.** `networkMode: NetworkMode.always` is set in the client's query defaults — the app talks to a device on the local network — but mutations still pause when the platform reports offline. **Mechanism.** Queries and mutations have **separate** client defaults, as in TanStack Query. Each resolves option → its own client default → `online`. **Fix.** Set `networkMode` in the mutation defaults as well. ## My "gave up" message disappears after an optimistic write **Symptom.** Polling stops after five failures and the screen says so. An optimistic write (`setQueryData`, a patch in `onMutate`) lands, the result turns into a success — and the "gave up" message is gone, while polling stays stopped. **Mechanism.** A manual write turns an error into a success but is not a fetch, so it does not reset `consecutiveErrorCount`, and a `RefetchInterval.dynamic` keyed on the count stays off. Only a *fetched* success resets it. **Fix.** Key the "gave up" UI on `result.consecutiveErrorCount` — every `QueryResult` carries it — not on the result being a `QueryError`. ## My optimistic rollback restored the wrong list **Symptom.** Two writes in one `MutationScope`. The second one fails, its `onError` restores the snapshot its `onMutate` took — and the first write's change vanishes from the screen, although it succeeded. **Mechanism.** A scope serialises the mutation **function** only. `onMutate` runs when the mutation is submitted, so the second snapshot was taken while the first write was still in flight. The scope is held until the running mutation's `onSettled` future completes — `client.isMutating()` still counts it inside its own `onSettled` — and a queued run reports `isPaused`. **Fix.** Roll back the row this mutation changed, not a whole-list snapshot. See [mutation scopes](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-scopes.md). ## `client.query` right after my write returned the old data **Symptom.** `await client.query(options)` straight after a write comes back with data from before it. **Mechanism.** `client.query` joins a fetch already in flight for the key — one that may have started before your write — rather than starting another, and a cancelled fetch that reverts resolves it with the reverted data. TanStack Query's `fetchQuery` does the same. **Fix.** `await client.refetchQueries(filters: QueryFilters(queryKey: key))`, whose `cancelRefetch` defaults to `true`, then read the cache. ## A `retry` I passed once is still in force **Symptom.** One `client.query` passed `retry: RetryPolicy.never`; later refetches of that query — an invalidation, a focus refetch — do not retry either. **Mechanism.** The options a call hands in become the query's options, as an observer's do: the cache entry is shared and refetches with what it was last given, as with TanStack Query's `fetchQuery`. Only the no-retry default of a call that configured nothing is limited to that one fetch. **Fix.** Leave `retry` out of the imperative call when it should not stick, or set the policy where the query is observed, which hands it in again. ## "context.query was called with the context an item builder was given" **Symptom.** A debug build throws this `FlutterError` from `context.query` (or `context.selectQuery`, `context.infiniteQuery`, `context.mutation`) inside a `ListView.builder`'s `itemBuilder` — or a `GridView.builder`'s, a `PageView.builder`'s, a `SliverList`'s, a `ListWheelScrollView.useDelegate`'s or a two-dimensional scroll view's. **Mechanism.** The `context` an item builder is given belongs to the list, not the row. The list builds its rows piecemeal, as they scroll in, and a read through its context cannot say which row it belongs to: released per frame, a row still on screen loses its subscription when others scroll in; kept, every row ever built stays subscribed until the list is rebuilt. Neither is right, so a debug build refuses the read. A release build keeps them: no row on screen loses its subscription, and the rows scrolled away stay subscribed until the list is rebuilt by its parent or unmounts. **Fix.** Give each row a widget of its own and read in its `build` — `itemBuilder: (_, i) => TaskTile(ids[i])` with the `context.query` inside `TaskTile.build` — so each row's reads come and go with it. ## A list item's query is never released **Symptom.** `watchQuery` in a `QueryMixin` `State`, or `context.query` through the *outer* `context` — or through an enclosing `LayoutBuilder`'s `context` — inside an `itemBuilder`. Items scroll away or the list shrinks, and their queries stay observed. **Mechanism.** A read made through the reader's context in a nested builder callback — an `itemBuilder`, a `ValueListenableBuilder`, a `LayoutBuilder` given the outer `context` — is **added** to the enclosing widget's reads; it does not release what that widget's own `build` read. A key such a callback stops reading is released only on that widget's next own build that reads, or when it goes. For an `itemBuilder` reading through a `LayoutBuilder`'s *own* `context`, a scroll is no builder run: what it stops reading goes at the `LayoutBuilder`'s next builder run — new constraints, a notification from one of its reads, its parent rebuilding it — or when it unmounts. The trade is deliberate: releasing per run would drop the subscriptions of data still on screen. **Fix.** The same: a row widget — a `TaskTile(id)` — that reads its own query, so its reads come and go with it. ## A `LayoutBuilder` keeps a query picked from an inherited value **Symptom.** A `LayoutBuilder` (or `OrientationBuilder`) picks its key from an `InheritedWidget` — a selected tab, a locale, a theme flag — and reads it through its own `context`. The selection moves, the new key is read, and the old one stays observed. **Mechanism.** A `LayoutBuilder`'s builder runs during layout, and a nested builder handed its `context` looks exactly like it, so a read through it starts over only when the run is provably the builder's: new constraints (a resize releases the wide layout's key), a notification from one of its own reads (a key taken from another query), or a new widget from its parent. A rebuild caused by an `InheritedWidget` it depends on arrives through `didChangeDependencies`, which no read can see, so those reads are additive: the old key goes at the next of the three, or when the `LayoutBuilder` unmounts. Nothing it shows loses its subscription. **Fix.** A key that depends on anything the builder reads belongs in a widget of its own below the `LayoutBuilder` — `c.maxWidth > 600 ? const WideTasks() : const NarrowTasks()`, each reading in its own `build` — whose own build is always provable, and the key goes with the widget. ## A dialog's data stops updating **Symptom.** A `showDialog` or bottom-sheet builder calls `context.query` (or `watchQuery`) with the page's `context`. The dialog shows the value it opened with; later changes do not reach it, or it shows a stale value after the page rebuilt. **Mechanism.** A read belongs to the element whose `context` it went through, and rebuilds that element: the page, not the dialog, which lives in another subtree. And the page's next own build does not read the dialog's key, so it is released after that frame while the dialog still shows it. Nothing a reader shows *in its own subtree* loses its subscription; a dialog is not in the page's. **Fix.** Give the dialog a reader of its own: read through the `context` the dialog builder is given, or in a widget of its own inside the dialog. ## "QueryClientProvider could not listen to its onlineStatus" **Symptom.** `OnlineStatus.stream(changes, …)` with a single-subscription stream works — until the provider remounts, or a second provider is given the same stream, or switches away from it and back. Then a `FlutterError` says the stream has already been listened to. **Mechanism.** A single-subscription stream can be listened to once. It works only while exactly one provider listens to it, once. **Fix.** `OnlineStatus.stream(changes.asBroadcastStream(), initial: …)`, built once outside `build`. Taking `onlineStatus` away, or disposing the provider, puts the client back online once no other provider has a status for it; a replacement provider on the same client keeps its own verdict. ## Two keys that "are" the same do not match **Symptom.** A key holding a record of a list — `['tasks', (ids: [1, 2],)]` — never matches the key built from the same values again, and every read fetches anew. **Mechanism.** A record compares its fields with their own `==`, and a `List`'s `==` is identity, so the key is new every time. Lists and maps as key *parts* are compared deeply; inside a record they are not. `DateTime` parts compare by instant — UTC and local of one moment are one key — but a `DateTime` used as a map *key* inside a part still compares with its own `==`. **Fix.** Put the list in the key directly, or use a value class with deep `==` and `hashCode`. --- # Differences from TanStack Query > Where query_kit behaves differently from TanStack Query on purpose — the shape of the API, the rules Dart's type system adds, and the failure cases it settles differently. query_kit follows TanStack Query's behaviour: the same cache, the same staleness and refetch rules, the same order of callbacks. Where it differs, the difference is deliberate — usually because Dart or Flutter offers something better, or because the JavaScript behaviour depends on something Dart does not have. This page lists what you can notice. For a name-by-name map, see [coming from React Query](https://dualmeta-gmbh.github.io/query_kit/docs/coming-from-react-query.md); for what is not here at all, the [feature matrix](https://dualmeta-gmbh.github.io/query_kit/docs/reference/feature-matrix.md). ## Types | TanStack Query | query_kit | |---|---| | `getQueryData` casts whatever is cached to the type you asked for | a key is bound to the exact type it was first used with; reading it as another type — a supertype or a nullable one included — throws `QueryDataTypeError`. A write may store any value the entry's type can hold. See [type safety in Dart](https://dualmeta-gmbh.github.io/query_kit/docs/dart-type-safety.md#one-key-one-exact-type) | | union-typed options: `staleTime: number \| 'static' \| fn`, `retry: boolean \| number \| fn`, … | sealed value types: `StaleTime`, `RetryPolicy`, `Enabled`, `RefetchOn`, `RefetchInterval`, … `null` always means "not configured" | | `staleTime: Infinity` | `StaleTime.infinite`, distinct from `StaleTime.static` | | `initialData: null` and `placeholderData: null` mean "none" | `InitialData.value(null)` is a value of `null`; a `.compute` returning `null` means "none" | | one observer options object with an optional `select` | two shapes: `QueryObserverOptions` without `select`, `QuerySelectOptions` with `select` required. `withSelect` turns the first into the second | | `queryKeyHashFn`, keys hashed to strings | `QueryKey` is a value type compared part by part; `debugString` is for logs | | `TError` type parameter | errors are `Object` plus a `StackTrace` | | a `queryFn` that resolves to `undefined` fails the query at run time | cannot happen: the query function returns a `Future`, and the type says whether `null` is a value | | a `refetchInterval` or `enabled` callback gets a typed `Query` | the callbacks of `StaleTime.dynamic`, `Enabled.when`, `RefetchInterval.dynamic` and `RefetchOn`'s computed form get a `Query`, because the same values sit in `QueryDefaults`, which match every key and type. Cast `query.state.data` to your type inside the callback | | `setQueryData(key, undefined)` writes nothing | there is no `undefined`, and `null` is a value: `setQueryData(key, null)` creates the entry and writes `null` into it. A bare `setQueryData(key, null)`, with no type, is treated as "write nothing". To leave the data alone from an updater, return `null` from `updateQueryData` | | an observer without `select` whose data type differs from the query's is not checked | refused with `ArgumentError` when it is built or handed new options; the binding's controllers also refuse a top-typed result type (`Object?`, `dynamic`) in debug builds | ## API shape | TanStack Query | query_kit | |---|---| | `fetchQuery`, `prefetchQuery`, `ensureQueryData` | one `client.query`: `await` it, `.ignore()` it, `staleTime: StaleTime.static`, or `revalidateIfStale: true`. See [prefetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/prefetching.md) | | a positional filters object | a named `filters:` argument everywhere | | `skipToken` | `Enabled.no` — which, unlike `skipToken`, lets `refetchQueries` refetch a query that nobody observes and that has been fetched before (see *Behaviour* below) | | `hasNextPage`, `fetchNextPage`, and `isRefetching` / `isRefetchError` corrected for page fetches, on the result | on the infinite query's controller or observer; the sealed result keeps one shape | | an infinite query's `queryFn` | `pageFn`, with a typed page context | | `useQueries` with a tuple of different types and `combine` | `QueriesBuilder` for a list of one type; different types combine as a record of results, `(a, b).combine(…)`. See [combining queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/combining-queries.md) | | `keepPreviousData` | `const PlaceholderData.keepPrevious()` | | `initialDataUpdatedAt` as a function | `initialDataUpdatedAtCompute` | | a mutation function's second argument | `mutationFnWithContext: (variables, context)`; plain `mutationFn` takes the variables only | | `mutate(variables, { onSuccess, … })` | `mutate(variables, callbacks: MutateCallbacks(…))` | | the rollback handle on the mutation result (`context`) | passed to `onSuccess`, `onError` and `onSettled`, not on `MutationResult` | | module-level `focusManager`, `onlineManager`, `notifyManager` | instances owned by each `QueryClient`; `NotifyManager.shared` opts back into one shared manager | | `QueryClientProvider` always takes a client you made | `QueryClientProvider(client: …)` borrows one; `QueryClientProvider.create` builds its own and clears it when it goes. See [the reference](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md#queryclientprovider) | | cache callbacks on a reassignable `config` | final constructor arguments of `QueryCache` and `MutationCache` | | `subscribe` keeps its listeners in a `Set`, so subscribing one function twice registers it once, and either unsubscribe removes it | every `subscribe` is its own registration, and the function it returns removes that one only, once — because two tear-offs of one method are `==` in Dart, and a `Set` would let one subscriber's unsubscribe silence another | ## Rebuilds | TanStack Query | query_kit | |---|---| | `notifyOnChangeProps` and tracked result properties | whole results are compared; `buildWhen` narrows rebuilds explicitly. See [what rebuilds](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md) | | `throwOnError` | errors live in the sealed result | | a `select` memo kept while the selector and its input are `===` the last ones | kept while both are `==` — two tear-offs of one method count as one selector, and data with value equality (a record, a value class, `InfiniteData`) that comes back equal is not selected again | | `replaceEqualDeep` walks arrays and plain objects; a `Map`, a `Set` or a class instance is replaced | lists are shared element by element, maps and sets whole when deep-equal, `InfiniteData`'s pages and params each on its own; typed data (`Uint8List`) and any other class are leaves compared with `==`, unless the class implements `StructurallyShareable`. A custom `structuralSharing` hook is typed to the query's data, so it covers the cache write but not a `select`'s output; `noStructuralSharing()` is the one spelling that switches both off. See [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md) | | an observer's listeners are told about every state change | a listener is told only when the new result is not `==` to the last one it was told. On a mutation that means fewer intermediate states: one `pending` across `onMutate`, not two | | notifications are batched to the next macrotask (`setTimeout(0)`) | batched to a microtask, so a batch lands before the next frame rather than after it. `NotifyManager.setScheduler` puts the old timing back | | a listener that subscribes during a notification hears that same notification | it hears the next one; a listener removed during a notification is skipped, as in TanStack Query | | a side effect on a query result needs a component that also renders | `QueryListener`, `InfiniteQueryListener` and `MutationListener` run a callback per accepted change and never rebuild their child. See [the reference](https://dualmeta-gmbh.github.io/query_kit/docs/reference/widgets-and-controllers.md#listeners) | ## Behaviour | TanStack Query | query_kit | |---|---| | a fetch with no query function is retried like any failure | `MissingQueryFunctionError` is never retried, and neither is `MissingMutationFunctionError` | | an imperative fetch with no retry policy writes `retry: 0` into the shared query | the one-attempt rule applies to that fetch alone | | a state-dependent filter on `invalidateQueries` refetches none of what it invalidated | the matched set is fixed before invalidating, so it refetches what it marked | | a filter predicate that throws, throws synchronously out of the bulk operations | `invalidateQueries`, `refetchQueries`, `resetQueries` and `cancelQueries` fail their returned future instead; `removeQueries`, which returns nothing, still throws where it is called | | a throwing listener, `retry` or `retryDelay` callback can leave a fetch pending forever | the throw is reported to the zone, or becomes the fetch's error; the fetch settles | | a silent cancel with no successor leaves `fetchStatus: 'fetching'` | it is put back to `idle` | | a cancelled fetch's late response can still write over the fetch that replaced it | only the fetch that currently owns the query writes; a cancelled one settles into nothing, and a third caller joins the running fetch instead of starting another | | a cancelled fetch's retry delay still runs to its end | the delay is dropped with the fetch | | a `retryDelay` callback runs once more than there are retries, after the final failure too | it runs only when a retry follows | | two callers of one fetch can see its result before the cache has it | every caller's future completes after the cache is written and its callbacks ran, so callers and cache agree | | an `enabled` callback over state outside the cache is never seen to change | it is re-read on the next `setOptions` — in Flutter, the reader's next rebuild — and a query that became enabled then fetches if stale. See [dependent queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/dependent-queries.md) | | `refetchQueries`, and `invalidateQueries` with `refetchType: 'all'`, skip an unobserved query whose `queryFn` is `skipToken` even when it holds data | `Enabled.no` spells both `skipToken` and `enabled: false`, and the second meaning is kept: an explicit refetch includes an unobserved query that has been fetched before, whatever its last reader's `enabled` was. `enabled` governs automatic fetching only | | a listener that reacts to a query's `fetch` event finds nothing to cancel or join yet | the fetch is in place before the event goes out: `cancelQueries` from that listener stops the request, and `client.query` of the same key joins it | | an observer subscribed again after its query was garbage-collected rejoins the dead query | it looks the key up again and joins the entry that is in the cache now | | a `select` that threw keeps reporting its error after `select` is removed, and a kept placeholder keeps its old selection after `select` changes | the error goes with the selector and the raw data is reported; a new `select` runs over the placeholder | | `resetQueries` on a query nobody observes leaves it in the cache for good | it is garbage-collected after its `gcTime`, like any unobserved query | | a `select` that throws on a new key's first data shows the previous key's (or the placeholder's) selection as stale data | it is a loading error with no stale data: a selection belongs to the key it came from | | every foreground event refetches stale queries | the same by default; `refetchMinBackgroundDuration` can skip a refetch after a short absence. See [app focus refetching](https://dualmeta-gmbh.github.io/query_kit/docs/guides/window-focus-refetching.md) | | `resumePausedMutations` waits for the whole client to be online | decided per mutation by its own network mode | | `await resumePausedMutations()` can complete before the resumed mutations' `onSuccess` ran, so a refetch can overtake their cache writes | it completes after every resumed run has settled and its callbacks have run | | a mutation's `setOptions` while it runs can move it to another scope | the scope is fixed for the run | | a scope's turn goes to the earliest-*built* pending mutation | it goes to the one that started first, and it holds the scope until its callbacks have run | | removing a scope's queued mutation that never started leaves the rest of the queue paused until the next resume | the next one in the scope starts; `clear()` empties the cache first, so nothing it drops starts | | two same-scope mutations with a synchronous `onMutate` run `optimistic 1, optimistic 2, request 1` | `optimistic 1, request 1, optimistic 2`: a synchronous `onMutate` goes straight to the request, so an optimistic update reaches the frame that asked for it | | a mutation removed from the cache keeps retrying, or stays paused for good | it stops, and fails with its last error or a `CancelledError`; its `onError` runs a moment later, which is why a widget test's teardown clears the client twice. See [testing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md) | | `mutate` on a forgotten observer re-attaches it | `mutate` on a disposed `MutationController` still runs the mutation and its options' callbacks, attaches nothing, and drops the per-call callbacks | | a mutation observer's `setOptions` before its first `mutate` emits `observerOptionsUpdated` with no mutation | nothing is emitted until the observer has a mutation | | mutation defaults may carry callbacks | `setMutationDefaults` carries no callbacks | | mutations cannot be cancelled | `cancel()` fails a run with `CancelledError`; see [cancelling mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/cancelling-mutations.md) | ## Dart and Flutter only - **Structural sharing into your own classes.** A class implementing `StructurallyShareable` is walked into; any other class is a leaf. See [structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md). - **`consecutiveErrorCount`** on the query's state and result, for giving up after failures in a row. See [polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md). - **App lifecycle as focus.** `inactive` counts as focused on phones and as unfocused on desktops. - **`refetchMinBackgroundDuration`** on the focus manager, so a glance at a notification does not refetch every stale query on return. - **`combine` over a record of results**, of different types, with a `CombinedResult` that is pending, an error or data. See [combining queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/combining-queries.md). - **Cancellation is `QueryCancelToken`**, with `onCancel` as the interop point, because Dart has no ecosystem-wide abort signal. ## Robustness Some JavaScript behaviours leave a fetch hanging when user code throws in an unexpected place. Here every such throw either becomes the fetch's error or is reported to the zone (`FlutterError.onError` in an app), and the fetch settles either way: - a throwing `retry` or `retryDelay` callback is the fetch's error; - a throwing cache listener, observer listener, focus or online listener, or a callback queued in a notification batch, is reported to the zone, and the listeners after it still run; - a throwing cancel callback is reported, and the other cancel callbacks still run; - a query or mutation removed from its cache cannot be added back, so one key never has two live entries; - a dynamic option that throws while an observer subscribes, or while it is handed new options, leaves the observer as it was — nothing half-registered, nothing half-switched — and the throw reaches the caller; - an observer destroyed from inside its own notification stays destroyed: no timer it scheduled re-arms polling. Every one of these is covered by a test in the core package's suite. The [errors reference](https://dualmeta-gmbh.github.io/query_kit/docs/reference/errors.md#errors-from-callbacks-and-listeners) says what each throw becomes. --- # Credits, and what this is not > A port of TanStack Query, with thanks — unaffiliated with TanStack, and written by AI. ## It is a port Not "inspired by". Not "in the spirit of". A **port of [TanStack Query](https://tanstack.com/query)**: the behaviour is TanStack Query's, the architecture is its, the option names are its where Dart allowed it, and its own test suite is ported case by case — every case left out is listed with its reason — and run against this code. Where query_kit deliberately behaves differently, the difference is written down with its reason — see [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md). If you know TanStack Query, you already know this library. That is the goal, and [how it is checked](https://dualmeta-gmbh.github.io/query_kit/docs/project/fidelity.md) is the only interesting thing about the project. ## Thank you **To Tanner Linsley, to TkDodo, and to everyone who has built, maintained, documented and supported TanStack Query.** This repository exists for one reason: we used TanStack Query, we loved it, and we wanted the same thing in Flutter. Server state is a genuinely hard problem that most teams end up solving badly and by accident, and TanStack Query is the answer that made it look easy. Everything good in here is an idea we took from them. It is published under their MIT licence. Each package carries Tanner Linsley's copyright line and the MIT permission notice in `LICENSE-TANSTACK`, because a port that translates `query-core` and its test suite line by line is a copy of substantial portions of the original, and the licence says so. ## It is not theirs > **Danger: Not affiliated with TanStack** > > This project is **not affiliated with, endorsed by, reviewed by, or connected > in any way to** Tanner Linsley, the TanStack team, or the TanStack > organisation. They have not seen it. They have no responsibility for it. The > name similarity, where any exists, is descriptive — it says what this is a port > *of*, not who made it. > > **Please do not take problems with this package to them.** Bugs, questions and > complaints belong in [this repository's > issues](https://github.com/dualmeta-gmbh/query_kit/issues), and nowhere near > TanStack's. The package name here is deliberately not TanStack's: [`docs/releasing.md`](https://github.com/dualmeta-gmbh/query_kit/blob/main/docs/releasing.md) explains why, and the [naming research](https://github.com/dualmeta-gmbh/query_kit/blob/main/docs/research/package-naming-and-affiliation.md) records what was actually checked — pub.dev's rules, what TanStack has and has not said about community ports, and how ports in six other languages named themselves. ## It was written by AI > **Warning: An AI-written project** > > 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. The agents also wrote the plan they worked from. That is not a disclaimer bolted on afterwards; it is how the project was run, and the repository records it. Every decision is settled by an agent, and the record states the options, the answer, and why it beats the alternatives, so any call can be reopened from the record alone. The plan, its decisions and the research behind them are all public. What the maintainer decided is a short list: the destination; that the binding's API shape would offer four equal call styles with no recommended default; and that neither published package may require a third-party dependency. Everything else — the architecture, the divergences, the test strategy, this sentence — was decided by an agent. **What stands in for human review is adversarial, and deliberately so:** - **TanStack Query's own test suite**, ported case by case. It is the one referee that cannot be talked round. - **Repeated external deep-dive reviews**, each by a fresh reviewer with no memory of the decisions — and a fresh review of every fix. - **A standing rule that no reported finding is acted on until it has been reproduced.** Findings that do not reproduce are written down too, with the disproof. - **Example applications** that use the library for real, and a first integration into a production app. What each of these found is on [how fidelity is proven](https://dualmeta-gmbh.github.io/query_kit/docs/project/fidelity.md). None of that makes the code correct. It does mean the claims on this site are the kind you can check, and every one of them names the file that would prove it wrong. Judge it on that. --- # How fidelity is proven > TanStack Query's suite ported case by case, what porting found, what review and real use found, and where every omission is written down. Several Dart packages cover the *idea* of TanStack Query. The claim here is narrower and checkable: **the behaviour is upstream's, and upstream's own tests say so.** ## The method Read the upstream module. Read its upstream test file. Write the Dart module. Then port the test file case by case and let the failures tell you where the port is wrong. Three rules keep that honest: 1. **When a ported test fails, the port is wrong, not the test.** An assertion changes only when a design decision genuinely makes upstream's expectation inapplicable — and then the reason is written down. 2. **Port it, don't rewrite it.** Upstream's test names are kept, so the two files diff against each other. One Dart file maps to one upstream file everywhere; port-only behaviour goes in `smoke_test.dart`. 3. **Nothing is omitted silently.** Every case that is not ported is listed by name and category in [`PORTING_NOTES.md`](https://github.com/dualmeta-gmbh/query_kit/blob/main/packages/query_kit/test/PORTING_NOTES.md), with its reason. The port follows one fixed revision of TanStack Query, **`50680b98c`**, so the ported tests and the behaviour they check describe the same version. ## What is ported **414 of the 536** upstream cases in seventeen suites are ported: | upstream suite | ported | upstream suite | ported | |---|---|---|---| | `query` | 44 / 51 | `mutation` | 28 / 28 | | `queryCache` | 14 / 16 | `mutationCache` | 16 / 16 | | `queryObserver` | 64 / 75 | `mutationObserver` | 16 / 16 | | `queryClient` | 106 / 156 | `infiniteQueryBehavior` | 7 / 9 | | `queriesObserver` | 12 / 23 | `infiniteQueryObserver` | 6 / 7 | | `retryer` | 13 / 13 | `utils` | 48 / 78 | | `subscribable` | 9 / 9 | `removable` | 11 / 12 | | `notifyManager` | 6 / 7 | `focusManager` | 7 / 9 | | `onlineManager` | 7 / 11 | | | The gap is almost entirely React-specific tests, JavaScript-helper tests with no Dart counterpart, and features [deliberately not ported](https://dualmeta-gmbh.github.io/query_kit/docs/reference/feature-matrix.md). Each is enumerated. ## What porting found **23 bugs**, none of which a test written from the Dart side would have caught — because each is a behaviour you only know to check if you know the original: - the abort signal marked as consumed one `await` too late; - a silent cancel dispatching an error nobody asked for; - mutation callbacks running in the wrong order; - a restored offline mutation that could never resume; - a cancel token whose callbacks ran a microtask too late for an in-flight page loop. ## What review found Repeated rounds of external deep-dive review — each by a fresh reviewer with no memory of the decisions, and each followed by a fresh review of its fixes — found **more than 150** more. None was caught by a ported case, because a ported case tests the port against upstream and these were about Dart and Flutter: - a `Set` of listeners silently dropping a subscription, because Dart tear-offs compare equal and JavaScript closures never do; - a `State` that never noticed its `QueryClient` had changed; - an infinite query's direction change never reaching the widget; - `unmount()` before `mount()` switching focus and reconnect refetches off permanently; - `AppLifecycleState.inactive` meaning "interrupted" on a phone and "another window is in front" on a desktop. Every one of them has a regression test — `port_specifics_test.dart` and `port_lifecycle_test.dart` in the core, `review_regressions_test.dart` in the binding — and PORTING_NOTES' "regressions found by review" section says what each was. **Every finding is reproduced before anything is changed**, and the reviews of the fixes found defects the fixes had introduced, round after round — which is why every fix gets a review of its own. Several reported findings could not be reproduced at all. They are written down too, with the disproof, because an unreproduced report is worth knowing about — and because "we checked, and here is what we found instead" is the only way that information survives. ## What the examples found Two library bugs that no ported test could reach, because neither is visible without a real widget: a read whose key changed lost its `PlaceholderData.keepPrevious()` placeholder, and `structuralSharing` was invisible to every reader because the observer re-shared the cache's data against its own last result. Both were reproduced in the library's own suite before anything was changed. That is what [the examples](https://dualmeta-gmbh.github.io/query_kit/docs/project/examples.md) are for. ## What real use found The first integration into a production Flutter app reported fourteen findings. One was a defect — structural sharing handed an unmodifiable list back growable — and was fixed with its regression tests. The rest were behaviour the library shares with TanStack Query, or requests beyond it; the ones that were built are `combine`, `mutationFnWithContext` and cancelling a mutation, `consecutiveErrorCount`, typed mutation state and `StructurallyShareable`, and the traps it hit opened the [troubleshooting](https://dualmeta-gmbh.github.io/query_kit/docs/reference/troubleshooting.md) page. ## The numbers Measured by running each suite, not by counting `test(` in the sources. | | | |---|---| | core | **826** Dart VM tests; **822** also compiled to JavaScript (a few barrel checks are VM-only). 414 of them are ported upstream cases; the rest are the port-only files, the review regressions and the integration regressions | | binding | **287** tests, widget tests behind one harness | | examples | a widget test per showcase screen and per task-manager checklist row, Playwright end-to-end tests in Chromium against each example's real server, and contract cases run against both the fake backend and the real server | | documentation | every Dart sample on this site is compiled, and held equal to its compiled twin by a test | Every push runs all of it, plus the analyzer at `--fatal-infos`, the formatter, `dart doc --validate-links`, both publish dry-runs, a web build of each example — and the whole suite again on the declared Flutter floor, because a floor nobody tests is a guess. ## Divergences Closeness to upstream is a **tiebreaker, not a goal**. Where a Dart or Flutter idiom is better, the port diverges and writes down why; the full table is at the end of PORTING_NOTES, and [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md) lists the ones you can notice, in user terms. --- # How the examples are built > How the examples are built and tested — the one-file tour, the showcase against a real backend, and the acceptance demo. Three, of increasing size. [Examples](https://dualmeta-gmbh.github.io/query_kit/docs/examples.md) lists what each showcase screen shows and which guide it belongs to; this page is about how they are built and proven. ## The one-file tour `packages/query_kit_flutter/example/` — a provider, one query read two ways and a mutation that invalidates it, with no server at all. `flutter run` in that directory. It is what pub.dev shows on the package page. ## The showcase `examples/showcase/` — **every feature of the library as its own screen**, 30 of them, against a dummy backend built for the purpose, each with widget tests and Playwright end-to-end tests in a real browser. The app is the catalogue; the tests are the proof. ```bash cd examples/showcase/server && npm install && npm run dev ``` ```bash cd examples/showcase && flutter run -d chrome ``` Each feature lives in `lib/features//`, imports only the package and `lib/shared/`, and says at the top of its file what it shows, which TanStack Query example it mirrors, and how it is proven. | Reading | Paging | Writing | Runtime | |---|---|---|---| | `simple` | `pagination` | `mutations` | `auto-refetching` | | `basic` | `load-more` | `optimistic-updates` | `retry` | | `default-query-function` | `max-pages` | `mutation-state` | `cancellation` | | `dependent-queries` | | `playground` | `offline` | | `parallel-queries` | | `invalidation-and-filters` | `focus-refetch` | | `query-collections` | | `global-callbacks` | `four-call-styles` | | `combine` | | `mutation-cancel` | | | `prefetching` | | | `cache-inspector` | | `select-and-sharing` | | | `diagnostics` | | `build-when` | | | | | `initial-and-placeholder` | | | | | `stale-and-gc` | | | | **No screen presents one of the four call styles as the default**; across the catalogue each is used in its turn. ### How it is tested Three layers and nothing else: - **Widget tests** run the real app against a `dio` `HttpClientAdapter` that mirrors the server route for route and loads the same seed file. - **A contract test** runs one list of cases against the fake *and*, with the server up, against the server. That is what makes the fake trustworthy. - **End-to-end tests** drive the real web build in Chromium against the real backend. Every test gets a backend scenario of its own, so the suite runs fully parallel and nothing one test does is visible to another. Nothing in the browser suite asserts on a clock. To prove something shows *before* the backend answers, the test holds the request in the browser and releases it after the assertion; a poll is proven to stop by sampling a request count, waiting, and sampling again. Flutter web paints to a canvas, so the tests read the **semantics tree** — the same tree a screen reader gets. ### What building it found Two library bugs that no ported test could reach; see [how fidelity is proven](https://dualmeta-gmbh.github.io/query_kit/docs/project/fidelity.md#what-the-examples-found). ## The acceptance demo `examples/task_manager/` — a small to-do app against a deliberately slow backend with scripted failures: renaming to `fail` is rejected, every second delete fails, and a reminder is *accepted* before it is *confirmed*, so a poll has to survive a confirmation window without stomping the value the user asked for. Where the showcase is a catalogue — one screen per feature, so you can look a feature up — this is the other kind of example: one ordinary app that needs six of them at once, so you can see how they compose. It was also the library's acceptance bar, and that checklist is in the app's README, one widget test per row. It is also where the four call styles are each used once, in the place each one genuinely fits — a controller for the list two siblings share, `context.query` per row, the mixin on the already-stateful detail screen, a select builder for the header badge.