Mutations & Invalidation
A query reads server data and keeps it in the cache. A mutation changes server data: create, update, delete. Mutations are not cached and not deduplicated; they run when you call them, once per call.
The hard part of a write is not sending it. It is what happens to the cached data afterwards: the list that should now contain the new item, the detail screen that shows the old title. This page is about that part.
The lifecycle
A mutation moves through four states:
MutationIdle ──mutate()──▶ MutationPending ──▶ MutationSuccess
└──▶ MutationFailure
Its callbacks run in a fixed order. Knowing it tells you where each piece of logic belongs:
mutate(variables)
1. onMutate(variables) → returns a context (usually a snapshot)
2. state: MutationPending
3. mutator(variables) → retried retryCount times (default 0)
success:
4. state: MutationSuccess
5. onSuccess(data, variables, context)
6. invalidates, then invalidatesTags
7. onSettled(data, null, variables, context)
failure:
4. state: MutationFailure
5. onError(error, variables, context)
6. onSettled(null, error, variables, context)
The context returned by onMutate is passed to every later callback. That is how a rollback in onError knows what to restore. If onMutate itself throws, the mutation goes straight to MutationFailure: the mutator is not called, and neither is onError.
Three ways to update the cache after a write
| Strategy | What you do | Use it when |
|---|---|---|
| Invalidate | Mark the affected queries stale; they refetch | The default. The server is the source of truth and one extra request is acceptable |
| Write the response | Put the server's response in the cache with setQueryData | The server returns the updated entity, and you want to skip the refetch |
| Optimistic update | Change the cache in onMutate, roll back in onError | The result is predictable and the UI must react instantly, or work offline |
They combine. The most robust pattern is an optimistic update followed by an invalidation: the UI changes immediately, and the refetch replaces your guess with what the server actually stored.
Invalidate
Declare the keys a mutation affects; Qora invalidates them after onSuccess:
QoraMutationBuilder<Todo, String, void>(
mutator: (title) => api.createTodo(title),
options: const MutationOptions(invalidates: [['todos']]),
builder: (context, state, mutate) => AddTodoField(
enabled: !state.isPending,
onSubmit: mutate,
),
)
Invalidating a key marks its data stale. A query that is on screen refetches immediately; meanwhile its state is Loading with the old data as previousData, so the screen can keep showing it. A query that is not on screen refetches the next time a widget uses it.
invalidates matches exact keys. [['todos']] invalidates ['todos'], not ['todos', 42]. To invalidate a whole family, call client.invalidatePrefix(['todos']) in onSuccess, or use tags (providesTags on the queries, invalidatesTags on the mutation). See Query Keys.Write the response
When the server returns the updated entity, put it in the cache instead of refetching it:
options: MutationOptions(
onSuccess: (updated, _, __) async {
client.setQueryData<Todo>(['todos', updated.id], updated);
},
),
Every widget showing ['todos', updated.id] rebuilds with the new value, and no request is made. Lists that contain the item are a separate key: invalidate them, or update them the same way.
Optimistic update
Change the cache before the server answers, keep a snapshot, and restore it on failure:
options: MutationOptions<Todo, Todo, List<Todo>?>(
onMutate: (todo) async {
final previous = client.getQueryData<List<Todo>>(['todos']);
client.setQueryData<List<Todo>>(
['todos'],
[for (final t in previous ?? <Todo>[]) t.id == todo.id ? todo : t],
);
return previous; // becomes `context` in the other callbacks
},
onError: (_, __, previous) async {
client.restoreQueryData<List<Todo>>(['todos'], previous);
},
invalidates: const [['todos']], // reconcile with the server either way
),
restoreQueryData puts the snapshot back, or removes the entry if the snapshot is null. The Optimistic Updates recipe covers lists, multiple keys and concurrent edits.
Mutations and the network
By default a mutation sends its request immediately. While the device is offline that request fails like any network error, and the failure path above runs.
With offlineQueue: true, a mutation called while offline is queued instead: onMutate runs right away, so optimistic updates work offline, and the request is sent when the device reconnects. Offline & Network explains the queue, its order and its limits.
Next Steps
- Offline & Network: paused queries, the mutation queue, reconnect
- Mutations recipe:
QoraMutationBuilder, retries, tags, standalone controllers - Optimistic Updates: patterns for lists and multiple keys
- QoraMutationBuilder: the widget API