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_1u2xg8k: 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 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 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.

  • Transport / HTTP errors and GraphQL errors both reject the mutate future with a SlingException (transport errors with the underlying exception). With MutationBuilder, they surface on state.error instead.
  • 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.

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:

example/lib/screens/launch_screen.dart
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 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.

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'],
);