tanstack-data-loading

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

TanStack data loading

TanStack 数据加载

The first argument selects the mode. With no mode argument, infer it: a request to audit, find waterfalls or review existing code is
audit
; a request to rediscover or bust the cache is
refresh
; anything else is writing.
  • audit [--fix] [path]
    — read references/audit.md before starting, and run it over
    path
    .
  • refresh [path]
    — rerun Discovery for the app containing
    path
    , write the result, and stop.
  • Writing — apply the rules below to the code being written or reviewed, within
    path
    if given.
Every mode starts with Discovery. The rules branch on what it records: if you guess the backend shape, you apply the wrong server function rules and the wrong write rules.
第一个参数用于选择模式。如果未提供模式参数,则进行推断:要求审计、查找请求瀑布或审查现有代码时使用
audit
;要求重新发现或清除缓存时使用
refresh
;其他情况均视为编写。
  • audit [--fix] [path]
    —— 开始前阅读 references/audit.md,并对
    path
    执行审计。
  • refresh [path]
    —— 对包含
    path
    的应用重新执行发现流程,写入结果,然后停止。
  • 编写 —— 对正在编写或审查的代码应用以下规则;如果提供了
    path
    ,则限定在该路径内。
每种模式都从发现流程开始。规则会根据发现流程记录的信息进行分支处理:如果错误判断了 backend 形态,就会应用错误的 server function 规则和错误的写入规则。

Available scripts

可用脚本

  • scripts/discovery.py
    —
    read
    and
    write
    the discovery cache at
    <repo-root>/.draekien/tanstack-data-loading/discovery.json
    , one discovery entry per app root;
    versions
    prints the installed
    @tanstack
    versions.
    --help
    lists the exit codes.
  • scripts/discovery.py
    —— 在
    <repo-root>/.draekien/tanstack-data-loading/discovery.json
    中读取和写入发现缓存,每个应用根目录对应一个发现条目;
    versions
    会打印已安装的
    @tanstack
    版本。
    --help
    会列出退出码。

Discovery

发现

App root. The nearest ancestor of
path
(or of the current directory) whose
package.json
depends on
@tanstack/react-start
. If none matches, or the task spans several apps, ask the user which app.
Read. Run the script from this skill's directory, passing the app root relative to the repo root (
.
for a single-package repo):
bash
uv run scripts/discovery.py read --app apps/web --repo <repo-root>
ExitMeaningNext
0Fresh discovery entry printedUse it, and apply its
versionNotes
in place of the rules they name. In
refresh
, discover and write anyway.
4No discovery entryDiscover and write.
5Older than the script's maximum age, or an installed
@tanstack
version changed
Discover and write.
3Cache file or an installed package manifest unreadableRead stderr. For the cache file, tell the user and delete it only after they agree, since it holds every app's discovery entry. For a manifest, ask the user to reinstall dependencies.
2Usage errorRead stderr, correct the call, rerun.
Discover. Find each field from the code:
FieldValuesHow to find it
backend
database
,
service
,
both
Read the
createServerFn
handlers.
database
: they query a database client directly.
service
: they call another service over HTTP.
both
: some do each.
pendingUi
route-level
,
per-section
route-level
when the router sets
defaultPendingComponent
. Otherwise ask the user whether they want a route-level pending component or Suspense sections only; with no user available, record
per-section
and say so.
verify
list of commandsThe repo's lint, typecheck and test commands from
package.json
scripts
.
versionNotes
list of stringsRun
uv run scripts/discovery.py versions --app <app-root> --repo <repo-root>
and compare against Versions. When it sends you to references/versions.md, record its findings, or
checked: no changes
when there are none. Otherwise empty. If
versions
prints
{}
, ask the user to install dependencies before continuing.
Write. The script adds
discoveredAt
and the installed
versions
, and rejects unknown values:
bash
uv run scripts/discovery.py write --app apps/web --repo <repo-root> <<'EOF'
{"backend": "database", "pendingUi": "per-section", "verify": ["pnpm check", "pnpm typecheck", "pnpm test"], "versionNotes": []}
EOF
First write in a repo. Exit 6 means
.draekien/
is missing: ask the user before creating it. When
.draekien/tanstack-data-loading/discovery.json
does not exist yet, ask whether to add
.draekien/tanstack-data-loading/
to
.gitignore
; the discovery entries hold one developer's local discovery. If the user declines
.draekien/
, keep the discovered fields for this task only and say the discovery cache was not written.
Discovery is done when
read
or
write
exits 0 for the app, or when the user declined the cache and every field is known.
Backend rules. Before writing or auditing any server function, mutation or write path, read the reference for
backend
:
  • database
    — references/backend-database.md.
  • service
    — references/backend-service.md.
  • both
    — read both. Apply each to the server functions whose handlers match it; a handler that does both applies each reference to its own part.
应用根目录。
path
(或当前目录)向上查找时,最近一个
package.json
依赖
@tanstack/react-start
的祖先目录。如果没有匹配项,或者任务涉及多个应用,则询问用户要使用哪个应用。
读取。 从此 skill 的目录运行脚本,并传入应用根目录相对于仓库根目录的路径(单包仓库使用
.
):
bash
uv run scripts/discovery.py read --app apps/web --repo <repo-root>
退出码含义后续操作
0已打印最新的发现条目使用该条目,并应用其中的
versionNotes
来替代其所指定的规则。在
refresh
模式下,仍需执行发现和写入。
4没有发现条目执行发现和写入。
5条目早于脚本允许的最大时长,或已安装的
@tanstack
版本发生变化
执行发现和写入。
3缓存文件或已安装的 package manifest 无法读取阅读 stderr。对于缓存文件,先告知用户,并且只有获得同意后才能删除,因为它保存了所有应用的发现条目。对于 manifest,请用户重新安装依赖。
2用法错误阅读 stderr,修正调用后重新运行。
发现。 从代码中查找每个字段:
字段值查找方式
backend
database
、
service
、
both
阅读
createServerFn
handler。
database
:直接查询 database client。
service
:通过 HTTP 调用其他 service。
both
:两种情况都有。
pendingUi
route-level
、
per-section
当 router 设置了
defaultPendingComponent
时使用
route-level
。否则询问用户希望使用 route-level pending component,还是仅使用 Suspense section;如果无法联系用户,则记录为
per-section
并说明这一点。
verify
命令列表从
package.json
的
scripts
中获取仓库的 lint、typecheck 和 test 命令。
versionNotes
字符串列表运行
uv run scripts/discovery.py versions --app <app-root> --repo <repo-root>
,并与版本中的内容进行比较。当它引导至 references/versions.md 时,记录发现结果;没有变化时记录
checked: no changes
。否则保持为空。如果
versions
输出
{}
,请用户先安装依赖再继续。
写入。 脚本会添加
discoveredAt
和已安装的
versions
,并拒绝未知值:
bash
uv run scripts/discovery.py write --app apps/web --repo <repo-root> <<'EOF'
{"backend": "database", "pendingUi": "per-section", "verify": ["pnpm check", "pnpm typecheck", "pnpm test"], "versionNotes": []}
EOF
仓库中的首次写入。 退出码 6 表示缺少
.draekien/
:创建前先询问用户。当
.draekien/tanstack-data-loading/discovery.json
尚不存在时,询问是否将
.draekien/tanstack-data-loading/
添加到
.gitignore
;发现条目保存的是单个开发者的本地发现结果。如果用户拒绝创建
.draekien/
,则仅为当前任务保留发现的字段,并说明未写入发现缓存。
当应用的读取或写入退出码为 0,或者用户拒绝使用缓存且所有字段均已确定时,即完成发现流程。
Backend 规则。 在编写或审计任何 server function、mutation 或写入路径之前,根据
backend
阅读对应参考资料:
  • database
    —— references/backend-database.md。
  • service
    —— references/backend-service.md。
  • both
    —— 两份都阅读。分别将其应用于匹配的 server function;如果某个 handler 同时包含两种逻辑,则对各自对应的部分应用相应参考资料。

Versions

版本

Rules were checked against
@tanstack/react-query
5.104.2,
@tanstack/react-router
1.170.44 and
@tanstack/react-start
1.169.1. Before applying a rule that uses an API below, compare the installed versions (the discovery entry's
versions
, or the
versions
command) to the minimum and use the substitute when the installed version is lower:
Rule usesNeedsBelow the minimum
queryClient.query
,
queryClient.infiniteQuery
@tanstack/react-query
5.102.0
fetchQuery
,
prefetchQuery
,
ensureQueryData
and their infinite variants, which are not deprecated there
staleTime: "static"
@tanstack/react-query
5.79.0
ensureQueryData(options)
, which behaves the same in a loader
import { noop } from "@tanstack/react-query"
@tanstack/react-query
5.76.0
.catch(() => {})
mutation
scope: { id }
@tanstack/react-query
5.32.0
disable the trigger while
isPending
createServerFn().validator(...)
@tanstack/react-start
1.168.25
.inputValidator(...)
; versions from 1.169.0 accept only
.validator(...)
*.server.ts
blocked from client imports
@tanstack/react-start
1.166.8
the suffix is a naming convention; nothing blocks the import
When an installed major version is newer than the checked one, or an API a rule uses is missing from the installed types, and the discovery entry has no
versionNotes
, read references/versions.md before applying any rule.
以下规则基于
@tanstack/react-query
5.104.2、
@tanstack/react-router
1.170.44 和
@tanstack/react-start
1.169.1 进行核对。应用使用下列 API 的规则之前,将已安装版本(发现条目中的
versions
,或
versions
命令的输出)与最低版本进行比较;如果已安装版本较低,则使用替代方案:
规则使用的 API最低版本低于最低版本时的替代方案
queryClient.query
、
queryClient.infiniteQuery
@tanstack/react-query
5.102.0
fetchQuery
、
prefetchQuery
、
ensureQueryData
及其 infinite 变体,这些 API 在该版本中尚未弃用
staleTime: "static"
@tanstack/react-query
5.79.0
ensureQueryData(options)
,在 loader 中具有相同行为
import { noop } from "@tanstack/react-query"
@tanstack/react-query
5.76.0
.catch(() => {})
mutation 的
scope: { id }
@tanstack/react-query
5.32.0
在
isPending
期间禁用触发器
createServerFn().validator(...)
@tanstack/react-start
1.168.25
.inputValidator(...)
;从 1.169.0 开始的版本仅接受
.validator(...)
阻止客户端导入
*.server.ts
@tanstack/react-start
1.166.8
后缀仅是一种命名约定,不会阻止导入
如果已安装的主版本高于核对时使用的版本,或者规则使用的 API 未出现在已安装的类型定义中,并且发现条目中没有
versionNotes
,则应用任何规则之前先阅读 references/versions.md。

Loaders

Loader

Scope: the
loader
option of every file route under the app root.
A read is critical when the route must not render without it or must render something else when it fails: the main content, existence (404), authorization (403), a redirect, a limit gate, form defaults. Every other read is unawaited.
  • Critical read:
    await context.queryClient.query(options)
    . Awaiting it in the loader sends a 404 or 403 to
    notFoundComponent
    or
    errorComponent
    before render; let the error reach them, or throw
    notFound()
    .
  • Unawaited read, including hidden variants such as a toggle's second period:
    void context.queryClient.query(options).catch(noop)
    . Without
    .catch(noop)
    the rejection is unhandled on the server; the component's
    useSuspenseQuery
    still rethrows the error to its boundary.
  • Start every unawaited read before the first
    await
    . A read started after an
    await
    is a waterfall.
  • Independent critical reads: one
    await Promise.all([...])
    .
    ts
    ✗ await context.queryClient.query(orderQueryOptions(id))
      await context.queryClient.query(customerQueryOptions(customerId))
    
    ✓ await Promise.all([
        context.queryClient.query(orderQueryOptions(id)),
        context.queryClient.query(customerQueryOptions(customerId)),
      ])
  • A read
    b
    that needs read
    a
    's result is a waterfall that cannot be removed:
    a
    b
    Loader
    criticalcritical
    const x = await query(a)
    , then
    await query(b(x))
    criticalunawaited
    const x = await query(a)
    , then
    void query(b(x)).catch(noop)
    : the one unawaited read started after an
    await
    unawaitedunawaited
    void query(a).then((x) => query(b(x))).catch(noop)
    , started before the first
    await
  • The loader and the component use the same
    queryOptions
    object. A different key in the component fetches the data a second time.
  • A route a signed-out visitor can open starts reads that require a session only in its signed-in branch. Otherwise every signed-out visit fires calls that throw.
  • Replace deprecated
    QueryClient
    methods:
    DeprecatedReplacement
    fetchQuery(options)
    query(options)
    prefetchQuery(options)
    void query(options).catch(noop)
    ensureQueryData(options)
    query({ ...options, staleTime: "static" })
    , which behaves the same. If a mutation invalidates the key, use
    staleTime: Infinity
    instead: a later loader run with
    "static"
    returns the invalidated cached data, so the route decides its branch, redirect or gate from old data, while
    Infinity
    refetches first. A
    staleTime
    passed to one
    query()
    call does not stay on the query: whether invalidation refetches a mounted page depends on the component's
    useSuspenseQuery
    staleTime
    fetchInfiniteQuery(options)
    infiniteQuery(options)
    prefetchInfiniteQuery(options)
    void infiniteQuery(options).catch(noop)
    ensureInfiniteQueryData(options)
    infiniteQuery(...)
    with the same
    staleTime
    choice as
    ensureQueryData
  • Do not defer reads to scroll or interaction, and do not add hover prefetching beyond the router's
    defaultPreload: "intent"
    . An unawaited read already streams without blocking the page; deferring it adds a request after render, and no TanStack doc defines how deferral behaves with
    useSuspenseQuery
    .
适用范围:应用根目录下每个 file route 的
loader
选项。
当 route 在缺少某次读取时无法渲染,或者读取失败时必须呈现其他内容时,该读取属于关键读取:主要内容、存在性检查(404)、授权检查(403)、重定向、限额拦截、表单默认值。其他所有读取均属于未等待读取。
  • 关键读取:
    await context.queryClient.query(options)
    。在 loader 中 await 它,会在渲染前将 404 或 403 传递给
    notFoundComponent
    或
    errorComponent
    ;让错误传递给它们,或抛出
    notFound()
    。
  • 未等待读取,包括切换开关的第二个时段等隐藏变体:
    void context.queryClient.query(options).catch(noop)
    。如果没有
    .catch(noop)
    ,服务端会将 rejection 视为未处理;组件中的
    useSuspenseQuery
    仍会将错误重新抛给对应的 boundary。
  • 每个未等待读取都必须在第一次
    await
    之前启动。在
    await
    之后启动的读取会形成请求瀑布。
  • 相互独立的关键读取:使用一次
    await Promise.all([...])
    。
    ts
    ✗ await context.queryClient.query(orderQueryOptions(id))
      await context.queryClient.query(customerQueryOptions(customerId))
    
    ✓ await Promise.all([
        context.queryClient.query(orderQueryOptions(id)),
        context.queryClient.query(customerQueryOptions(customerId)),
      ])
  • 如果读取
    b
    需要读取
    a
    的结果,则该请求瀑布无法消除:
    a
    b
    Loader
    关键读取关键读取
    const x = await query(a)
    ,然后
    await query(b(x))
    关键读取未等待读取
    const x = await query(a)
    ,然后
    void query(b(x)).catch(noop)
    ;这个未等待读取在
    await
    之后启动
    未等待读取未等待读取
    void query(a).then((x) => query(b(x))).catch(noop)
    ,在第一次
    await
    之前启动
  • Loader 和组件应使用同一个
    queryOptions
    对象。组件使用不同的 key 时,会再次获取数据。
  • 对于未登录访问者也能打开的 route,仅在已登录分支中启动需要 session 的读取。否则每次未登录访问都会触发抛出错误的调用。
  • 替换已弃用的
    QueryClient
    方法:
    已弃用方法替代方案
    fetchQuery(options)
    query(options)
    prefetchQuery(options)
    void query(options).catch(noop)
    ensureQueryData(options)
    query({ ...options, staleTime: "static" })
    ,其行为相同。如果 mutation 会使该 key 失效,则改用
    staleTime: Infinity
    :后续 loader 使用
    "static"
    时会返回已经失效但仍被缓存的数据,导致 route 根据旧数据决定分支、重定向或拦截,而
    Infinity
    会先重新获取数据。传递给单次
    query()
    调用的
    staleTime
    不会附着在该 query 上;失效后是否重新获取已挂载页面的数据,取决于组件的
    useSuspenseQuery
    staleTime
    fetchInfiniteQuery(options)
    infiniteQuery(options)
    prefetchInfiniteQuery(options)
    void infiniteQuery(options).catch(noop)
    ensureInfiniteQueryData(options)
    infiniteQuery(...)
    ,并采用与
    ensureQueryData
    相同的
    staleTime
    选择
  • 不要将读取推迟到滚动或交互时,也不要在 router 的
    defaultPreload: "intent"
    之外添加 hover 预取。未等待读取本身已经以流式方式传输,不会阻塞页面;将其推迟只会增加一次渲染后的请求,而且没有任何 TanStack 文档定义这种延迟行为在
    useSuspenseQuery
    下的表现。

Streaming and SSR

流式传输与 SSR

Scope: the router file and every route's
ssr
option.
  • The rules here hold only when the router file calls
    setupRouterSsrQueryIntegration
    from
    @tanstack/react-router-ssr-query
    . If it does not, report that as the first finding.
  • An unawaited read runs on the server and streams to the client without blocking the server response. A critical read blocks the response until it resolves.
  • useSuspenseQuery
    and loader reads run on the server.
    useQuery
    does not; its data loads after hydration, so a route component that reads with
    useQuery
    is a waterfall.
  • ssr: "data-only"
    runs
    beforeLoad
    and
    loader
    on the server and renders the component only on the client. Do not rely on unawaited reads streaming under
    ssr: "data-only"
    , and do not recommend it as a fix; no official doc states the behaviour. Check it in the running app when the task depends on it.
  • Do not configure route code splitting: Start always enables it, and the loader stays in the main chunk.
适用范围:router 文件以及每个 route 的
ssr
选项。
  • 仅当 router 文件调用了
    @tanstack/react-router-ssr-query
    中的
    setupRouterSsrQueryIntegration
    时,此处的规则才成立。如果未调用,应首先报告此问题。
  • 未等待读取在服务端运行并流向客户端,不会阻塞服务端响应。关键读取会阻塞响应,直至解析完成。
  • useSuspenseQuery
    和 loader 读取在服务端运行。
    useQuery
    不会在服务端运行;其数据会在 hydration 后加载,因此使用
    useQuery
    读取数据的 route 组件会形成请求瀑布。
  • ssr: "data-only"
    会在服务端运行
    beforeLoad
    和
    loader
    ,并且仅在客户端渲染组件。不要依赖未等待读取在该模式下进行流式传输,也不要将其作为修复建议;没有任何官方文档说明此行为。当任务依赖于具体表现时,应在实际运行的应用中检查。
  • 不要配置 route 代码分割:Start 始终会启用它,而 loader 会保留在 main chunk 中。

Components

组件

Scope: components that read query data with suspense.
  • Two or more suspense queries in one component: one
    useSuspenseQueries
    call. Separate
    useSuspenseQuery
    calls in one component are a waterfall: the second starts after the first resolves.
    tsx
    ✗ const { data: order } = useSuspenseQuery(orderQueryOptions(id))
      const { data: notes } = useSuspenseQuery(notesQueryOptions(id))
    
    ✓ const [{ data: order }, { data: notes }] = useSuspenseQueries({
        queries: [orderQueryOptions(id), notesQueryOptions(id)],
      })
  • A child that suspends on a query its route could know about: start that read in the route loader. Otherwise the child's request starts only after the parent renders.
  • Each unawaited read renders inside its own Suspense section:
    <CatchBoundary getResetKey={...} errorComponent={...}>
    from
    @tanstack/react-router
    around
    <React.Suspense fallback={...}>
    , inside
    QueryErrorResetBoundary
    where the error UI offers a retry. The rest of the page renders while the section streams, and its error stays inside it. A critical read needs no Suspense section.
  • A query key that changes on the same route (search param, tab, page, period): make the change inside
    React.startTransition
    .
    useSuspenseQuery
    has no
    placeholderData
    ; without the transition, React replaces the content with the fallback.
  • pendingUi: route-level
    : the router sets
    defaultPendingComponent
    , rendered inside the app's shell layout. It renders after
    defaultPendingMs
    (default 1000) and stays at least
    defaultPendingMinMs
    (default 500).
    pendingUi: per-section
    : add no
    defaultPendingComponent
    ; if the user chose this while the router sets one, leave it until they ask for its removal.
适用范围:使用 suspense 读取 query 数据的组件。
  • 一个组件中存在两个或更多 suspense query 时,使用一次
    useSuspenseQueries
    。在同一组件中分别调用
    useSuspenseQuery
    会形成请求瀑布:第二个调用会在第一个解析完成后才开始。
    tsx
    ✗ const { data: order } = useSuspenseQuery(orderQueryOptions(id))
      const { data: notes } = useSuspenseQuery(notesQueryOptions(id))
    
    ✓ const [{ data: order }, { data: notes }] = useSuspenseQueries({
        queries: [orderQueryOptions(id), notesQueryOptions(id)],
      })
  • 如果子组件会因 route 已知的数据而 suspend,则应在 route loader 中启动该读取。否则子组件的请求只会在父组件渲染后开始。
  • 每个未等待读取都应在独立的 Suspense section 中渲染:使用
    @tanstack/react-router
    中的
    <CatchBoundary getResetKey={...} errorComponent={...}>
    包裹
    <React.Suspense fallback={...}>
    ;如果错误 UI 提供重试功能,则将其放在
    QueryErrorResetBoundary
    内。section 流式传输期间,页面其余部分仍可渲染,并且错误会保留在该 section 内。关键读取不需要 Suspense section。
  • 同一 route 中的 query key 发生变化时(搜索参数、标签页、页码、时间范围),应在
    React.startTransition
    内进行更改。
    useSuspenseQuery
    没有
    placeholderData
    ;如果没有 transition,React 会用 fallback 替换原有内容。
  • pendingUi: route-level
    :router 设置了
    defaultPendingComponent
    ,并在应用的 shell layout 内渲染。它会在
    defaultPendingMs
    之后渲染(默认 1000),并且至少保留
    defaultPendingMinMs
    (默认 500)。
    pendingUi: per-section
    :不要添加
    defaultPendingComponent
    ;如果用户选择了此模式但 router 已设置该组件,则保持现状,直到用户要求将其移除。

Mutations

Mutation

Scope: every
useMutation
.
  • The server function returns the saved entity.
    onSuccess
    writes it with
    queryClient.setQueryData(key, saved)
    .
  • onSuccess
    invalidates only keys derived from the change (lists, counts, totals) and does not await the invalidation. A promise returned or awaited in
    onSuccess
    keeps
    isPending
    true until every invalidated query has refetched.
    ts
    ✗ onSuccess: async () => {
        await queryClient.invalidateQueries({ queryKey: orderKeys.all })
      }
    
    ✓ onSuccess: (saved) => {
        queryClient.setQueryData(orderKeys.detail(saved.id), saved)
        void queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
      }
  • Never invalidate the key just written; invalidation refetches it. To invalidate a prefix that contains it, skip the written key with a
    predicate
    :
    ts
    void queryClient.invalidateQueries({
      queryKey: orderKeys.all,
      predicate: (query) =>
        query.queryKey.length !== written.length ||
        query.queryKey.some((part, index) => part !== written[index]),
    })
  • Saves to one entity that can overlap (autosave, rapid toggles): set
    scope: { id }
    to a string naming the entity, such as
    `order-${orderId}`
    . Mutations with the same
    scope.id
    run one at a time in call order. A refetch after each save does not fix ordering: the responses can still arrive out of order.
  • A mutation that changes which data the user may see (switching account or workspace) awaits its invalidation, so the next screen never renders with the previous account's data. Every other mutation follows the rule above.
适用范围:每个
useMutation
。
  • Server function 返回已保存的实体。
    onSuccess
    使用
    queryClient.setQueryData(key, saved)
    写入该实体。
  • onSuccess
    仅使由本次更改派生出的 key(列表、计数、总计)失效,并且不等待失效操作完成。在
    onSuccess
    中返回或 await Promise,会使
    isPending
    一直保持为 true,直到所有被失效的 query 都完成重新获取。
    ts
    ✗ onSuccess: async () => {
        await queryClient.invalidateQueries({ queryKey: orderKeys.all })
      }
    
    ✓ onSuccess: (saved) => {
        queryClient.setQueryData(orderKeys.detail(saved.id), saved)
        void queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
      }
  • 绝不要使刚刚写入的 key 失效;失效操作会重新获取它。如果需要使包含该 key 的前缀失效,请使用
    predicate
    跳过刚写入的 key:
    ts
    void queryClient.invalidateQueries({
      queryKey: orderKeys.all,
      predicate: (query) =>
        query.queryKey.length !== written.length ||
        query.queryKey.some((part, index) => part !== written[index]),
    })
  • 如果对同一个可能发生重叠的实体执行保存操作(autosave、快速切换),应设置
    scope: { id }
    ,并使用标识该实体的字符串,例如
    `order-${orderId}`
    。具有相同
    scope.id
    的 mutation 会按照调用顺序逐一执行。每次保存后重新获取无法解决顺序问题,因为响应仍可能乱序到达。
  • 如果 mutation 会改变用户有权查看的数据(切换账户或 workspace),则应等待失效操作完成,确保下一个界面绝不会先渲染旧账户的数据。其他 mutation 均遵循上述规则。

Server functions

Server function

Scope: every
createServerFn
.
  • Put
    createServerFn
    exports in
    *.functions.ts
    . Client code may import them; the build replaces each handler with a request stub. Server-only helpers go in
    *.server.ts
    , which import protection blocks from client code.
  • Validate input:
    .validator(z.object({ ... }))
    .
  • Each call from the client is one HTTP request with a fixed cost: the request, plus every middleware it runs. Start neither batches nor deduplicates calls:
    Promise.all
    over three server functions sends three concurrent requests, and two identical calls send two. Deduplicate identical reads through the query cache by calling the server function only from a
    queryFn
    .
  • Do not merge every read on a page into one server function. The page then waits for its slowest part, Suspense sections stop streaming separately, and every mutation refetches all of it.
  • Middleware runs on every call to every server function that uses it. Shared middleware computes only what every one of those functions reads; a value only some read goes in its own middleware.
  • Statements sent on one database connection, including every statement in one transaction, run one at a time.
    Promise.all
    over them saves no time; reduce the number of statements instead.
  • Apply the backend reference named under Discovery, "Backend rules", to every server function.
适用范围:每个
createServerFn
。
  • 将
    createServerFn
    导出放在
    *.functions.ts
    中。客户端代码可以导入它们;构建过程会将每个 handler 替换为 request stub。仅供服务端使用的 helper 应放在
    *.server.ts
    中,其导入保护机制会阻止客户端代码导入。
  • 验证输入:
    .validator(z.object({ ... }))
    。
  • 客户端的每次调用都会发起一个具有固定成本的 HTTP 请求:请求本身,以及它所经过的每项 middleware。Start 既不会批处理调用,也不会对调用去重:对三个 server function 使用
    Promise.all
    会发送三个并发请求,而两次相同的调用也会发送两个请求。通过 query cache 对相同读取进行去重,方法是只在
    queryFn
    中调用 server function。
  • 不要将页面上的所有读取合并到一个 server function 中。否则页面必须等待其中最慢的部分,Suspense section 无法分别流式传输,而且每次 mutation 都会重新获取全部数据。
  • Middleware 会在每次调用使用它的 server function 时运行。共享 middleware 只计算所有这些 function 都会读取的内容;仅部分 function 读取的值应放入各自的 middleware。
  • 通过同一个 database connection 发送的语句,包括同一 transaction 中的所有语句,都会依次执行。对它们使用
    Promise.all
    无法节省时间;应改为减少语句数量。
  • 对每个 server function 应用发现流程中“Backend 规则”所指定的 backend 参考资料。

Gotchas

常见陷阱

  • useSuspenseQueries
    does not accept infinite queries. An infinite query in the same component still starts after the others resolve; start it in the loader.
  • After converting to
    useSuspenseQueries
    , every test mock of
    @tanstack/react-query
    must export
    useSuspenseQueries
    .
  • toBe
    on cached rows fails: structural sharing returns an equal copy, not the same object. Use
    toEqual
    .
  • Once a loader awaits a read, a 404 from it right after a write (a backend that updates its read model asynchronously) shows the route's error component instead of an error inside a Suspense section.
  • useSuspenseQueries
    不接受 infinite query。同一组件中的 infinite query 仍会在其他 query 解析完成后才开始;应在 loader 中启动它。
  • 转换为
    useSuspenseQueries
    后,每个 mock
    @tanstack/react-query
    的测试都必须导出
    useSuspenseQueries
    。
  • 对缓存行使用
    toBe
    会失败:结构共享返回的是内容相等的副本,而不是同一个对象。请使用
    toEqual
    。
  • Loader 一旦 await 了某次读取,写入后立即由该读取返回的 404(backend 异步更新 read model)会显示 route 的 error component,而不是显示在 Suspense section 内部。