Skip to main content

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 to onError and to the result; nothing is thrown at the call site.
  • mutateAsync(vars) returns a Future<TData> that rejects on failure. Use it when the caller genuinely wants to await the 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.

Live demoMutationsmutate, mutateAsync, reset, callbacks, and scopes.~3 MB, runs in your browser; no server involved.

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.

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,
);
}
}

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.

In React Query

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​