Skip to content

Mutations

A mutation in sling_gql is a function that reads the fields it wants back:

final favorite = await client.mutate(
(m) => m.toggleFavorite(launchId: id)?.favorite,
);

client.mutate is an extension the generator emits for your schema (it binds the Mutation root type); the runtime primitive is client.mutateWith(Mutation.root, body).

The body runs twice:

  1. Record. The body is run against an empty recorder. Every field it touches is added to the selection; nothing is fetched on a miss. toggleFavorite(launchId:) with ?.favorite records:

    mutation ($launchId: ID!) {
    toggleFavorite_u4uz0j0fqwhmj: toggleFavorite(launchId: $launchId) {
    __typename
    id
    favorite
    }
    }

    id is added because Launch is a keyed type — see Caching.

  2. Send. One request, immediately — mutations are never batched with queries.

  3. Write. The response is normalized exactly like a query response: the payload’s Launch merges into Launch:launch-181. Every scope that read Launch:launch-181.favorite — the list row, the detail screen, anything — is notified and rebuilds. No refetch, no manual cache update, no “which queries do I invalidate”.

  4. Return. The body runs again, this time against the cache, and its return value (true) is what mutate resolves to. The payload’s root field is then dropped from ROOT_MUTATION so it does not pin entities in memory.

MutationBuilder — the useMutation equivalent

Section titled “MutationBuilder — the useMutation equivalent”
MutationBuilder<Mutation>(
builder: (context, mutate, state) => CupertinoButton(
onPressed: state.isLoading
? null
: () => mutate(
(m) => m.toggleFavorite(launchId: id)?.favorite,
optimistic: () => launch.favorite = !favorite,
),
child: Icon(favorite ? CupertinoIcons.heart_fill : CupertinoIcons.heart),
),
)

MutationBuilder needs no root of its own: it resolves Mutation.root from the nearest SlingScope, which takes it from the client built with the generated slingSchema constant — or from its own schema::

final client = SlingClient<Query>(endpoint: uri, schema: slingSchema);
SlingScope<Query>(client: client, child: const App()) // roots from client.schema

An explicit root: Mutation.root on MutationBuilder still works and always wins over the scope — useful for a widget wired to a different schema than the one above it.

mutate returns Future<T?>: the body’s value on success, null on failure with the error on state.error. A failure never throws, so tell it apart from a null result by state.error (or call client.mutate to get the exception). state.isLoading is true while the request is in flight. state.data holds the value of the last call that succeeded; it stays while a new call is loading and is cleared by a failure. It is Object? because each call may return a different type, so cast it or use the awaited value. When calls overlap, the state describes the latest one, and an older call finishing late never overwrites it. The widget does not rebuild on data changes — it has nothing to do with data; the QueryBuilder around it does that, because the accessor launch it reads is bound to the query scope.

optimistic runs synchronously before the request. Inside it you use the generated setters on any accessor you already hold:

optimistic: () => launch.favorite = !favorite,

That is a normal cache write: it lands on the Launch:launch-181 entity, so every copy updates in the same frame, before the network answers. The client journals each write with the previous value (CacheWrite). If the mutation fails — transport error, or errors[] in the response — the journal is undone in reverse order and the affected scopes rebuild. A field that was never fetched goes back to missing (not null), so it is re-fetched rather than shown as empty. On a partial failure the rollback runs before the fields that did resolve are written, so it never overwrites a value the server confirmed (see Partial errors).

When the mutation succeeds, the response simply overwrites the optimistic value with the server’s.

example/lib/demos/optimistic_demo.dart
class FavoriteButton extends StatelessWidget {
const FavoriteButton({super.key, required this.launch, this.favorite});
final Launch launch;
final bool? favorite;
@override
Widget build(BuildContext context) {
final id = launch.id;
return MutationBuilder<Mutation>(
builder: (context, mutate, state) => Row(
children: [
Text(switch (state) {
MutationState(isLoading: true) => 'sending…',
MutationState(error: != null) => 'failed: rolled back',
MutationState(data: != null) => 'confirmed',
_ => '',
}),
CupertinoButton(
onPressed: id == null || favorite == null || state.isLoading
? null
: () => mutate(
(m) => m.toggleFavorite(launchId: id)?.favorite,
// Written to the cache now, undone if the server fails.
optimistic: () => launch.favorite = !favorite!,
),
child: Icon(
favorite == true
? CupertinoIcons.heart_fill
: CupertinoIcons.heart,
color: kColorCoral,
),
),
],
),
);
}
}

The server takes 1.5 s to answer, so each step stays on screen long enough to see.

  • Server succeeds: tap the heart. It fills at once, sending…, then confirmed. The line below is another widget reading the same launch: it changes in the same frame, and nobody told it to.
  • Server fails: tap again. The heart flips, and when the error comes back it flips back on its own (failed: rolled back), and so does the other widget.
  • Network, timeout, HTTP and GraphQL errors all reject the mutate future with a SlingException (SlingNetworkException, SlingHttpException, SlingGraphQLException, …). With MutationBuilder, they surface on state.error instead.
  • Mutations are sent once. Unlike queries they are not retried: one that timed out may have been applied. Pass retry: (and timeout:) per call for the ones safe to repeat — see Transport § Retry.
  • Partial GraphQL errors (data and errors) are failures too, but the cache keeps what the server resolved — see below.
  • Mutations have no sticky-error behaviour — every call is a fresh request.

A response with both data and errors counts as a failed mutation under the default errorPolicy (ErrorPolicy.none); the semantics are:

  • The call fails. client.mutate rejects with a partial SlingGraphQLException whose errors hold the server’s errors; with MutationBuilder, mutate resolves null, the exception is on state.error and state.data is cleared. The body is not run a second time, and refetchQueries are not refetched.
  • Optimistic writes are undone first. Every write the optimistic callback made (setters, list edits) is rolled back, exactly as for a total failure.
  • Then the resolved fields are written. What the server did resolve is normalized into the cache over the rolled-back state, so a field the server confirmed shows the server’s value even if the optimistic callback wrote it too. Every widget reading a changed field rebuilds once, for the rollback and the resolved fields together.
  • Errored paths are pruned. The null the server puts at an errored path is not a real null: the field keeps its pre-mutation value (or stays missing).
  • Nothing is pinned. The payload’s root field is dropped from ROOT_MUTATION, as on success.

To read what did resolve, go through the entities: client.cacheScope.launch(id)?.favorite. A transport error, an HTTP error or a response without data writes nothing — only the rollback applies.

With errorPolicy: ErrorPolicy.all or ErrorPolicy.ignore (per call, or SlingClient(errorPolicy:)) a partial response counts as landed instead: it is written as sent (nulls at errored paths included), the optimistic writes stay, refetchQueries run and the body computes the value. ignore returns it; all throws the SlingGraphQLException with the value in e.data — and MutationBuilder then shows both state.data and state.error. See errorPolicy.

Pass offline: true and a mutation the server cannot be reached for is queued instead of failing — the user’s action is kept and sent when the network comes back:

mutate(
(m) => m.toggleFavorite(launchId: id)?.favorite,
optimistic: () => launch.favorite = !favorite,
offline: true,
);
// MutationBuilder / useSlingMutation: state.isQueued while it waits
  • What queues it: on the first send, only a failure that means no response — SlingException.isNetworkUnreachable (SlingNetworkException: offline, DNS, connection refused). A timeout, an HTTP error or a GraphQL error fails the call as usual (rolled back, rejected): the server answered, or may have applied it.
  • While queued the optimistic writes stay applied, state.isQueued is true (isLoading false), and the call’s future stays pending. Imperative callers can pass onQueued: to client.mutate to learn it was queued.
  • The future’s outcome is the replay’s: the body’s value once the response landed, or the error the server answered with (optimistic writes rolled back). If the client is disposed (or the app killed) first, it never completes — the queue outlives it, see below.
  • Replay sends the queue in order, one at a time: after the next request of any kind that reaches the server (a query, another mutation, a subscription event), on client.replayQueue() (call it when the app knows it is back online), and on timers that back off along SlingClient(mutationQueueBackoff:) (1 s doubling up to 5 min by default). An offline call made while others wait is queued behind them; calls without offline are sent at once as usual.
  • A replay that fails with something retryable — what mutationQueueBackoff.retryIf accepts, by default RetryPolicy.isTransient: network errors, timeouts, HTTP 5xx — stays queued (optimistic writes and stored entry included) and backs off.
  • A replay the server rejects (HTTP 4xx, GraphQL errors, credentials refused after a refresh: anything retryIf does not retry) drops the entry, rolls back its optimistic writes and is reported on client.onQueuedMutationFailed (a QueuedMutationFailure: the QueuedMutation and the SlingException) — the only report a call queued by an earlier run of the app gets. client.queuedMutations lists what waits.

Replays carry the headers of the moment they are sent — SlingClient(headers:) and auth: — not those of the call. A mutation queued by one user and replayed after the next one signed in would run as the new user. Sign-out must clear the queue:

Future<void> signOut() async {
await client.clearMutationQueue(); // rolls back, fails the futures, empties the store
client.cancelQueries(); // drops the responses still in flight
await persistence.clear(); // with sling_gql_sqflite: cache + stored queue
}

cancelQueries() aborts the query batches in flight: a response requested with the previous user’s headers would otherwise land in the cleared cache. Call it once the credentials are gone: the scopes that waited run again and fetch with the headers of that moment.

clearMutationQueue() rolls back every queued call’s optimistic writes (newest first, from the stored undo log for calls restored from an earlier run), fails their futures with a SlingCancelledException (MutationBuilder: state.error; nothing on onQueuedMutationFailed), aborts a replay in flight and ignores its response (the server may still have applied it), and empties mutationQueue.

The queue lives in SlingClient(mutationQueue:), a MutationQueueStore: in memory by default, in SQLite with persistence.mutationQueue. An offline call is stored from the moment it is sent until it lands or fails for good, as a QueuedMutation: the printed document, its variables as they were at the call, and the undo log of its optimistic writes. A client created on a store that holds entries replays them first.

What a restart does with optimistic writes is defined by that undo log:

  • the persisted cache may hold the optimistic values (it is saved behind, the queue entry is written before it); they stay on screen while the call waits;
  • if the replay lands, its response is written over them as usual;
  • if the server rejects it, the stored log rolls them back — even though the closure that made them is gone.

The body is not run again for a restored call (there is no caller to return to); its response is cached like any other, except when the document renames fields (QueuedMutation.renamesFields: fragments selecting one field twice, merging field policies), whose mapping back lives in the lost selection — the optimistic values then stand until a query refetches them.

A mutation without offline that is in flight when the app is killed is not stored: its optimistic values may be in the persisted cache and stay until a response for those fields overwrites them (with maxAge, restored data is revalidated on first read).

Because the cache is normalized, a mutation returning an existing entity updates every list it appears in for free. What the response cannot say is that a list gained or lost a row: after toggleFavorite, Launch:launch-181.favorite is right everywhere, but me.favorites still holds the same refs.

When membership follows from the entity (me.favorites holds a launch iff launch.favorite), declare it once as a list rule: the client moves the row for the optimistic write, for the response and for the rollback. That is what the example does. Otherwise, edit the list yourself, through the typed client.cacheScope:

mutate(
(m) => m.toggleFavorite(launchId: id)?.favorite,
optimistic: () {
launch.favorite = !wasFavorite;
final cache = client.cacheScope;
final me = cache.query.me;
if (me == null || me.isSkeleton) return; // Me tab not loaded: it will fetch fresh
final favorites = cache.list((q) => q.me?.favorites);
wasFavorite ? favorites.remove(launch) : favorites.prepend(launch);
},
);

cache.list(selector) returns a CacheList for the list field the selector reads — written exactly as in a widget, arguments included ((q) => q.launches(first: 20).nodes), through objects, entities or lookups ((q) => q.launch(id: x)?.crew). It has append, prepend, remove and contains, each taking a generated accessor for a cached entity:

  • Same semantics as a response. An edit writes the new list of refs back through the normal write path, so it touches the same dependency key a response replacing that list would (Viewer:viewer-1.favorites): the Me tab rebuilds, a widget reading only me.name does not.
  • Journaled. Inside optimistic, list edits are rolled back with the rest of the callback’s writes if the mutation fails. Edits after await mutate(...) (e.g. prepending the entity a scheduleLaunch created, cache.launch(newId)!) are simply permanent.
  • Set-like. Adding an entity already in the list, or removing one that is not, is a no-op that returns false and notifies nobody.
  • Only cached lists. A list that was never fetched (or is null) is left alone — adding to it would make a one-element list look complete — so edits return false and the next build fetches the real thing.

Deleting: cache.evict(launch) drops Launch:<id> from the cache and from every list that referenced it, blanks object fields pointing at it (they are re-fetched), and rebuilds every scope that read any of those fields. Evictions are not journaled — evict after the mutation succeeded, not in optimistic.

The detail screen’s heart is a MutationBuilder; the list row reads launch.favorite so it depends on Launch:<id>.favorite. Its optimistic callback flips launch.favorite and adjusts me.favoriteCount; the favorites list rule (example/lib/list_rules.dart) adds or removes the row in me.favorites, so the Me tab follows without a refetch. example/test/app_test.dart asserts, against the real mock server: one request for the mutation, the optimistic state before the response, the confirmed state after, the list row updated on the way back, and the Me tab gaining (then losing) the row — all with no extra request.

Updating lists after a mutation covers what a normalized cache cannot fix by itself — a row a list should gain, or one it should lose — by editing the list. refetchQueries is the simple, always-available alternative that asks the server instead, and the right one for paginated or filtered lists:

final input = ScheduleLaunchInput(name: name, rocketId: rocketId, launchpadId: padId, date: date);
await client.mutate(
(m) => m.scheduleLaunch(input: input)?.id,
refetchQueries: ['launches'],
);

Names are root query field names — 'me', 'launches' — not aliases, so arguments and aliasing do not matter: refetchQueries: ['launches'] matches every live scope that selected a launches field, however it was paginated or filtered. After the mutation’s response has been written and every scope notified as usual, each matching scope is refetch()ed.

The refetches are fire-and-forget: mutateWith/mutate resolves as soon as the mutation itself lands, it does not wait for the refetches too. Their errors surface the normal way, on the affected scopes’ state.error — not on the mutation’s catch/state.error. If the mutation itself fails, no refetch happens at all.

MutationBuilder’s mutate takes the same parameter:

mutate(
(m) => m.scheduleLaunch(input: input)?.id,
refetchQueries: ['launches'],
);