Coming from TanStack Query (JS)
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 says how.
Two rules shape most of the table:
nullmeans "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 aswitchis exhaustive and there is nodata!. AQueryErrorstill carriesstaleData.
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<T>(options: …, builder: …) |
QueryController<TQueryData, TData>(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<int>; the snapshot is client.isFetching() |
useIsMutating(filters) | a MutationStateController filtered on MutationStatus.pending, read for its length; the snapshot is client.isMutating() |
getQueryData<InfiniteData<…>>(key) | getInfiniteQueryData<TPage, TParam>(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<T>(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<Task> and List<Object?> are different types, and getQueriesData<T> checks like getQueryData<T> |
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<TQueryData, TData>, select required. Without one, QueryObserverOptions<TData> 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 |
Caches and events
| JS | Here |
|---|---|
new QueryCache({ onError, onSuccess, onSettled }) | QueryCache(onError: …, onSuccess: …, onSettled: …) — final constructor arguments; see global callbacks |
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 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
switchoverQueryPending/QuerySuccess/QueryErrorputs 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/hydrateandHydrationBoundaryhave nothing to do. - Devtools. None. The cache is observable, though: inspecting the
cache shows how to watch it, and the showcase's
cache-inspectorscreen is a small inspector built that way. - Persistence. Not in 1.0. A persister would restore entries with
setQueryDataat start-up and save them from aqueryCache.subscribelistener; there is no ready-made one. streamedQuery. Not ported. A stream subscription that writes into the cache withsetQueryData, or invalidates it, does the same job.
The feature matrix lists everything that is out.