bundled-skills/frontend-optimistic-mutations/SKILL.md
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...
npx skillsauth add FrancoStino/opencode-skills-antigravity frontend-optimistic-mutationsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
| 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.
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:
ApiError.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),
});
}
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.
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:
mutationFn (which re-runs per retry → defeats the purpose).useMutation({
retry: (count, error: ApiError) => error.isNetworkError && count < 2, // network-only, bounded
});
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.
onMutate cancels in-flight queries before patching.onSettled invalidates so server-computed fields are refetched (on success and error).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 missing
cancelQueries (race clobber), partial cache patches (detail/list disagreement), and idempotency
keys generated inside mutationFn (no longer protect retries).
This skill follows the Anthropic SKILL.md format and is portable across agents.
skills/frontend-optimistic-mutations/SKILL.md in a public GitHub repo.name and high-signal description — discovery indexes match against it.npx skills add <org>/<repo> --skill "frontend-optimistic-mutations".SKILL.md agents can be pointed here from AGENTS.md / CLAUDE.md; Kiro can mirror it as a steering file.tools
Authorized security assessment of LLM applications and AI agents: prompt injection, tool abuse, RAG exposure, memory poisoning, system-prompt extraction, and agent-compliance engineering per OWASP LLM/ASI Top 10.
development
Builds two parameterized UI modes—流光溢彩白 (iridescent white) and 五彩斑斓黑 (colorful black)—with OKLCH, WebGL/CSS fallback, vision gating, screenshot QA, and total/per-color intensity reports. Use when a UI request names either mode or needs measured color parameters.
tools
Delegate coding tasks to the Kimi Code CLI (`kimi`) only when the user explicitly requests it, while the orchestrator retains review and landing responsibility.
development
Front-end JavaScript reverse engineering: locate signature chains, analyze encrypted request parameters, sample runtime behavior, and reproduce logic locally in Node for evidence-based output.