Mutations
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.
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<TData, TVariables, TOnMutateResult>. 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 toonErrorand to the result; nothing is thrown at the call site.mutateAsync(vars)returns aFuture<TData>that rejects on failure. Use it when the caller genuinely wants toawaitthe 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:
add.mutate(
'New task',
callbacks: MutateCallbacks<void, String, void>(
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.
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.
// lib/data/device_mutations.dart
MutationOptions<Device, bool, void> setPowerMutation(
QueryClient client,
String id,
) =>
MutationOptions.simple(
mutationFn: (bool on) => devices.setPower(id, on: on),
onSuccess: (device, _, __) {
client.setQueryData<Device>(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
- MutationBuilder
- QueryMixin
- MutationController
class PowerSwitch extends StatelessWidget {
const PowerSwitch({super.key, required this.device});
final Device device;
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,
);
}
}
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,
);
},
);
class _MixinPowerSwitchState extends State<MixinPowerSwitch> with QueryMixin {
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,
);
}
}
class _ControllerPowerSwitchState extends State<ControllerPowerSwitch> {
late final QueryClient _client = QueryClientProvider.read(context);
late final MutationController<Device, bool, void> _power =
MutationController(_client, setPowerMutation(_client, widget.device.id));
void dispose() {
_power.dispose();
super.dispose();
}
Widget build(BuildContext context) =>
ValueListenableBuilder<MutationResult<Device, bool>>(
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 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:
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; 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.
A mutation with neither a mutationFn nor a mutationFnWithContext anywhere
fails with
MissingMutationFunctionError, and is never retried.
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.
Where next
- Invalidations from mutations and updates from mutation responses — making the cache agree with the write.
- Optimistic updates — showing the write before the server confirms it.
- Mutation scopes, cancelling mutations and mutation state.
- Offline, a mutation is paused rather than failed; see network mode.