:基于 React useOptimistic 的乐观状态写入实现指南)
Electric 写路径模式二基于 React useOptimistic 的乐观状态写入实现指南【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric导读本文深入讲解examples/write-patterns示例中的第二种写路径模式——乐观状态Optimistic state。该模式使用 Electric 完成读取路径的数据同步同时利用 React 19 内置的useOptimisticHook 在本地先行渲染写入结果让应用在离线或弱网环境下依然能立即响应用户操作。读完本文你将掌握如何把useShape、useOptimistic、useTransition与matchStream/matchBy组合起来构建写入先渲染、后台重试、同步成功后自动收敛的本地优先交互体验并理解该模式的适用场景与固有局限。一、模式概述把网络从写路径中移除乐观状态模式是 write patterns 示例 中第二个写路径实现其完整代码位于 patterns/2-optimistic-state/index.tsx。它的核心思路是读取路径交给 Electric通过 Electric 将 Postgres 中的todos表同步到本地应用写入路径做本地乐观更新用户触发写入时先把临时的乐观状态合并进待渲染的待办列表界面立刻反映操作结果同时后台将写入发送到既有 API离线自动重试如果应用或 API 处于离线状态写入请求会按照退避算法backoff algorithm持续重试待网络恢复后最终成功同步成功自动收敛写入真正落库后通过 Electric 的 shape stream 同步回应用此时本地乐观状态被自动丢弃UI 以服务端数据为准。这种设计允许你继续使用已有的 REST API 作为写入通道不需要引入新的基础设施同时借助 Electric 的增量同步把最终结果回流到界面从而把网络从写路径中移除——用户不再需要等待 HTTP 请求完成才能看到自己的操作生效。从项目结构看本模式复用了1-online-writes在线写入模式的全部基础代码仅在其上增加了乐观状态层。两者的事件处理函数几乎一致差异在于乐观状态模式额外通过startTransition包装写入流程并调用addOptimisticState应用本地状态。二、适用场景与收益原文档明确列出了该模式的三大收益并结合示例代码可以得到更具体的印证实现简单相比后续更复杂的模式共享持久化乐观状态、直写本地数据库它只依赖 React 内置能力useOptimistic、useTransition无需引入额外依赖改动面小。可以复用既有 API写入仍然通过你已有的后端接口见 api.js 中的POST /todos、PUT /todos/:id、DELETE /todos/:id不必为本地优先改造后端协议。网络移出写路径读写皆可离线界面不再等待网络往返应用在网络抖动或离线时仍能流畅完成读写交互写入请求由带退避重试的客户端在后台完成。适合该模式的典型场景包括管理类应用与交互式仪表盘对响应速度敏感不希望每次操作都出现 loading 转圈追求手感快的应用希望在写入时不显示加载指示器让界面反馈即时可见移动端应用需要抵抗不稳定的网络连接在弱网或断网环境下依然可用。三、局限与权衡乐观状态的作用域与生命周期原文档同样清楚地指出了该模式的两点固有局限这两点都源于 ReactuseOptimistic的语义作用域限定在发起写入的组件内乐观状态只存在于执行写入的那个组件中。其他组件虽然渲染的是同一条数据同样来自 Electric 同步流却看不到这份临时状态可能继续显示旧数据造成界面不一致。状态不持久化乐观状态仅存于内存一旦组件卸载或页面刷新尚未落库的乐观修改就会丢失。这两点限制正是下一个模式——共享持久化乐观状态模式——要解决的问题它把乐观状态提升到共享、持久的本地存储中使离线写入更健壮并避免多个组件之间数据不同步。如果你的应用存在跨组件渲染同一数据、或需要长时间离线写入的场景应当优先评估该升级方案再进一步则是 直写本地数据库模式。四、如何运行本模式包含在examples/write-patterns示例工程中与另外三种模式作为同一个 React 页面中的组件同时运行便于横向对比行为差异。运行方式见示例 README 的 How to run 章节步骤如下先在 monorepo 根目录安装依赖并构建所有包pnpm install pnpm run -r build然后在examples/write-patterns目录启动后端容器Postgres 与 Electricpnpm backend:upbackend:up脚本实际会调用仓库根目录的example-backend:up并随后执行db:migrate应用shared/migrations下的迁移见 package.json。启动开发服务器Vite 前端 Express API 后端并行pnpm devdev脚本通过concurrently同时启动 Vite 和node shared/backend/api.jsAPI 默认监听http://localhost:3001。结束后关闭后端容器pnpm backend:down五、核心实现拆解下面逐段分析 index.tsx 的实现理解各部件如何协作。5.1 数据模型与写入操作类型type Todo { id: string title: string completed: boolean created_at: Date } type PartialTodo PartialTodo { id: string } type Write { operation: insert | update | delete value: PartialTodo }Todo与数据库迁移 01-create-todos.sql 中的表结构一一对应id UUID、title TEXT、completed BOOLEAN、created_at TIMESTAMPTZ。Write类型把乐观状态抽象为操作 值的联合形态供useOptimistic的更新函数消费。5.2 读取路径useShape 同步 Postgres 数据const { isLoading, data, stream } useShapeTodo({ url: TODOS_URL, parser: { timestamptz: (value: string) new Date(value), }, })useShape来自electric-sql/react从TODOS_URL定义于 shared/app/config.ts默认http://localhost:3001/todos拉取并订阅 shape。值得注意的两点parser.timestamptz把数据库返回的时间戳字符串解析为Date对象保证后续按created_at排序正确解构出的stream是整个模式的关键——它用于在下方检测本机写入是否已经通过 Electric 同步回来。随后数据按created_at升序排序得到服务端基准列表sorted。5.3 乐观层useOptimistic 合并本地写入const [todos, addOptimisticState] useOptimistic( sorted, (synced: Todo[], { operation, value }: Write) { switch (operation) { case insert: return synced.some((todo) todo.id value.id) ? synced : [...synced, value as Todo] case update: return synced.map((todo) todo.id value.id ? { ...todo, ...value } : todo ) case delete: return synced.filter((todo) todo.id ! value.id) } } )useOptimistic接收两个参数当前真实状态即 Electric 同步来的sorted和一个更新函数。当调用addOptimisticState(write)时React 会临时把write合并进渲染结果但不修改底层真实状态insert先去重按id判断是否已存在不存在才追加update按id找到目标行浅合并value中的字段如翻转completeddelete按id过滤掉目标行。由于useOptimistic每次都以最新的同步数据为基准重新应用乐观状态当 Electric 的数据流更新时乐观层会自动收敛。5.4 写入流程startTransition 双 Promise 等待以创建待办为例async function createTodo(event: React.FormEvent) { event.preventDefault() const form event.target as HTMLFormElement const formData new FormData(form) const title formData.get(todo) as string const path /todos const data { id: uuidv4(), title: title, created_at: new Date(), completed: false, } startTransition(async () { addOptimisticState({ operation: insert, value: data }) const fetchPromise api.request(path, POST, data) const syncPromise matchStream( stream, [insert], matchBy(id, data.id) ) await Promise.all([fetchPromise, syncPromise]) }) form.reset() }这里体现了本模式与大多数乐观更新示例的关键差异addOptimisticState立即应用本地乐观状态界面瞬间出现新待办fetchPromise向既有 API 发送POST /todos携带uuidv4()生成的客户端idsyncPromise通过matchStream等待 Electric shape stream 中出现对应这次写入的insert变更消息await Promise.all([...])意味着只有 HTTP 请求完成且数据经 Electric 同步回流之后整个 transition 才算结束。乐观状态的生命周期因此覆盖请求进行中到同步回流完成两个阶段。update与delete的处理逻辑完全同构仅操作符不同PUT /todos/:id配update流匹配、DELETE /todos/:id配delete流匹配。界面上isPending指示器来自useTransition在 transition 期间点亮代表写入尚未完全同步。5.5 底层的流匹配工具matchStream 与 matchBymatchStream与matchBy来自electric-sql/experimental包源码见 packages/experimental/src/match.ts。其工作原理是matchStream(stream, operations, matchFn, timeout 60000)订阅 shape stream持续过滤变更消息isChangeMessage判定直到找到一条操作类型匹配在operations数组中且匹配函数返回 true的消息一旦命中即取消订阅并 resolve默认 60 秒超时则 rejectmatchBy(column, value)返回一个匹配函数判断消息中的value[column] value。在本例中matchBy(id, data.id)确保我们等待的是自己发起的这条写入从服务端同步回来而不是其他用户的并发修改。这为后续模式中按操作级更新键匹配以精确作废本地状态见 02-add-write-id.sql 的注释奠定了设计基础——注释中明确指出按write_id而非单纯按行id匹配可以在其他用户并发修改同一行时只在你自己的写入同步通过时才清除本地状态从而实现乐观状态的重基rebase。六、离线重试与数据回流链路6.1 带退避算法的弹性请求客户端本模式的离线重试能力来自共享的 client.ts。它实现了指数级增长的退避重试// Keeps trying for 3 minutes, with the delay // increasing slowly from 1 to 20 seconds. const maxRetries 32 const backoffMultiplier 1.1 const initialDelayMs 1_000 async function retryFetch(url, options, retryCount) { if (retryCount maxRetries) return const delay retryCount * backoffMultiplier * initialDelayMs return await new Promise((resolve) { setTimeout(async () { resolve(await resilientFetch(url, options, retryCount)) }, delay) }) }最多重试 32 次总时长约 3 分钟每次重试延迟从 1 秒起、以 1.1 倍系数递增只有网络错误fetch抛异常才触发重试注释提示如需对 4xx/5xx 也做弹性可自行扩展。这意味着当 API 离线时fetchPromise会在后台持续等待并重试而界面早已通过乐观状态完成了即时反馈——这正是把网络移出写路径的落地实现。6.2 API 服务器与 Electric shape 代理写入请求到达的 api.js 是一个 Express 服务它承担两类职责写接口POST /todos经zod的createSchema校验id/title/created_at/write_id后插入、PUT /todos/:id更新completed、DELETE /todos/:id均直接写 Postgres读接口代理GET /todos将请求转发到 Electric 的/v1/shape端点只透传 Electric 协议参数ELECTRIC_PROTOCOL_QUERY_PARAMS服务端固定设置tabletodos并在配置了ELECTRIC_SOURCE_ID/ELECTRIC_SOURCE_SECRET时附加源认证参数最后把 Web Stream 转为 Node 流回传给前端。所以前端useShape订阅的TODOS_URL实际是一个代理后的 shape 端点客户端每次通过 API 写入的数据会由 Postgres → Electric 的变更数据捕获流转发到该 shapematchStream正是借助这一回流链路判断写入已成功同步随后 React 会以新到的服务端数据重算sorteduseOptimistic的乐观层随之被覆盖并丢弃界面自动收敛。七、模式演进定位在 write patterns 示例 中四种模式按能力与复杂度递增排列模式写路径策略乐观状态持久化1. 在线写入直接调 API失败重试无写入成功后才更新 UI—2. 乐观状态本文调 API 本地乐观渲染组件内、内存态否3. 共享持久化乐观状态共享本地存储 后台同步跨组件共享是4. 直写本地数据库本地嵌入式数据库 影子表 视图数据库级是本模式位于极简在线写入与共享持久化之间用最小的复杂度换取即时的写入反馈是权衡 UX用户体验、DX开发体验与实现成本时非常务实的一个落点当你发现组件间数据不同步或页面刷新丢失写入成为痛点时再沿链路升级到后续模式即可。另外写路径的完整演进思路也体现在数据库迁移 02-add-write-id.sql 中——它提前为高级模式预留了write_id列用于在并发场景下精确匹配自己的写入本文模式虽未强制使用该字段但其matchBy(id, ...)的匹配思路正是该演进方向的雏形。【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考