Core Concepts

Mutations & Invalidation

How writes run, in which order their callbacks fire, and the three ways to bring the cache back in line with the server afterwards.

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

StrategyWhat you doUse it when
InvalidateMark the affected queries stale; they refetchThe default. The server is the source of truth and one extra request is acceptable
Write the responsePut the server's response in the cache with setQueryDataThe server returns the updated entity, and you want to skip the refetch
Optimistic updateChange the cache in onMutate, roll back in onErrorThe 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

Copyright © 2026