Frontend Optimistic Mutations (the write path)
When to Use
Use this skill when you need a portable, framework-agnostic discipline for the write path of any React or React Native app using a query/cache layer. Codifies the optimistic-update lifecycle (cancel in-flight queries → snapshot every affected cache → patch instantly → roll back verbatim on error → invalidate on...
Portable skill — readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others.
This skill describes the discipline of the write path — optimistic updates, rollback,
idempotency, cache coherence — not a UI library or a styling system. It builds directly on the
frontend-data-contracts skill (writes go through the typed client) and the
frontend-architecture skill (mutations live in modules/{feature}/hooks/, keyed by a factory).
The goal: a write feels instant (the UI reflects it before the server confirms), is safe
(a failure restores the exact prior state, and a retry never double-charges), and leaves the cache
coherent (the detail view and every list page agree). All three at once — that's the craft.
0. The five core ideas
- The optimistic lifecycle is fixed. cancel → snapshot → patch → (error: roll back) → (settle: invalidate). Every optimistic mutation follows the same five beats.
- Roll back verbatim. On failure, restore the exact snapshot taken before the patch — not a "best guess" re-derivation. Keep the snapshot in mutation context.
- Idempotency is generated once, not per attempt. The key is created at form init (or first intent), so a network retry replays the original server response instead of performing the action twice.
- Caches move in lock-step. A status change patches the detail cache and every list page that contains the entity, so badges never disagree across surfaces.
- Server state never enters the client store. Optimistic state lives in the query cache, not Zustand/Redux. The cache is the single source of truth for server data (per frontend-architecture §4).
1. When to be optimistic (and when not)
| Situation |
Strategy |
| High-confidence, low-conflict write (toggle status, like, mark-paid, reorder) |
Optimistic — patch immediately, roll back on error. |
| Create that returns a server-generated id/number/total |
Pending state, then setQueryData from the server response. A temporary optimistic row is optional; reconcile on success. |
| Destructive or hard-to-reverse write (delete with cascade, send money) |
Confirm first, then optimistic or pending — never silent-optimistic. |
| Write whose result the user can't see yet (background job) |
Pending + toast, invalidate when done. No optimistic patch. |
Optimism is a UX tool for writes you're confident will succeed. If failure is common or expensive to
undo, prefer a pending state.
2. The optimistic lifecycle (TanStack Query)
The canonical shape. Each beat has a job; skipping one breaks correctness.
// modules/invoice/hooks/useInvoiceMutations.ts
interface MarkPaidContext {
previousInvoice: Invoice | undefined; // detail snapshot
previousLists: Array<[readonly unknown[], InvoiceListResponse]>; // every list page snapshot
}
export function useMarkInvoicePaid() {
const queryClient = useQueryClient();
const notifyError = useApiErrorToast();
return useMutation<Invoice, ApiError, { id: InvoiceId }, MarkPaidContext>({
mutationFn: ({ id }) => apiClient.post<Invoice>(INVOICE_API.markPaid(id)),
// 1 + 2 + 3: cancel in-flight reads, snapshot, patch
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: invoiceKeys.all }); // (1) no late refetch clobber
const detailKey = invoiceKeys.detail(id);
const previousInvoice = queryClient.getQueryData<Invoice>(detailKey); // (2) snapshot detail
if (previousInvoice) {
queryClient.setQueryData<Invoice>(detailKey, {
// (3) patch detail
...previousInvoice,
status: InvoiceStatus.Paid,
});
}
const previousLists: MarkPaidContext["previousLists"] = [];
for (const [key, list] of queryClient.getQueriesData<InvoiceListResponse>(
{
queryKey: invoiceKeys.lists(),
},
)) {
if (!list) continue;
previousLists.push([key, li
1---2name: frontend-optimistic-mutations3description: A portable, framework-agnostic discipline for the write path of any React or React Native app using a query/cache layer.4---5
6
7# Frontend Optimistic Mutations (the write path)
8## When to Use
9
10Use this skill when you need a portable, framework-agnostic discipline for the write path of any React or React Native app using a query/cache layer. Codifies the optimistic-update lifecycle (cancel in-flight queries → snapshot every affected cache → patch instantly → roll back verbatim on error → invalidate on...
11
12
13> Portable skill — readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others.
14> This skill describes the **discipline of the write path** — optimistic updates, rollback,
15> idempotency, cache coherence — not a UI library or a styling system. It builds directly on the
16> **frontend-data-contracts** skill (writes go through the typed client) and the
17> **frontend-architecture** skill (mutations live in `modules/{feature}/hooks/`, keyed by a factory).
18
19The goal: a write **feels instant** (the UI reflects it before the server confirms), is **safe**
20(a failure restores the exact prior state, and a retry never double-charges), and leaves the cache
21**coherent** (the detail view and every list page agree). All three at once — that's the craft.
22
23---
24
25## 0. The five core ideas
26
271. **The optimistic lifecycle is fixed.** cancel → snapshot → patch → (error: roll back) → (settle: invalidate). Every optimistic mutation follows the same five beats.
282. **Roll back verbatim.** On failure, restore the exact snapshot taken before the patch — not a "best guess" re-derivation. Keep the snapshot in mutation context.
293. **Idempotency is generated once, not per attempt.** The key is created at form init (or first intent), so a network retry replays the original server response instead of performing the action twice.
304. **Caches move in lock-step.** A status change patches the detail cache **and** every list page that contains the entity, so badges never disagree across surfaces.
315. **Server state never enters the client store.** Optimistic state lives in the query cache, not Zustand/Redux. The cache is the single source of truth for server data (per frontend-architecture §4).
32
33---
34
35## 1. When to be optimistic (and when not)
36
37| Situation | Strategy |
38| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
39| High-confidence, low-conflict write (toggle status, like, mark-paid, reorder) | **Optimistic** — patch immediately, roll back on error. |
40| Create that returns a server-generated id/number/total | **Pending state**, then `setQueryData` from the server response. A temporary optimistic row is optional; reconcile on success. |
41| Destructive or hard-to-reverse write (delete with cascade, send money) | **Confirm first**, then optimistic _or_ pending — never silent-optimistic. |
42| Write whose result the user can't see yet (background job) | **Pending + toast**, invalidate when done. No optimistic patch. |
43
44Optimism is a UX tool for writes you're confident will succeed. If failure is common or expensive to
45undo, prefer a pending state.
46
47---
48
49## 2. The optimistic lifecycle (TanStack Query)
50
51The canonical shape. Each beat has a job; skipping one breaks correctness.
52
53```ts
54// modules/invoice/hooks/useInvoiceMutations.ts
55interface MarkPaidContext {
56 previousInvoice: Invoice | undefined; // detail snapshot
57 previousLists: Array<[readonly unknown[], InvoiceListResponse]>; // every list page snapshot
58}
59
60export function useMarkInvoicePaid() {
61 const queryClient = useQueryClient();
62 const notifyError = useApiErrorToast();
63
64 return useMutation<Invoice, ApiError, { id: InvoiceId }, MarkPaidContext>({
65 mutationFn: ({ id }) => apiClient.post<Invoice>(INVOICE_API.markPaid(id)),
66
67 // 1 + 2 + 3: cancel in-flight reads, snapshot, patch
68 onMutate: async ({ id }) => {
69 await queryClient.cancelQueries({ queryKey: invoiceKeys.all }); // (1) no late refetch clobber
70
71 const detailKey = invoiceKeys.detail(id);
72 const previousInvoice = queryClient.getQueryData<Invoice>(detailKey); // (2) snapshot detail
73 if (previousInvoice) {
74 queryClient.setQueryData<Invoice>(detailKey, {
75 // (3) patch detail
76 ...previousInvoice,
77 status: InvoiceStatus.Paid,
78 });
79 }
80
81 const previousLists: MarkPaidContext["previousLists"] = [];
82 for (const [key, list] of queryClient.getQueriesData<InvoiceListResponse>(
83 {
84 queryKey: invoiceKeys.lists(),
85 },
86 )) {
87 if (!list) continue;
88 previousLists.push([key, li