Mutations
The same idea as queries
Section titled “The same idea as queries”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:
-
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?.favoriterecords:mutation ($launchId: ID!) {toggleFavorite_u4uz0j0fqwhmj: toggleFavorite(launchId: $launchId) {__typenameidfavorite}}idis added becauseLaunchis a keyed type — see Caching. -
Send. One request, immediately — mutations are never batched with queries.
-
Write. The response is normalized exactly like a query response: the payload’s
Launchmerges intoLaunch:launch-181. Every scope that readLaunch: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”. -
Return. The body runs again, this time against the cache, and its return value (
true) is whatmutateresolves to. The payload’s root field is then dropped fromROOT_MUTATIONso 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.schemaAn 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 writes and rollback
Section titled “Optimistic writes and rollback”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.
Try it
Section titled “Try it”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.
Errors
Section titled “Errors”- Network, timeout, HTTP and GraphQL errors all reject the
mutatefuture with aSlingException(SlingNetworkException,SlingHttpException,SlingGraphQLException, …). WithMutationBuilder, they surface onstate.errorinstead. - Mutations are sent once. Unlike queries they are not retried: one that timed out may
have been applied. Pass
retry:(andtimeout:) 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.
Partial errors
Section titled “Partial errors”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.mutaterejects with a partialSlingGraphQLExceptionwhoseerrorshold the server’s errors; withMutationBuilder,mutateresolvesnull, the exception is onstate.errorandstate.datais cleared. The body is not run a second time, andrefetchQueriesare not refetched. - Optimistic writes are undone first. Every write the
optimisticcallback 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
nullthe server puts at an errored path is not a realnull: 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.
Offline mutations
Section titled “Offline mutations”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.isQueuedis true (isLoadingfalse), and the call’s future stays pending. Imperative callers can passonQueued:toclient.mutateto 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 alongSlingClient(mutationQueueBackoff:)(1 s doubling up to 5 min by default). An offline call made while others wait is queued behind them; calls withoutofflineare sent at once as usual. - A replay that fails with something retryable — what
mutationQueueBackoff.retryIfaccepts, by defaultRetryPolicy.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
retryIfdoes not retry) drops the entry, rolls back its optimistic writes and is reported onclient.onQueuedMutationFailed(aQueuedMutationFailure: theQueuedMutationand theSlingException) — the only report a call queued by an earlier run of the app gets.client.queuedMutationslists what waits.
Sign-out
Section titled “Sign-out”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.
Across app restarts
Section titled “Across app restarts”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).
Updating lists after a mutation
Section titled “Updating lists after a mutation”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 onlyme.namedoes not. - Journaled. Inside
optimistic, list edits are rolled back with the rest of the callback’s writes if the mutation fails. Edits afterawait mutate(...)(e.g. prepending the entity ascheduleLaunchcreated,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
falseand 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 returnfalseand 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.
What is in the example
Section titled “What is in the example”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.
refetchQueries
Section titled “refetchQueries”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'],);