Implement optimistic UI mutations with rollback safety
A frontend skill for optimistic mutations - the cancel/snapshot/patch/rollback/invalidate lifecycle with cache coherence.
Why it matters
Ensure every write operation in your React application feels instant to users while maintaining data consistency across all UI surfaces, with automatic rollback on failure and idempotent retry logic that prevents duplicate actions.
Outcomes
What it gets done
Cancel in-flight queries and snapshot all affected cache entries before patching UI state
Apply optimistic updates to detail views and every list page simultaneously for cache coherence
Roll back to exact prior state verbatim when server errors occur, never reconstructing manually
Generate idempotency keys at form initialization to ensure retries replay responses instead of duplicating actions
Install
Add it to your toolbox
Run in your project directory:
curl -fsSL https://spark.entire.vc/get/ag-frontend-optimistic-mutations | bash Overview
Frontend Optimistic Mutations (the write path)
This skill codifies the optimistic mutation lifecycle for React/React Native: cancel, snapshot, patch, rollback, and invalidate, with idempotency and lock-step cache coherence across detail and list views. Use it when implementing or reviewing mutations that need to feel instant, fail safely, and keep multiple caches coherent.
What it does
A portable discipline for the write path of any React/React Native app using a query/cache layer, building on frontend-data-contracts (writes go through the typed client) and frontend-architecture (mutations live in modules/{feature}/hooks/). Five core ideas: the optimistic lifecycle is fixed at five beats - cancel in-flight queries, snapshot every affected cache, patch instantly, roll back verbatim on error, invalidate on settle; rollback restores the exact prior snapshot from mutation context, never a re-derived guess; an idempotency key is generated once at form-init or first intent (not inside mutationFn, and not regenerated per retry) so a network retry replays the original server response instead of double-performing the action; caches move in lock-step - a status change patches the detail cache and every list page containing the entity together, so surfaces never disagree; and server state never leaves the query cache into a client store. A decision table sets when to be optimistic: high-confidence low-conflict writes (toggle status, like, mark-paid, reorder) go fully optimistic; creates returning server-generated ids/totals use a pending state seeded from the server response instead; destructive or hard-to-reverse writes (cascading delete, sending money) confirm first, never silent-optimistic; and writes with invisible results (background jobs) use pending-plus-toast with no optimistic patch. The reference TanStack Query shape shows onMutate canceling queries then snapshotting and patching both the detail cache and every list page, onError restoring both snapshots verbatim before surfacing the typed ApiError, and onSettled invalidating both regardless of success or failure so server-computed fields (timestamps, totals) become authoritative again. Retry policy is network-only and bounded (error.isNetworkError && count < 2), never retrying on 4xx, and 409 conflicts surface rather than retry so the user re-decides on fresh data. Library adapters cover TanStack Query's onMutate/onError/onSettled trio, RTK Query's updateQueryData patchResult with an undo() in catch plus invalidatesTags, and SWR's mutate() with rollbackOnError and populateCache - the five-beat lifecycle stays constant across all three, and React Native works unchanged since the cache is the source of truth there too.
When to use - and when NOT to
Use it when implementing or reviewing mutations in a React/React Native app with a query/cache layer - deciding whether a write should be optimistic, implementing the cancel/snapshot/patch/rollback/invalidate lifecycle, making a write safely retryable, or keeping detail and list caches coherent after a change.
Inputs and outputs
Given a mutation and the set of caches an entity appears in, it produces a hook implementing the five-beat lifecycle: an onMutate patch with snapshots, an onError rollback, and an onSettled invalidation, wired to the typed apiClient and ApiError from frontend-data-contracts.
Integrations
onMutate: async ({ id }) => { await queryClient.cancelQueries({ queryKey: invoiceKeys.all }); /* snapshot + patch */ }
Works with TanStack Query, RTK Query, or SWR as the underlying cache library, and pairs with the typed apiClient and ApiError from the frontend-data-contracts skill for the actual network call.
Who it's for
Frontend teams implementing writes that need to feel instant, fail safely with exact rollback, and keep every cache (detail plus all list pages) coherent - avoiding the most common write-path bugs: race-clobbered optimistic state from a missing cancelQueries, partial cache patches that leave surfaces disagreeing, and idempotency keys generated inside mutationFn that no longer protect retries.
Source README
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 inmodules/{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, list]); // (2) snapshot each page
if (!list.invoices.some((i) => i.id === id)) continue;
queryClient.setQueryData<InvoiceListResponse>(key, {
// (3) patch matching row
...list,
invoices: list.invoices.map((i) =>
i.id === id ? { ...i, status: InvoiceStatus.Paid } : i,
),
});
}
return { previousInvoice, previousLists };
},
// 4: roll back verbatim
onError: (error, { id }, ctx) => {
if (ctx?.previousInvoice)
queryClient.setQueryData(invoiceKeys.detail(id), ctx.previousInvoice);
for (const [key, list] of ctx?.previousLists ?? [])
queryClient.setQueryData(key, list);
notifyError(error);
},
// 5: invalidate so authoritative server state (paidAt, aggregates) refetches
onSettled: (_d, _e, { id }) => {
void queryClient.invalidateQueries({ queryKey: invoiceKeys.detail(id) });
void queryClient.invalidateQueries({ queryKey: invoiceKeys.lists() });
},
});
}
Why each beat:
- cancel - without it, a query that was already in flight can resolve after your patch and overwrite the optimistic state.
- snapshot - the only safe rollback source; never reconstruct prior state by hand.
- patch - the instant UX; mutate detail and lists together (§4).
- roll back - restore snapshots verbatim, then surface the typed
ApiError. - invalidate on settle - success or failure, refetch so server-computed fields (timestamps, totals) are authoritative. Settle, not just success: a failed write may still have changed server state.
3. Non-optimistic writes: create with server-owned fields
A create that returns an id/number/total can't be fully optimistic. Run it as a pending mutation and
seed the cache from the response.
export function useCreateInvoice() {
const queryClient = useQueryClient();
return useMutation<Invoice, ApiError, CreateInvoiceInput>({
mutationFn: ({ document, idempotencyKey }) =>
apiClient.post<Invoice>("/invoices", document, { idempotencyKey }),
onSuccess: (invoice) => {
queryClient.setQueryData(invoiceKeys.detail(invoice.id), invoice); // seed detail
void queryClient.invalidateQueries({ queryKey: invoiceKeys.lists() }); // refresh lists
},
onError: (error) => notifyError(error),
});
}
4. Cache coherence (lock-step detail + lists)
A single entity appears in many caches: its detail, and every filtered/paginated list page. An
optimistic patch must touch all of them or surfaces disagree. Use a hierarchical key factory
(from frontend-architecture §4.3) so you can target precisely.
export const invoiceKeys = {
all: ["invoices"] as const,
lists: () => [...invoiceKeys.all, "list"] as const,
list: (p: IListParams) => [...invoiceKeys.lists(), p] as const,
detail: (id: InvoiceId) => [...invoiceKeys.all, "detail", id] as const,
} as const;
getQueriesData({ queryKey: invoiceKeys.lists() })enumerates every cached list page so you can patch each.invalidateQueries({ queryKey: invoiceKeys.lists() })refreshes them all on settle.detail(id)targets exactly one entity.
Snapshot each page you touch (keyed by its exact query key) so rollback restores every page
verbatim, not just the one currently on screen.
5. Idempotency (safe retries on money-moving writes)
A retried POST must not perform the action twice. Generate the key once, at form init (or first
user intent), carry it through retries, and let the client send it as a header. The server replays
the original response for a repeated key within its window.
// at form initialisation — stable for the lifetime of this attempt
const idempotencyKey = useMemo(() => crypto.randomUUID(), []);
// mutation forwards it; the typed client puts it on the header
apiClient.post<Invoice>("/invoices", document, { idempotencyKey });
Hard rules:
- Generate the key at intent time, not inside
mutationFn(which re-runs per retry → defeats the purpose). - The data client auto-detects financial routes and injects the header; an explicit key always wins so retries replay.
- Pair idempotency with disabled-while-pending UI so the user can't fire a second distinct write.
6. Retry policy
- Reads: retry a few times with backoff (default in most query libs) - safe and idempotent.
- Writes: do not auto-retry non-idempotent mutations. Retry only when an idempotency key guarantees replay, or only on network errors (status 0), never on 4xx.
- Conflicts (409): don't retry - surface the typed error, invalidate, and let the user re-decide on fresh data.
useMutation({
retry: (count, error: ApiError) => error.isNetworkError && count < 2, // network-only, bounded
});
7. Library adapters
The five-beat lifecycle is the same; the hooks differ.
| Library | Optimistic mechanism |
|---|---|
| TanStack Query | onMutate (cancel + snapshot + patch) → onError (rollback) → onSettled (invalidate). The reference shape above. |
| RTK Query | onQueryStarted: updateQueryData returns a patchResult; await queryFulfilled and call patchResult.undo() in catch. invalidatesTags on settle. |
| SWR | mutate(key, optimisticData, { rollbackOnError: true, populateCache, revalidate: true }) - optimistic data + automatic rollback + revalidate. |
For React Native, all three libraries work unchanged; the cache is the source of truth on
native too. Keep mutation hooks DOM-free so they're shareable across web and native.
8. Conventions checklist (enforce in review)
- Optimistic mutations follow cancel → snapshot → patch → rollback → invalidate.
-
onMutatecancels in-flight queries before patching. - Rollback restores the exact snapshot from context, not a re-derivation.
- Detail and every affected list page are patched and snapshotted together.
-
onSettledinvalidates so server-computed fields are refetched (on success and error). - Idempotency key is generated at intent time and replayed across retries, not regenerated per attempt.
- Money-moving / destructive writes confirm first and disable the trigger while pending.
- Non-idempotent writes don't auto-retry; 409s surface rather than retry.
- Server state stays in the query cache - never copied into a client store.
- Query keys come from a hierarchical factory; invalidation is scoped, not global-blunt.
9. How to apply this skill
Adding an optimistic mutation: decide it's safe to be optimistic (§1). Write the five beats
(§2). Identify every cache the entity lives in and patch/snapshot all of them (§4).
Making a write safe to retry: generate an idempotency key at form init, thread it through the
mutation, confirm the client sends it (§5), and set a network-only bounded retry (§6).
Debugging a flicker / wrong-state-after-write: check that onMutate cancels queries (late
refetch clobber) and that onSettled invalidates (stale server-computed fields). Check that all
list pages were patched, not just the visible one.
Reviewing the write path: run the checklist in §8. The highest-value catches are missingcancelQueries (race clobber), partial cache patches (detail/list disagreement), and idempotency
keys generated inside mutationFn (no longer protect retries).
Publishing / installing this skill
This skill follows the Anthropic SKILL.md format and is portable across agents.
- Keep it under
skills/frontend-optimistic-mutations/SKILL.mdin a public GitHub repo. - Keep the frontmatter
nameand high-signaldescription- discovery indexes match against it. - Install with:
npx skills add <org>/<repo> --skill "frontend-optimistic-mutations". - Non-
SKILL.mdagents can be pointed here fromAGENTS.md/CLAUDE.md; Kiro can mirror it as a steering file.
Limitations
- Use this skill only when the task clearly matches its upstream source and local project context.
- Verify commands, generated code, dependencies, credentials, and external service behavior before applying changes.
- Do not treat examples as a substitute for environment-specific tests, security review, or user approval for destructive or costly actions.
FAQ
Common questions
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.