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_1u2xg8k: 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 the generated slingSchema constant (or mutationRoot: for the
root factory alone) alongside client::
SlingScope<Query>( client: client, schema: slingSchema, // generated: SlingSchema(query: Query.root, mutation: Mutation.root) child: const App(),)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. state.isLoading is true while the request is in flight. 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.
When the mutation succeeds, the response simply overwrites the optimistic value with the server’s.
Errors
Section titled “Errors”- Transport / HTTP errors and GraphQL errors both reject the
mutatefuture with aSlingException(transport errors with the underlying exception). WithMutationBuilder, they surface onstate.errorinstead. - Partial GraphQL errors (data and errors) still write the fields that resolved, prune
the
nulls at errored paths, then reject. - Mutations have no sticky-error behaviour — every call is a fresh request.
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. 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 also prepends/removes the launch
in me.favorites and adjusts me.favoriteCount, 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:
await client.mutateWith( Mutation.root, (m) => m.scheduleLaunch(name: name)?.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(name: name)?.id, refetchQueries: ['launches'],);