Skip to main content

Differences from TanStack Query

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; for what is not here at all, the feature matrix.

Types​

TanStack Queryquery_kit
getQueryData casts whatever is cached to the type you asked fora 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
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: InfinityStaleTime.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 selecttwo shapes: QueryObserverOptions<TData> without select, QuerySelectOptions<TQueryData, TData> with select required. withSelect turns the first into the second
queryKeyHashFn, keys hashed to stringsQueryKey is a value type compared part by part; debugString is for logs
TError type parametererrors are Object plus a StackTrace
a queryFn that resolves to undefined fails the query at run timecannot happen: the query function returns a Future<T>, and the type says whether null is a value
a refetchInterval or enabled callback gets a typed Querythe callbacks of StaleTime.dynamic, Enabled.when, RefetchInterval.dynamic and RefetchOn's computed form get a Query<Object?>, 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 nothingthere is no undefined, and null is a value: setQueryData<Product?>(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 checkedrefused 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 Queryquery_kit
fetchQuery, prefetchQuery, ensureQueryDataone client.query: await it, .ignore() it, staleTime: StaleTime.static, or revalidateIfStale: true. See prefetching
a positional filters objecta named filters: argument everywhere
skipTokenEnabled.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 resulton the infinite query's controller or observer; the sealed result keeps one shape
an infinite query's queryFnpageFn, with a typed page context
useQueries with a tuple of different types and combineQueriesBuilder for a list of one type; different types combine as a record of results, (a, b).combine(…). See combining queries
keepPreviousDataconst PlaceholderData.keepPrevious()
initialDataUpdatedAt as a functioninitialDataUpdatedAtCompute
a mutation function's second argumentmutationFnWithContext: (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, notifyManagerinstances owned by each QueryClient; NotifyManager.shared opts back into one shared manager
QueryClientProvider always takes a client you madeQueryClientProvider(client: …) borrows one; QueryClientProvider.create builds its own and clears it when it goes. See the reference
cache callbacks on a reassignable configfinal 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 itevery 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 Queryquery_kit
notifyOnChangeProps and tracked result propertieswhole results are compared; buildWhen narrows rebuilds explicitly. See what rebuilds
throwOnErrorerrors live in the sealed result
a select memo kept while the selector and its input are === the last oneskept 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 replacedlists 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
an observer's listeners are told about every state changea 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 notificationit 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 rendersQueryListener, InfiniteQueryListener and MutationListener run a callback per accepted change and never rebuild their child. See the reference

Behaviour​

TanStack Queryquery_kit
a fetch with no query function is retried like any failureMissingQueryFunctionError is never retried, and neither is MissingMutationFunctionError
an imperative fetch with no retry policy writes retry: 0 into the shared querythe one-attempt rule applies to that fetch alone
a state-dependent filter on invalidateQueries refetches none of what it invalidatedthe matched set is fixed before invalidating, so it refetches what it marked
a filter predicate that throws, throws synchronously out of the bulk operationsinvalidateQueries, 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 foreverthe 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 itonly 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 endthe delay is dropped with the fetch
a retryDelay callback runs once more than there are retries, after the final failure tooit runs only when a retry follows
two callers of one fetch can see its result before the cache has itevery 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 changeit 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
refetchQueries, and invalidateQueries with refetchType: 'all', skip an unobserved query whose queryFn is skipToken even when it holds dataEnabled.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 yetthe 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 queryit 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 changesthe 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 goodit 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 datait is a loading error with no stale data: a selection belongs to the key it came from
every foreground event refetches stale queriesthe same by default; refetchMinBackgroundDuration can skip a refetch after a short absence. See app focus refetching
resumePausedMutations waits for the whole client to be onlinedecided per mutation by its own network mode
await resumePausedMutations() can complete before the resumed mutations' onSuccess ran, so a refetch can overtake their cache writesit completes after every resumed run has settled and its callbacks have run
a mutation's setOptions while it runs can move it to another scopethe scope is fixed for the run
a scope's turn goes to the earliest-built pending mutationit 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 resumethe 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 1optimistic 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 goodit 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
mutate on a forgotten observer re-attaches itmutate 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 mutationnothing is emitted until the observer has a mutation
mutation defaults may carry callbackssetMutationDefaults carries no callbacks
mutations cannot be cancelledcancel() fails a run with CancelledError; see cancelling mutations

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.
  • consecutiveErrorCount on the query's state and result, for giving up after failures in a row. See polling.
  • 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.
  • 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 says what each throw becomes.