skip to content

In Apollo Client 4, how does useMutation's optimisticResponse make a closeIssue click show instantly, and what happens to the cache if the server rejects it?

level: middleimportance: should knowfreq 40%

answer

  1. a guess written before the answer
  2. a separate layer over real data
  3. same __typename and id as the entity
  4. the update function runs twice
  5. layer dropped on success and on failure

basics

~20 s

Apollo Client's optimisticResponse writes a guessed result into a separate optimistic cache layer, so the closed row repaints at once. When the server answers or the mutation fails, Apollo drops that layer, keeping the real result or rolling back.

solid answer

~50 s

`optimisticResponse` on `useMutation` is the result you expect, shaped like the mutation's selection set. For `closeIssue` it is `{ closeIssue: { __typename: "Issue", id, status: "CLOSED" } }`. When `mutate` runs, Apollo writes this guess into an **optimistic layer** stacked on the cache instead of the canonical record, and every watching query re-renders from it at once. `__typename` and `id` must match the real entity, or the guess never lands on the `Issue:42` record the list reads. An `update` function runs twice: once against the layer with the guess, and once with the server's result. On success Apollo removes the layer and writes the real result. On failure it removes the layer too, so the row rolls back to `OPEN`, and under the default `errorPolicy` the `mutate` promise rejects. A function form can return `IGNORE` to skip the guess for some calls.

code

tsx · 28 lines
tsx
import { gql, type TypedDocumentNode } from "@apollo/client";
import { useMutation } from "@apollo/client/react";

const CLOSE_ISSUE: TypedDocumentNode<
  { closeIssue: { __typename: "Issue"; id: string; status: "OPEN" | "CLOSED" } },
  { id: string }
> = gql`
  mutation CloseIssue($id: ID!) {
    closeIssue(id: $id) { id status }
  }
`;

export function CloseButton({ id }: { id: string }) {
  const [closeIssue, { error }] = useMutation(CLOSE_ISSUE);

  const onClick = () =>
    closeIssue({
      variables: { id },
      optimisticResponse: { closeIssue: { __typename: "Issue", id, status: "CLOSED" } },
    }).catch(() => {});

  return (
    <>
      <button onClick={onClick}>Close</button>
      {error && <span role="alert">Could not close: {error.message}</span>}
    </>
  );
}

go deeper

for a junior

Know that optimisticResponse shows the expected result before the server replies and that Apollo reverts it by itself if the mutation fails.

for a middle

Explain the optimistic layer, why __typename and id must match the entity, and why update runs twice. Show how a rejected mutation surfaces through error and the rejected mutate promise.

for a senior

Decide which mutations deserve a guess, based on rejection rate and predictability, and handle temporary ids for created items without side effects firing twice.

for a principal

Treat optimistic UI as a product promise: agree on which actions may claim success early, how rollbacks are communicated, and where a spinner is the more honest choice.

## What optimisticResponse is A mutation normally changes the screen only after the server responds. For a `closeIssue` button that means a visible lag between the click and the status change. Apollo Client's **`optimisticResponse`** option on `useMutation` (or on the `mutate` call) removes the lag. You describe the result you expect, and Apollo shows it immediately. The value is either an object shaped exactly like the mutation's response, or a function `(variables, { IGNORE }) => response`. For ```graphql mutation CloseIssue($id: ID!) { closeIssue(id: $id) { id status } } ``` the optimistic response is `{ closeIssue: { __typename: "Issue", id: "42", status: "CLOSED" } }`. The guess stays on the client: the request sent to the server is the same as it would be without it, and only the cache and the screen see the guessed values. ## The lifecycle, step by step 1. You call `closeIssue({ variables: { id: "42" } })`. 2. Apollo writes the guess into an **optimistic layer**, a temporary layer stacked on top of the cache. The canonical `Issue:42` record underneath is not modified. 3. Every query that reads `Issue:42` re-renders from the layer, so the row shows `CLOSED` with no network wait. An optimistic write never triggers a refetch of those queries by itself. 4. If you passed an `update` function, it runs now, against the layer, with the optimistic data. 5. The server answers. Apollo removes the optimistic layer, writes the real result into the canonical cache, and runs `update` again with the real data. 6. If the server's result equals the guess, the user sees no second change. If it differs, the real value replaces the guess. ## Shape rules that make it work - Include **`__typename` and `id`**. The cache uses them to compute the cache ID (`Issue:42`), which is how the guess reaches the same record the list reads. With a different `id` the guess lands on some other record and the row does not change. - Include **every field in the mutation's selection set**. A missing field makes Apollo log a "Missing field" error in development while writing the guess. - Put the payload under the **mutation's root field** (`closeIssue`), exactly as the server would return it. - For an issue that already exists, use its **real id**. A temporary id is only for objects that do not exist yet. ## Creating an issue optimistically For `createIssue` there is no id yet, so the guess uses a placeholder such as `{ __typename: "Issue", id: "temp-1", title, status: "OPEN" }`. Apollo stores it as `Issue:temp-1` inside the optimistic layer. The payload does not insert it into a list, so the same `update` function that appends real issues to `issues` also appends the temporary one during the optimistic run. When the real result arrives, the layer disappears together with `Issue:temp-1` and the reference the optimistic run appended. Then `update` runs again with the real id against the canonical cache. Nothing needs manual cleanup. Until then, the temporary row has no server identity, so actions like "assign" or "comment" should be disabled on it. ## When the server rejects it With the default `errorPolicy` of `none`, a GraphQL error or a network error fails the mutation. Apollo then: - removes the optimistic layer, so every query re-renders from the untouched canonical record and the row shows `OPEN` again; - sets `error` on the hook's result and rejects the promise returned by `mutate`, so code after `await closeIssue(...)` must handle the failure; - does not refetch anything on its own, because the canonical cache was never changed. The rollback is automatic because the guess never replaced real data. It sat in a layer, and removing the layer is the whole undo. ## When not to use it - The server may reject often, for example on permission checks. The row then flickers to `CLOSED` and back, which reads worse than a short spinner. - The result cannot be predicted, for example a server-computed field like `closedAt` or a new issue number. - The action is destructive or irreversible, where showing success before it happened misleads the user. - The screen already shows a clear pending state, such as a disabled button in a modal, where a short wait costs nothing. Skipping the guess for some calls does not need a second hook. Pass a function and return the `IGNORE` sentinel from its second argument when the guess should not apply.

  • How do you skip the optimistic write for some calls of the same mutation?
    Pass `optimisticResponse` as a function: `(variables, { IGNORE }) => ...`. Return the response object when a guess makes sense, and return the `IGNORE` sentinel when it does not, for example when closing an issue that needs a server-side resolution check. Returning `IGNORE` means no optimistic layer is written for that call.
  • Why does the update function run twice, and what does that require of it?
    Apollo runs `update` once inside the optimistic layer with the guessed data, and again against the canonical cache with the server's result. The first run's writes disappear with the layer. So `update` must work with either payload, for example with a temporary id, and must not cause side effects outside the cache, such as analytics calls or toasts, that would fire twice.

Like pencilling a change onto tracing paper laid over a ledger: everyone reads the ledger through the sheet, and when the real entry is inked or refused, the sheet is simply lifted off.

saying these in an interview costs you the question

  • optimisticResponse overwrites the cached issue, so a failure needs a manual rollback.
  • The optimistic guess can omit id because Apollo matches it to the entity by position.
  • The update function runs only once, after the server responds.
  • Closing an existing issue optimistically needs a temporary id.
  • An optimistic update makes every query that reads the issue refetch from the server.