TanStack data loading
The first argument selects the mode. With no mode argument, infer it: a request to audit, find waterfalls or review existing code is
; a request to rediscover or bust the cache is
; anything else is writing.
- — read references/audit.md before starting, and run it over .
- — rerun Discovery for the app containing , write the result, and stop.
- Writing — apply the rules below to the code being written or reviewed, within 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.
Available scripts
- — and the discovery cache at
<repo-root>/.draekien/tanstack-data-loading/discovery.json
, one discovery entry per app root; prints the installed versions. lists the exit codes.
Discovery
App root. The nearest ancestor of
(or of the current directory) whose
depends on
. 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>
| Exit | Meaning | Next |
|---|
| 0 | Fresh discovery entry printed | Use it, and apply its in place of the rules they name. In , discover and write anyway. |
| 4 | No discovery entry | Discover and write. |
| 5 | Older than the script's maximum age, or an installed version changed | Discover and write. |
| 3 | Cache file or an installed package manifest unreadable | Read 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. |
| 2 | Usage error | Read stderr, correct the call, rerun. |
Discover. Find each field from the code:
| Field | Values | How to find it |
|---|
| , , | Read the handlers. : they query a database client directly. : they call another service over HTTP. : some do each. |
| , | when the router sets . Otherwise ask the user whether they want a route-level pending component or Suspense sections only; with no user available, record and say so. |
| list of commands | The repo's lint, typecheck and test commands from . |
| list of strings | Run 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 when there are none. Otherwise empty. If prints , ask the user to install dependencies before continuing. |
Write. The script adds
and the installed
, 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
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
; the discovery entries hold one developer's local discovery. If the user declines
, keep the discovered fields for this task only and say the discovery cache was not written.
Discovery is done when
or
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
:
- — references/backend-database.md.
- — references/backend-service.md.
- — read both. Apply each to the server functions whose handlers match it; a handler that does both applies each reference to its own part.
Versions
Rules were checked against
5.104.2,
1.170.44 and
1.169.1. Before applying a rule that uses an API below, compare the installed versions (the discovery entry's
, or the
command) to the minimum and use the substitute when the installed version is lower:
| Rule uses | Needs | Below the minimum |
|---|
, queryClient.infiniteQuery
| 5.102.0 | , , and their infinite variants, which are not deprecated there |
| 5.79.0 | , which behaves the same in a loader |
import { noop } from "@tanstack/react-query"
| 5.76.0 | |
| mutation | 5.32.0 | disable the trigger while |
createServerFn().validator(...)
| 1.168.25 | ; versions from 1.169.0 accept only |
| blocked from client imports | 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
, read
references/versions.md before applying any rule.
Loaders
Scope: the
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
or
before render; let the error reach them, or throw
.
-
Unawaited read, including hidden variants such as a toggle's second period:
void context.queryClient.query(options).catch(noop)
. Without
the rejection is unhandled on the server; the component's
still rethrows the error to its boundary.
-
Start every unawaited read before the first
. A read started after an
is a waterfall.
-
Independent critical reads: one
.
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
that needs read
's result is a waterfall that cannot be removed:
| | Loader |
|---|
| critical | critical | , then |
| critical | unawaited | , then void query(b(x)).catch(noop)
: the one unawaited read started after an |
| unawaited | unawaited | void query(a).then((x) => query(b(x))).catch(noop)
, started before the first |
-
The loader and the component use the same
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
methods:
| Deprecated | Replacement |
|---|
| |
| void query(options).catch(noop)
|
| query({ ...options, staleTime: "static" })
, which behaves the same. If a mutation invalidates the key, use instead: a later loader run with returns the invalidated cached data, so the route decides its branch, redirect or gate from old data, while refetches first. A passed to one call does not stay on the query: whether invalidation refetches a mounted page depends on the component's |
fetchInfiniteQuery(options)
| |
prefetchInfiniteQuery(options)
| void infiniteQuery(options).catch(noop)
|
ensureInfiniteQueryData(options)
| with the same choice as |
-
Do not defer reads to scroll or interaction, and do not add hover prefetching beyond the router's
. 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
.
Streaming and SSR
Scope: the router file and every route's
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.
- and loader reads run on the server. does not; its data loads after hydration, so a route component that reads with is a waterfall.
- runs and on the server and renders the component only on the client. Do not rely on unawaited reads streaming under , 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.
Components
Scope: components that read query data with suspense.
-
Two or more suspense queries in one component: one
call. Separate
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
around
<React.Suspense fallback={...}>
, inside
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
.
has no
; without the transition, React replaces the content with the fallback.
-
: the router sets
, rendered inside the app's shell layout. It renders after
(default 1000) and stays at least
(default 500).
: add no
; if the user chose this while the router sets one, leave it until they ask for its removal.
Mutations
-
The server function returns the saved entity.
writes it with
queryClient.setQueryData(key, saved)
.
-
invalidates only keys derived from the change (lists, counts, totals) and does not await the invalidation. A promise returned or awaited in
keeps
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
:
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
to a string naming the entity, such as
. Mutations with the same
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.
Server functions
- Put exports in . Client code may import them; the build replaces each handler with a request stub. Server-only helpers go in , 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: 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 .
- 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. over them saves no time; reduce the number of statements instead.
- Apply the backend reference named under Discovery, "Backend rules", to every server function.
Gotchas
- 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 , every test mock of must export .
- on cached rows fails: structural sharing returns an equal copy, not the same object. Use .
- 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.