Sim 项目 React Query 最佳实践审计指南:Key Factory、staleTime、Mutation 与服务器状态所有权
Sim 项目 React Query 最佳实践审计指南Key Factory、staleTime、Mutation 与服务器状态所有权【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000 builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim本指南基于 Sim 仓库中react-query-best-practices技能文档.agents/skills/react-query-best-practices/SKILL.md系统讲解该仓库如何将 TanStack QueryReact Query v5确立为唯一的服务器状态来源并围绕 key factory、staleTime、mutation 语义与服务器状态所有权建立一整套可静态检查的工程规范。读完本文你将掌握如何在apps/sim/hooks/queries/下编写符合规范的查询/变更 Hook、如何设计层级化 key factory 与模糊失效、如何实现乐观更新与回滚、如何让服务端 prefetch 与客户端缓存严格对齐以及如何用仓库自带的 Lint 脚本scripts/check-react-query-patterns.ts把规范固化为 CI 门槛。核心语境React Query 是服务器状态的唯一事实来源该技能文档开篇就定义了本仓库的数据获取架构边界对应 CLAUDE.md 的 React Query 一节与 .claude/rules/sim-queries.md所有服务端状态必须经由 React Query 获取组件内禁止使用useStatefetch组合做数据请求或变更所有查询 Hook 集中在hooks/queries/实际路径为 apps/sim/hooks/queries/目录下约上百个按领域划分的查询文件folders.ts、workflows.ts、credentials.ts、tables.ts、chats.ts等以及一个服务端可安全导入的utils/子目录Zustand 仅用于纯客户端 UI 状态画布缩放、光标、拖拽尺寸、协作选中态等两者职责互不重叠严禁把查询数据复制进useState或 Zustand store唯一的例外是 mutation 回调中用于协调跨 store 状态的场景如临时 ID 替换。这一点与 URL 状态管理nuqs形成三权分立React Query 拥有远程数据nuqs 拥有可分享的客户端视图状态标签页、筛选、搜索、分页Zustand 拥有高频/大体积/瞬时/由 socket 同步的状态。分界细节见 .claude/rules/sim-url-state.md。说明技能文档引用的三篇参考文章Practical React Query、Effective React Query Keys、React Query as a State Manager均为社区知名作者 TkDodo 的系列文章用于建立理论基础本文不再重复外部链接直接以仓库内可验证的规则与源码为准。查询键与查询 Hook 的强制规则层级化 key factoryall根键 复数前缀键技能文档与 .claude/rules/sim-queries.md 共同要求每个查询文件必须定义层级化 key factory以all根键为起点中间层用复数前缀键lists、details支持前缀级失效fuzzy invalidation并严禁使用内联字面量作为queryKey。仓库标准模板export const entityKeys { all: [entity] as const, lists: () [...entityKeys.all, list] as const, list: (workspaceId?: string) [...entityKeys.lists(), workspaceId ?? ] as const, details: () [...entityKeys.all, detail] as const, detail: (id?: string) [...entityKeys.details(), id ?? ] as const, }真实实现可参考 apps/sim/hooks/queries/utils/folder-keys.ts 中的folderKeys。它展示了两个进阶设计resourceType必须进入 key而不是作为隐式默认值——因为一个工作区对每种资源Workflow、Table、Knowledge Base各自维护一棵独立的文件夹树若不把resourceType编入 key三棵树的列表会共享同一个缓存条目互相覆盖export const folderKeys { all: [folders] as const, lists: () [...folderKeys.all, list] as const, resource: (resourceType: FolderResourceType workflow) [...folderKeys.lists(), resourceType] as const, list: (workspaceId, scope active, resourceType workflow) [...folderKeys.resource(resourceType), workspaceId ?? , scope] as const, }key-fetch-arg-drift规则queryFn转发进请求的每一个标识符workspaceId、cursor、limit、org id 等都必须出现在queryKey中——否则不同的请求参数会共享同一个缓存条目造成跨租户/跨参数缓存碰撞。唯一的豁免是全局唯一 ID 作为 key、第二个参数仅是授权作用域不可能碰撞的情况需用// rq-lint-allow: reason显式标注。该检查由scripts/check-react-query-patterns.ts中的key-fetch-arg-drift类别强制执行。查询 Hook 的三项硬性要求queryFn必须解构并转发signal用于请求取消AbortSignal每个查询必须显式声明staleTime且来自命名导出常量如ENTITY_LIST_STALE_TIME绝不允许内联数字字面量——因为服务端 prefetch 要导入并复用同一个常量来水合同一 key若两边各自写死数字缓存的过期语义会悄悄漂移。0是唯一豁免因为它表示总是重新请求的哨兵语义而非可调时长keepPreviousData只能用于变量 key 的查询参数会变化如翻页、切工作区静态 key 上使用会导致陈旧数据永久停留。完整的合规查询 Hook 范例来自 .claude/rules/sim-queries.md与 apps/sim/hooks/queries/folders.ts 的useFolders实现一致import { requestJson } from /lib/api/client/request import { listEntitiesContract, type EntityList } from /lib/api/contracts/entities export const ENTITY_LIST_STALE_TIME 60 * 1000 async function fetchEntities(workspaceId: string, signal?: AbortSignal): PromiseEntityList { const data await requestJson(listEntitiesContract, { query: { workspaceId }, signal, }) return data.entities } export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) { return useQuery({ queryKey: entityKeys.list(workspaceId), queryFn: ({ signal }) fetchEntities(workspaceId as string, signal), enabled: Boolean(workspaceId) (options?.enabled ?? true), staleTime: ENTITY_LIST_STALE_TIME, placeholderData: keepPreviousData, // OK: workspaceId varies }) }注意enabled的拼装方式必须把调用方传入的enabled与必需参数守卫组合Boolean(id) (options?.enabled ?? true)并且绝不能在内部守卫之后再展开 options——因为{ enabled: true }会静默地重新启用一个参数非法的请求。folders.ts中正是这样做的。服务端可导入的查询原语use client边界技能文档特别强调了一个 Next.js 运行时陷阱被服务端模块prefetch.ts、RSCpage.tsx/layout.tsx、路由处理器、block 定义、trigger/worker导入的 key factory、独立 fetcher、mapper 或常量绝不能放在use client模块里。因为 Next.js 会把use client模块的每个导出重写为客户端引用服务端调用会直接抛运行时错误Attempted to call X from the server but X is on the client而且next build根本不会报错只有 SSR/运行时才炸。正确的布局详见 .claude/rules/sim-queries.md 的 Server-importable query primitives 一节key factory →hooks/queries/utils/entity-keys.ts如folder-keys.ts、table-keys.ts、credential-keys.ts、workflow-keys.ts独立 fetcher / mapper →hooks/queries/utils/fetch-*.ts或*-list-query.ts如fetch-workflow-envelope.ts、fetch-workspace-credentials.ts、workflow-list-query.ts带use client的 Hook 模块再从这些位置导入回来使用。folder-keys.ts里的mapFolder就是刻意放在 keys 文件旁而非 Hook 模块里这样服务端 prefetch 水合文件夹列表时无需引入/hooks/queries/folders后者会拖入 contracts barrel 和乐观更新机制。mapFolder还逐字段显式映射而非...spread防止新增的 wire 字段静默进入缓存形状、导致水合条目与客户端拉取结果不一致。该边界由 scripts/check-client-boundary-imports.tsbun run check:client-boundaryCI 中运行对 prefetch/route/trigger/block 文件强制检查浏览器专用路径可用// client-boundary-allow: reason逃生。查询的门控、预取与加载态语义用enabled守卫必需参数用enabled防止查询在缺少必需参数时运行见上例Boolean(workspaceId)。技能文档进一步指出一个容易踩的坑被禁用的查询仍然可能报告isPending: true。因此聚合加载状态时只统计适用/已启用的查询否则一个可选的查询可能让整个页面陷入永久加载态。悬停/聚焦意图预热prefetchQuery 共享queryOptions技能文档明确禁止临时启用一个挂载的隐藏 observer来做预热——它可能在焦点恢复后依然存活并重新拉取已关闭 UI 的数据。正确做法是用queryClient.prefetchQuery配合共享的queryOptions预热。仓库还提供了一个更精细的工具 apps/sim/hooks/queries/utils/prefetch-query-on-intent.tsexport function prefetchQueryOnIntentTQueryFnData, TError, TData, TQueryKey extends QueryKey( queryClient: QueryClient, options: FetchQueryOptionsTQueryFnData, TError, TData, TQueryKey ): void { void queryClient.prefetchQuery(options).then(() { const state queryClient.getQueryState(options.queryKey) if (state?.status ! error || state.data ! undefined) return queryClient.removeQueries({ queryKey: options.queryKey as QueryFilterKey, exact: true, type: inactive, }) }) }该工具在预取失败时移除未激活的错误条目——既避免了未激活的失败污染后续挂载又保留了已挂载 observer 的错误以便组件渲染真实反馈。它配套的测试在 apps/sim/hooks/queries/utils/prefetch-query-on-intent.test.ts。按视图/模态门控查询所有消费方一起迁移当用一个查询去门控视图或模态框状态时技能文档要求把每一个消费方都迁移到激活的查询上命令式刷新/分页、加载与错误反馈、数据派生控件都绝不能读取被禁用的查询或读取来自上一个 key 的 placeholder 数据。否则会出现刷新按钮操作的是死查询错误反馈读的是旧 key 的占位数据这类一致性问题。延后的授权/策略查询必须 fail closed对于延后执行的授权或策略查询绝不能把 pending/error 数据当作已成功加载的无限制策略的回退值。在被禁用状态下守卫动作必须保持禁用直到策略查询成功为止——这是权限安全的基础语义宁可功能不可用也不能误放行。服务端 prefetch 五条规则与客户端缓存严格对齐技能文档与 .claude/rules/sim-queries.md 的 Server prefetching 一节把服务端 prefetch 定义为填充客户端 Hook 会填充的同一个缓存 key因此必须与客户端请求不可区分。五条规则缺一不可直读数据层绝不自调 API服务端到服务端的/api/...调用多一次往返和一次二次认证。路由跑的是 application use caseprefetch 就调用同一个 use case使用与路由声明的同一套 auth 策略解析出的 principal而不是调用其下的 manager匹配 Hook 缓存的 wire 形状Hook 缓存的是requestJson(contract, …)产出的数据seed 必须与之相等。两个陷阱z.coerce.date()字段意味着 Hook 持有Date而原始路由 JSON 是字符串透传式响应 schemaz.custom意味着 Hook 原样缓存路由 JSON直接用原始行 seed 会泄漏Date和服务端专属字段。当路由在响应前做了投影就让路由与 prefetch 共用同一个投影函数证明查看者身份数据层读取本身不携带授权授权曾是路由提供的。用getWorkspaceHostContextForViewer解析查看者布局层已缓存它成本为零失败时提前返回且不缓存任何东西——客户端 fetch 随后会打到路由拿到真正的 403。绝不能拓宽查看者可见范围必须await只有 settle 的查询才会被脱水未 await 的 prefetch 会被静默丢弃面板照样瀑布式请求不要重复布局已 seed 的 keygetQueryClient()每次服务端调用都新建客户端页面重复 seed 布局 key 是真正的第二次读取而HydrationBoundary会把已见过的查询延后到 effect 里执行SSR 根本不跑 effect所以它永远不会进入服务端渲染。配套要求prefetch 复用 Hook 导出的staleTime常量与 key factorydehydrate既不携带 options 也不携带staleTime新鲜度是按 observer 计算的只有需要拒绝创建缓存条目时空列表要落到路由的创建路径才用setQueryDataseed因为prefetchQuery和ensureQueryData总是会创建条目同时保持 prefetch 导入轻量页面 prefetch 的导入会进入该路由的服务端图拉一个 barrel 可能拖入数千个模块由bun run check:tool-registry-boundary按页门控。Mutation Hook失效、乐观更新与依赖数组普通 mutationonSuccess定向失效普通非乐观变更在onSuccess中失效。要点是优先定向失效entityKeys.lists()而非宽泛失效entityKeys.all且失效必须覆盖所有受影响的 key 前缀列表、详情、关联视图。mutationFn同样走requestJson(contract, { body, signal })边界。模板见 .claude/rules/sim-queries.mdexport function useCreateEntity() { const queryClient useQueryClient() return useMutation({ mutationFn: (body: CreateEntityBody) requestJson(createEntityContract, { body }), onSuccess: () { queryClient.invalidateQueries({ queryKey: entityKeys.lists() }) }, }) }乐观 mutationonSettled对账 onError回滚技能文档的规则是普通 mutation 在onSuccess失效乐观 mutation 在onSettled对账onSettled在成功和失败时都会触发保证缓存始终与服务端对齐回滚放onError。标准四段式export function useUpdateEntity() { const queryClient useQueryClient() return useMutation({ mutationFn: async (variables) { /* ... */ }, onMutate: async (variables) { await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) }) const previous queryClient.getQueryData(entityKeys.detail(variables.id)) queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic value */) return { previous } }, onError: (_err, variables, context) { queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous) }, onSettled: (_data, _error, variables) { queryClient.invalidateQueries({ queryKey: entityKeys.lists() }) queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) }) }, }) }useCallback依赖陷阱绝不要把 mutation 对象放进useCallback依赖数组——mutation 对象引用不稳定每次状态更新都会变化导致回调被反复重建。TanStack Query v5 中.mutate()/.mutateAsync()是稳定的// ✗ Bad — 引用不稳定导致不必要的重建 const handler useCallback(() { createEntity.mutate(data) }, [createEntity]) // ✓ Good — 从依赖中剔除mutate 本身稳定 const handler useCallback(() { createEntity.mutate(data) }, [data])仓库级封装createOptimisticMutationHandlers技能文档指出与 Zustand 协同的乐观 mutation 应使用createOptimisticMutationHandlers。其实现位于 apps/sim/hooks/queries/utils/optimistic-mutation.ts把完整的乐观生命周期封装成一份可复用的 handlers 配置getQueryKey/getSnapshot/generateTempId/createOptimisticItem/applyOptimisticUpdate/replaceOptimisticEntry/rollback/onSuccessExtra统一在onMutate取消在途查询并快照、onSuccess用服务端数据替换临时条目、onError回滚快照、onSettled定向失效。export function generateTempId(prefix: string): string { return ${prefix}-${generateId()} }临时 ID 用generateId()而非时间戳避免同一毫秒内创建两行导致replaceOptimisticEntry用同一行服务端数据覆盖两个条目。该工具的测试在 apps/sim/hooks/queries/utils/optimistic-mutation.test.ts。folders.ts是这套模式的完整实战范本apps/sim/hooks/queries/folders.ts 中的useCreateFolder、useUpdateFolder、useDeleteFolderMutation、useRestoreFolder、useReorderFolders、useDuplicateFolderMutation全部基于上述语义。值得注意的细节useDeleteFolderMutation/useRestoreFolder在onSettled中不仅失效文件夹树还通过invalidateCascadedResourceLists级联失效被归档/恢复的资源列表——因为文件夹子树内的资源状态被级联改写只刷新文件夹树是不够的按resourceType分发到invalidateWorkflowLists、tableKeys.lists()或knowledgeKeys.lists()useReorderFolders在onMutate里按updatesByIdMap 就地改写sortOrder/parentIdonError用快照回滚useDuplicateFolderMutation刻意只支持resourceType: workflow因为POST /api/folders/[id]/duplicate没有知识库/表的等价物——注释里明确这是约束而非疏漏泛化会让乐观插入一个路由随后拒绝的条目。服务器状态所有权什么能进setQueryData技能文档的最后一条纪律是缓存所有权绝不把查询数据复制进useState——直接在组件里消费查询数据绝不把查询数据复制进 Zustand store——唯一例外是跨 store 状态协调的 mutation 回调如临时 ID 替换查询缓存不是本地状态管理器——setQueryData只用于乐观更新和.claude/rules/sim-queries.mdServer prefetching 中描述的 prefetch seed 场景没有第三种用途表单是唯一的刻意例外一个按 key 区分的表单子组件从已加载的查询数据惰性初始化而非用 effect 同步。该模式的所有权归.agents/skills/you-might-not-need-an-effect/SKILL.md中的 Query-backed forms 一节审计时不要重复产出它的结论。审计步骤与静态强制Enforcement技能文档定义的工作流是一个可交互的审计技能支持scope与fix两个参数scope要分析的范围默认当前改动可指定为diff to main、PR #123、src/hooks/queries/、whole codebase等fix是否直接应用修复默认true设为false时只给出修改建议不动代码。执行步骤为先阅读上述规则与参考材料再按规则逐条分析指定范围最后根据fix决定落地或仅提议。该技能背后的规则并非纸面约定而是有真实脚本在 CI 中强制执行的scripts/check-react-query-patterns.tsbun run check:react-query静态检查六类违规missing-stale-time—useQuery/useInfiniteQuery/useSuspenseQuery缺少显式staleTimestale-time-literal—staleTime使用数字字面量而非命名常量0豁免queryfn-no-signal— 内联queryFn不接收参数无法转发 AbortSignalinline-query-key— 用queryKey: [literal, …]而非 co-located key factorykey-factory-no-root—hooks/queries/**中的*Keysfactory 缺少all根键key-fetch-arg-drift—queryFn转发进 fetch 的标识符未出现在queryKey保守地只检查裸 camelCase 标识符排除 contract 参数、常量与signal/pageParam机制。执行模型与check-api-validation-contracts.ts一致apps/sim/hooks/queries/**是零容忍严格区任何违规直接失败apps/sim/**其余部分按 scripts/check-react-query-patterns.baseline.json 基线做 ratchet某类别计数超过基线才失败。逃逸通道是// rq-lint-allow: reason置于被标记构造的正上方最多容忍前面 3 行注释reason 必须非空。scripts/check-client-boundary-imports.tsbun run check:client-boundary防止服务端模块导入use client中的查询原语从根上杜绝 SSR 崩溃。scripts/check-api-validation-contracts.tsbun run check:api-validation/:strict强制requestJson契约边界与// boundary-raw-fetch: reason标注——同源 JSON 调用必须走契约原始fetch仅限流式响应、二进制下载、multipart 上传、签名 URL、OAuth 重定向、外部来源等文档化例外。命名约定与文件结构速查.claude/rules/sim-queries.md 的 Naming 与 File Structure 两节给出了可直接照抄的约定KeysentityKeys查询 HookuseEntity、useEntityList单数/复数区分变更 HookuseCreateEntity、useUpdateEntity、useDeleteEntityFetch 函数fetchEntity、fetchEntities私有不导出。单文件内部结构固定为四段式① key factory → ② 类型如需→ ③ 私有 fetch 函数接受signal参数→ ④ 导出的 Hook。按此结构组织文件配合上述六类静态检查即可保证apps/sim/hooks/queries/中新增的每一个查询都天然具备可取消、可定向失效、可被服务端安全水合、且不会发生缓存碰撞的正确语义——这正是 React Query 作为服务器状态管理器在本仓库落地的完整工程化实践。【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000 builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考