前端乐观突变(写入路径)
使用场景
当你需要一套可移植、与框架无关的写入路径规范,用于任何使用 query/cache 层的 React 或 React Native 应用时,使用本技能。它固化了乐观更新生命周期(取消进行中的查询 → 快照每个受影响的缓存 → 即时打补丁 → 出错时逐字回滚 → 完成后失效……)。
可移植技能——Claude Code、OpenCode、Codex、Cursor、Windsurf 等均可阅读。 本技能描述的是写入路径的规范——乐观更新、回滚、幂等性、缓存一致性——而非某个 UI 库或样式系统。它直接建立在 frontend-data-contracts 技能(写入通过类型化客户端进行)和 frontend-architecture 技能(突变位于
modules/{feature}/hooks/,由工厂函数生成键)之上。
目标:写入操作感觉即时(UI 在服务器确认前就反映出变化),安全可靠(失败时恢复精确的先前状态,重试绝不会重复扣款),并使缓存保持一致(详情视图与每个列表页一致)。三者同时做到——这就是技艺所在。
0. 五大核心思想
- 乐观生命周期是固定的。 取消 → 快照 → 打补丁 →(出错:回滚)→(完成:失效)。每个乐观突变都遵循同样的五个节拍。
- 逐字回滚。 失败时,恢复打补丁前所取的精确快照——而不是靠"最佳猜测"重新推导。将快照保留在突变上下文中。
- 幂等键只生成一次,而不是每次尝试都生成。 键在表单初始化时(或首次意图时)创建,以便网络重放重试时返回原始服务器响应,而非重复执行动作。
- 缓存同步推进。 状态变更同时打补丁到详情缓存和包含该实体的每个列表页,使不同视图上的徽标永不冲突。
- 服务器状态永不进入客户端 store。 乐观状态存于 query 缓存中,而非 Zustand/Redux。缓存是服务器数据的唯一真相源(参见 frontend-architecture §4)。
1. 何时使用乐观更新(以及何时不用)
| 场景 | 策略 |
|---|---|
| 高置信、低冲突的写入(切换状态、点赞、标记已付、排序) | 乐观 —— 立即打补丁,出错时回滚。 |
| 需要服务器生成 id/编号/总计的创建操作 | 待定状态,随后通过服务器响应 setQueryData。可选地临时插入乐观行;成功时协调一致。 |
| 破坏性或难以撤销的写入(级联删除、转账) | 先确认,再乐观或待定——绝不静默乐观。 |
| 用户暂时看不到结果的写入(后台任务) | 待定 + Toast,完成时失效。不打乐观补丁。 |
乐观是一种针对你确信会成功的写入的 UX 工具。如果失败常见或撤销代价高昂,请优先使用待定状态。
2. 乐观生命周期(TanStack Query)
规范的形态。每个节拍都有其职责;跳过任何一个都会破坏正确性。
// 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() });
},
});
}
每个节拍的作用:
- 取消 —— 没有它,已经在飞的查询可能在你的补丁之后解析并覆盖乐观状态。
- 快照 —— 唯一安全的回滚来源;绝不要手工重建先前状态。
- 打补丁 —— 即时 UX;同时变更详情和列表(§4)。
- 回滚 —— 逐字恢复快照,然后展示类型化的
ApiError。 - 完成时失效 —— 无论成功失败,都重新拉取,使服务器计算字段(时间戳、汇总)成为权威。是在"完成"时而非仅在"成功"时:失败的写入也可能改变了服务器状态。
3. 非乐观写入:创建带服务器自有字段的记录
返回 id/编号/总计的创建操作无法完全乐观。作为待定突变运行,并用响应数据填充缓存。
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. 缓存一致性(详情 + 列表同步推进)
单一实体出现在多个缓存中:它的详情,以及每个经过筛选/分页的列表页。一次乐观补丁必须触及所有这些缓存,否则各视图会不一致。使用分层键工厂(参见 frontend-architecture §4.3),以便精确命中目标。
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() })枚举每个已缓存的列表页,以便逐一打补丁。invalidateQueries({ queryKey: invoiceKeys.lists() })在完成时刷新它们。detail(id)精确命中单个实体。
为你触及的每一页做快照(以其精确的 query 键为键),以便回滚时逐字恢复每一页,而不仅仅是当前屏幕上的那一页。
5. 幂等性(在涉及金钱的写入上安全重试)
重试的 POST 不得重复执行动作。在表单初始化时(或用户首次意图时)一次性生成键,将其贯穿所有重试,并让客户端将其作为请求头发送。服务器在其窗口期内对重复的键重放原始响应。
// 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 });
硬性规则:
- 在意图时刻生成键,而不是在
mutationFn内部(后者每次重试都会重新运行 → 失去意义)。 - 数据客户端自动检测金融类路由并注入该请求头;显式键总是优先生效,使重试能够重放。
- 将幂等性与待定时禁用的 UI 配对,以防用户发起第二次不同的写入。
6. 重试策略
- 读取: 带退避策略重试几次(大多数 query 库的默认行为)——安全且幂等。
- 写入: 对非幂等突变不要自动重试。仅当幂等键保证重放时、或仅在网络错误(状态码 0)时重试,绝不在 4xx 时重试。
- 冲突(409): 不重试——展示类型化错误,失效,让用户在新鲜数据上重新决策。
useMutation({
retry: (count, error: ApiError) => error.isNetworkError && count < 2, // network-only, bounded
});
7. 库适配器
五个节拍的生命周期是相同的;区别在于 hook。
| 库 | 乐观机制 |
|---|---|
| TanStack Query | onMutate(取消 + 快照 + 打补丁)→ onError(回滚)→ onSettled(失效)。上面的参考形态。 |
| RTK Query | onQueryStarted:updateQueryData 返回一个 patchResult;await queryFulfilled 并在 catch 中调用 patchResult.undo()。完成时使用 invalidatesTags。 |
| SWR | mutate(key, optimisticData, { rollbackOnError: true, populateCache, revalidate: true }) —— 乐观数据 + 自动回滚 + 重新校验。 |
对于 React Native,上述三个库都不需要改动即可使用;缓存同样在原生侧是真相源。保持突变 hook 不依赖 DOM,以便在 Web 和原生之间共享。
8. 规范清单(在评审中强制执行)
- 乐观突变遵循 取消 → 快照 → 打补丁 → 回滚 → 失效 的顺序。
-
onMutate在打补丁前取消进行中的查询。 - 回滚从上下文中恢复精确的快照,而不是重新推导。
- 详情和每个受影响的列表页一并被打补丁和快照。
-
onSettled失效,使服务器计算的字段被重新拉取(成功和出错时均如此)。 - 幂等键在意图时生成并在重试间重放,而不是每次尝试重新生成。
- 涉及金钱/破坏性的写入先确认,并在待定期间禁用触发按钮。
- 非幂等写入不自动重试;409 错误展示而非重试。
- 服务器状态保留在 query 缓存中——绝不复制到客户端 store。
- Query 键来自分层工厂;失效是精确范围的,而非全局一刀切。
9. 如何应用本技能
新增乐观突变: 决定它适合乐观(§1)。编写五个节拍(§2)。识别实体所在的每个缓存并对它们一并打补丁/快照(§4)。
让写入可安全重试: 在表单初始化时生成幂等键,将其贯穿整个突变,确认客户端会发送它(§5),并设置仅限网络错误的有限重试(§6)。
调试闪烁/写入后状态错误: 检查 onMutate 是否取消了查询(晚到的 refetch 覆盖)以及 onSettled 是否失效(陈旧的服务器计算字段)。检查所有列表页是否都打了补丁,而不仅仅是可见的那一页。
评审写入路径: 运行 §8 的清单。最有价值的捕获项是缺失的 cancelQueries(竞争覆盖)、部分缓存补丁(详情/列表不一致)以及在 mutationFn 内生成的幂等键(不再保护重试)。
发布 / 安装本技能
本技能遵循 Anthropic 的 SKILL.md 格式,可跨智能体移植。
- 将其放在公共 GitHub 仓库的
skills/frontend-optimistic-mutations/SKILL.md下。 - 保留 frontmatter 的
name和高信号的description——发现索引据此匹配。 - 通过以下方式安装:
npx skills add <org>/<repo> --skill "frontend-optimistic-mutations"。 - 非
SKILL.md的智能体可从AGENTS.md/CLAUDE.md指向此处;Kiro 可将其作为 steering 文件镜像。
局限性
- 仅当任务明确匹配其上游来源和本地项目上下文时使用本技能。
- 在应用更改之前,请验证命令、生成的代码、依赖、凭证以及外部服务的行为。
- 不要将示例视为针对特定环境的测试、安全审查或用户对破坏性/高成本操作的批准的替代品。