Home/Learn/React/Mutations and Optimistic Updates

Mutations and Optimistic Updates

Advanced
Data

A mutation changes server data and then has to make every affected view agree. Invalidation is the safe default; an optimistic update is what makes an interface feel instant.

Overview

Reads are the easy half. A write has to answer a harder question: after this succeeds, which of the things on screen are now wrong? Invalidating the affected query keys is the correct and boring answer — mark them stale, let them refetch, everything converges. For actions where a round trip is too slow to feel good, an optimistic update writes the expected result into the cache immediately and rolls back if the server disagrees. The rollback is the part people forget, and it is the part that matters.

A Mutation, and Invalidation

Change the data, then say what that invalidated.

mutate, isPending, invalidate
const queryClient = useQueryClient()

const submit = useMutation({
  mutationFn: (payload) => submitSolution(problemId, payload),
  onSuccess: (result) => {
    queryClient.invalidateQueries({ queryKey: ['submissions', problemId] })
    queryClient.invalidateQueries({ queryKey: ['progress'] })
    toast.success('Submitted')
  },
  onError: (err) => toast.error(describe(err)),
})

<button onClick={() => submit.mutate({ code })} disabled={submit.isPending}>
  {submit.isPending ? 'Running…' : 'Submit'}
</button>

// isPending is the disabled state you were tracking by hand, and it
// is what stops a double-click creating two submissions.

// The discipline: after every write, list what is now stale.
// A stale sidebar counter is the classic missed invalidation.

Optimistic Updates

Four callbacks: cancel, snapshot, apply, and roll back on failure.

Cancel, snapshot, apply, revert
const toggle = useMutation({
  mutationFn: ({ id, done }) => setDone(id, done),

  onMutate: async ({ id, done }) => {
    // stop in-flight refetches from overwriting our optimistic value
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previous = queryClient.getQueryData(['todos'])      // snapshot

    queryClient.setQueryData(['todos'], (old) =>
      old.map(t => (t.id === id ? { ...t, done } : t)))       // apply

    return { previous }                                       // context
  },

  onError: (_err, _vars, context) => {
    queryClient.setQueryData(['todos'], context.previous)     // roll back
    toast.error('Could not save — reverted')
  },

  onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})

// Worth it for: toggles, likes, reordering, marking as read.
// Not worth it for: payments, submissions, anything where showing a
// success that then reverses is worse than waiting.

Failure Is Part of the Design

What the user sees when the write does not land, including the awkward cases.

Idempotency, keeping input, unsaved-work warnings
// Retry writes carefully. A GET is safe to retry; a POST that
// creates something is not, unless the server accepts an
// idempotency key.
useMutation({ mutationFn: pay, retry: 0 })
// with a key, retrying is safe:
pay({ amount, idempotencyKey: crypto.randomUUID() })

// Keep the user's input on failure. Clearing a form after a failed
// submit is the single most annoying thing a form can do.
onError: () => { /* leave the form values exactly as they are */ }

// Warn before losing unsaved work
useEffect(() => {
  if (!isDirty) return
  const onLeave = (e) => { e.preventDefault(); e.returnValue = '' }
  window.addEventListener('beforeunload', onLeave)
  return () => window.removeEventListener('beforeunload', onLeave)
}, [isDirty])

// And map validation errors back onto fields rather than showing one
// generic banner — see the forms concepts.

Key Points to Remember

  • 1After a successful write, invalidate every query key whose data that write affected
  • 2isPending gives you the disabled state that prevents double submission
  • 3An optimistic update must cancel in-flight queries, snapshot, apply, and roll back on error
  • 4Use optimistic updates for cheap reversible actions, never for payments or submissions
  • 5Retrying a POST is unsafe without an idempotency key, and a failed submit must never clear the user's input

Interview Questions

Sign in to ask Aria
1

After a successful mutation, how do you make the rest of the UI reflect the change?

Medium
2

What are the steps of a correct optimistic update?

Hard
3

Why is automatically retrying a failed POST risky?

Hard

Ask Aria about Mutations and Optimistic Updates

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…