Skip to content

Setting up the client

Configure one QueryClient for the whole app: set defaults, report every failure in one place, and catch the errors that callbacks throw. Do it once, in main, before anything creates an observer. Part of the app can run on a client of its own, for example in a widget test. The QueryClient reference lists every constructor option.

Fuery.client is the client that widgets use without a FueryProvider. observe() and a definition’s mutate use it too, unless you pass a client. Fuery creates it on first use, so an app that configures nothing still works.

To configure it, assign a new client in main:

void main() {
Fuery.client = QueryClient(
defaultOptions: const DefaultOptions(
queries: QueryDefaults(staleTime: Duration(seconds: 30)),
),
);
runApp(const App());
}
  • Assigning Fuery.client mounts the new client and unmounts the previous one.
  • A mounted client refetches on focus and on reconnect, and resumes paused mutations.
  • Assign it before anything creates an observer. An observer keeps the client it was created with.
  • Register defaults before observers exist too. An observer applies the defaults when it receives its options, not afterwards.

Set defaults for every query and mutation of the client, or for a key prefix:

Fuery.client = QueryClient(
defaultOptions: const DefaultOptions(
queries: QueryDefaults(staleTime: Duration(seconds: 30)),
mutations: MutationDefaults(retry: RetryPolicy.count(2)),
),
);
Fuery.client.setQueryDefaults(
['settings'],
const QueryDefaults(staleTime: infiniteDuration),
);
Fuery.client.setMutationDefaults(
['todos'],
const MutationDefaults(networkMode: NetworkMode.offlineFirst),
);
  • Options set on the query or mutation win over per-key defaults.
  • Per-key defaults win over the client’s defaultOptions.
  • A mutation gets per-key defaults only when it has a mutationKey.
  • getQueryDefaults(['settings']) and getMutationDefaults(['todos']) return the per-key defaults for a key, merged from every matching prefix. They don’t include defaultOptions.

Defaults lists the fields of QueryDefaults and MutationDefaults.

Give the caches a config to run a callback for every query and every mutation, for example to report failures to a crash or logging service:

Fuery.client = QueryClient(
queryCache: QueryCache(
config: QueryCacheConfig(
onError: (error, query) => reportError(error, query.queryKey),
),
),
mutationCache: MutationCache(
config: MutationCacheConfig(
onError: (error, variables, context, mutation) =>
reportError(error, mutation.options.mutationKey),
),
),
);
  • A cache keeps its config for its whole life, so pass the config when you construct the client.
  • QueryCacheConfig callbacks run after a fetch. A cancelled fetch isn’t a failure and reaches none of them.
  • MutationCacheConfig callbacks run before the callbacks of the mutation itself, and Fuery awaits a future they return.
  • The callbacks receive each mutation run as an AnyCachedMutation, whose data, variables, and context are Object?. Tell mutations apart by mutation.options.mutationKey or mutation.options.meta.

Cache callbacks lists every callback and when it runs.

onUncaughtError receives the errors that no caller can catch, so you decide how to record them:

  • An error thrown by a QueryCacheConfig or MutateOptions callback.
  • An error thrown by onError or onSettled after a mutation failed, from the mutation or from its MutationCacheConfig.
  • An error thrown by refetchWhile or placeholderData while Fuery updates an observer after its query changed.
  • An error thrown by a listener: the listener of a listener widget, a consumer, or a hook, or a function passed to a slot’s listen or subscribeToRuns.
  • A mistake Fuery finds while running, such as a getNextPageParam that returns a param of the wrong type or throws while a result is built, or a persisted mutationKey that can’t be stored.
Fuery.client = QueryClient(
onUncaughtError: (error, stackTrace) => reportError(error, stackTrace),
);
  • The query or mutation goes on as if the callback hadn’t thrown.
  • Fuery reports each mistake once per client, not every time the code runs.
  • When onUncaughtError throws, its error and the error it received both go to the current zone.

Without onUncaughtError, these errors go to the current zone, and Flutter passes them to PlatformDispatcher.onError. A crash reporter that records everything there as fatal then counts them as crashes, although the app keeps running.

The other callbacks of a Mutation and a MutationCacheConfig are part of the mutation. An error thrown by onMutate, or by onSuccess or onSettled after a success, fails the mutation and reaches its onError.

A Query holds no client, so one definition works with every client. Fuery picks the client where the query is used:

  • A widget or hook that gets a definition uses the client of the nearest FueryProvider, or Fuery.client without one. It follows a provider whose client is replaced.
  • observe() uses the client you pass as client:, or Fuery.client at that moment. The observer keeps that client for its whole life, and observer.client returns it.
  • A definition’s mutate and mutateAsync use the client you pass them, or Fuery.client at that moment. In a widget, pass context.queryClient, so the run reaches the cache that the widgets read.
  • A widget or hook that gets an observer uses the observer’s client. In debug builds, it prints a warning when that isn’t its own client. See A screen reads another client’s cache.
  • Query functions, placeholderData, and mutation callbacks receive the client that runs them.

Queries can therefore be top-level values. Widget tests that each get a fresh client, through Fuery.client or a FueryProvider, need nothing else.

Wrap part of the app in FueryProvider to run it on another client, for example in a widget test. Keep the client in a State field, so the subtree keeps one client while it is mounted:

class _SettingsPageState extends State<SettingsPage> {
final client = QueryClient();
@override
Widget build(BuildContext context) {
return FueryProvider(client: client, child: const SettingsView());
}
}
  • Create the client once: in main, in a State field, or in a test’s setUp.
  • Don’t create it in build. A QueryClient created there is a new, empty cache on every rebuild and every hot reload, so the widgets below go back to loading and fetch again.
  • FueryProvider mounts the client and unmounts it when the provider goes away, so the State needs no dispose.

Widgets below the provider use its client. context.queryClient returns it, or Fuery.client when there is no provider. Pass it to observe for an observer of your own:

late final todos = todosQuery.observe(client: context.queryClient);

Pass it to a definition’s mutate too: addTodo.mutate('Buy milk', context.queryClient).

An adapter for another state library reads the client with FueryProvider.of(context, listen: true), which rebuilds when the provider’s client is replaced.

The example assigns Fuery.client in main, with a storage, and restores stored mutations before runApp. Its README maps each screen to what it shows.