Skip to main content

Invalidations from mutations

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:

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 — 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:

// lib/data/device_mutations.dart
MutationOptions<void, String, void> 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:

MutationOptions<Device, String, void> 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(<Future<void>>[
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, 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:

MutationOptions<Device, String, void> 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 demoMutationsmutate, mutateAsync, reset, callbacks, and scopes.~3 MB, runs in your browser; no server involved.

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.

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.

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.

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.