16 · Zustand 草稿:未提交的内容应该放在哪里#
客户写了一半补充说明,切去看另一张工单,再回来发现内容没了,会很沮丧。草稿用于保留这些尚未提交的编辑内容。本章解释当前 store 怎样创建、按什么范围保存、何时恢复,以及哪些内容仍然不该放进它。
1. 先建立四种状态的分工#
| 状态 | 当前负责者 | 事实来源 |
|---|---|---|
| 工单、负责人、已发送回复 | Prisma + TanStack Query | 数据库,Query 是浏览器缓存 |
| 未发送回复、未创建工单内容 | Zustand 草稿 | 当前浏览器会话中的用户编辑 |
| 创建表单字段与错误 | Ant Design Form | 表单实例 |
| 列表筛选、页码 | nuqs | URL 中的浏览条件 |
不要再把已发送回复复制一份到 Zustand 作为第二套事实来源。草稿与服务器数据是不同生命周期的对象,发送成功才跨过这条边界。
创建表单中,Form 负责实时字段与校验,onValuesChange 将当前字段保存成草稿快照;回复输入框则直接以草稿文本作为受控值。使用同一个 store,不等于所有表单都要改用同一种绑定方式。
2. 两层 key 怎样避免草稿串用#
stores/drafts.ts 的两个函数:
// 源码节选
export const draftStorageKey = (userId: string) => `teamdesk-drafts:${userId}`;
export const draftKey = (teamId: string, ticketId: string) =>
`${teamId}:${ticketId}`;外层存储名区分账号,内部条目区分团队与工单:
teamdesk-drafts:用户A
├─ 甲团队:工单1 → 回复文本 + 请求编号
├─ 甲团队:工单2 → 另一份回复
└─ 乙团队:new → 新建工单的表单字段new 是创建表单用的特殊条目,不是数据库工单 ID。一个团队当前只有这一份新建工单草稿,不是同时保存任意多张待创建工单。
账号、团队、工单任何一层丢掉,都可能误展示另一处的编辑内容。加 key 是组织客户端数据,不是授权;能访问某份本地草稿不能让服务端接受越权请求。
3. 为什么 store 要用工厂创建#
createDraftStore(userId, storage?) 使用 zustand/vanilla 的 createStore 创建独立实例,而不是在模块顶层建立一个所有账号共享的可变全局对象。
// 源码节选:独立 store 实例在该 Provider 生命周期内保持稳定
const [store] = useState(() => createDraftStore(userId));Context 传递 store 实例,useDraftStore(selector) 再通过 Zustand 的 useStore 订阅所需部分。比如只读 state.drafts[key],能让组件围绕自己的草稿变化更新;这不等于永远不会因其他 props 或父组件重新渲染。
TeamWorkspace 按用户、团队和角色给外层 Provider 设置 key,上下文变化会重新挂载这组子树。重新挂载不是删除持久草稿,新 store 还可以从当前账号存储恢复相关条目。
拓展阅读为什么不直接把 store 放模块全局?
服务端渲染进程会处理多个请求,模块级可变状态的生命周期可能长于一个用户页面;浏览器切账号时也容易残留上一个人的实例。这里用工厂加 Provider 约束实例范围,另外用账号 key 管持久存储。两层边界共同工作,不能仅靠给组件加 'use client' 就忽略首次渲染和实例归属。
4. persist 为什么使用 sessionStorage#
关键配置是:
// 源码节选:src/stores/drafts.ts
name: draftStorageKey(userId),
storage: createJSONStorage(() => storage ?? sessionStorage),
partialize: (state) => ({ drafts: state.drafts }),
skipHydration: true,persist 将选中的 state 序列化到存储。partialize 只保存 drafts,不会把 hydrated 标记和 save / remove 方法当作持久内容。
当前是 sessionStorage:通常可在同一标签页刷新后恢复,生命周期属于浏览器页面会话;它不是跨设备同步,也不是长期云草稿。新窗口、复制标签页、恢复会话等行为受浏览器机制影响,不要把它描述成“跨标签实时共享”或“永远关闭即绝对清除”。
教程阅读站自己的学习进度使用 localStorage,那是另一项产品功能,和业务草稿不共用存储。
5. 什么是这里的 hydration,为什么延迟恢复#
这个词在这里表示“把持久存储中的草稿合并回 store”。它与 React 首屏 HTML 对齐有关,但不是两者完全等同。
首屏先用空 state,挂载后再执行:
// 源码节选:src/features/tickets/draft-provider.tsx
useEffect(() => {
void Promise.resolve(store.persist.rehydrate()).finally(() =>
store.setState({ hydrated: true }),
);
}, [store]);skipHydration: true 把恢复时机交给 Provider。这样服务端首次 HTML 与浏览器首帧都不依赖 sessionStorage 中的私有内容,降低首屏不一致的风险。
恢复完成前,创建按钮和回复输入被禁用,避免用户刚输入就被稍后恢复的旧草稿覆盖。hydrated: true 表示恢复流程已结束,不能理解为“存储一定可用、所有内容一定成功保存”。
当前实现没有完整的存储配额错误提示或草稿加密机制。浏览器禁用存储、存储写入失败等情况,不在“已验证损坏数据可丢弃”的保证范围内。
6. 为什么浏览器存储也要校验#
merge 用 Zod 检查恢复对象:文本长度、请求 UUID、表单字段形状都有限制。校验失败则恢复为空草稿集合,避免把坏数据或意外结构当可用草稿。
这项校验并不是对每个坏条目都单独修复:当前如果整份 drafts 对象不满足 schema,结果会丢弃该次恢复的整份草稿集合。JSON 解析失败或底层存储异常也不能和“JSON 可解析但结构错误”混为同一测试场景。
store 同时限制最多 100 项:保存时把刚编辑条目移到对象末尾,超量删除最前项;恢复时也只取最多 100 项。这是按最近编辑顺序控制条目数,不是每项带时间戳的过期策略,也不是严格的字节配额。
7. 创建与回复各在什么时候保存、清除#
| 动作 | 新建工单草稿 | 回复草稿 |
|---|---|---|
| 编辑输入 | onValuesChange 保存表单字段 |
保存文本并生成请求编号 |
| 关闭弹窗 / 切去别处 | 保留 | 按原 key 保留 |
| 成功提交 | resetFields 并移除条目 |
移除条目,回复页回 1 |
| 提交失败 | 保留以便修正 | 保留文本和编号 |
| 点击清空草稿 | 删除条目,并重置表单值 | 删除当前回复条目 |
| 工单关闭 | 不是同一编辑入口 | 隐藏发送输入,未发送草稿仍保留 |
| 主动退出登录 | 删除当前标签页该账号的草稿存储 | 同左 |
新建表单打开时先填默认字段,再覆盖 draft?.ticket。单纯改 initialValues 不足以在每次重开弹窗时恢复动态内容,当前实现使用 form.setFieldsValue。
save(key, draft) 会替换这个 key 的草稿对象,而非自动深合并。调用时要传本次需要保留的整份条目;不要只保存 { requestId } 却期待旧文本自动留下。
8. 测试与练习#
tests/drafts.test.ts 注入内存 storage,验证同账号重建 store 后恢复、不同账号 / 团队 / 工单不串用、clearStorage 后重建不恢复,以及 JSON 形状错误时丢弃数据。它不依赖数据库,也不模拟实际退出按钮、100 项淘汰边界、持久化 requestId 的重试链路或真实浏览器存储异常;这些不能由“草稿测试通过”一并推出。
练习:为什么不能只持久化回复 text,不保存 requestId?
动手练习查看参考答案
参考答案:刷新后虽然文字还在,原发送意图的编号却丢了;如果之前已经写库但响应丢失,再发时产生新编号就可能重复。当前回复草稿把文本和编号一起保存,以衔接上一章的幂等合同。
这条链路是:用户编辑 → 按账号 / 团队 / 工单保存草稿 → sessionStorage 持久化 → 挂载后校验恢复 → 成功提交才清除;真实业务结果仍由服务端与数据库确认。