About tanstack-data-loading
tanstack-data-loading is a coding skill for TanStack React Start projects, guiding data-loading implementation and review. It discovers project details, checks versions, and applies rules across loaders, SSR, components, and mutations.
Key features
- 01Writing, audit, and refresh modes
- 02App discovery with a local cache
- 03TanStack dependency version checks
- 04Loader concurrency and error handling guidance
- 05Rules for SSR, components, and mutations
Use cases
- 01Audit route loaders for request waterfalls
- 02Refresh an app’s data-loading discovery record
- 03Write server functions for the detected backend
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ draekien/ skills --skill tanstack-data-loadingAfter installing, type/tanstack-data-loadingin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/audit.mdbackend-database.mdbackend-service.mdversions.mdscripts/discovery.pyDetails
From the project READMETanStack 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 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 overpath.refresh [path]— rerun Discovery for the app containingpath, write the result, and stop.- Writing — apply the rules below to the code being written or reviewed, within
pathif 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
scripts/discovery.py—readandwritethe discovery cache at<repo-root>/.draekien/tanstack-data-loading/discovery.json, one discovery entry per app root;versionsprints the installed@tanstackversions.--helplists the exit codes.
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):
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 versionNotes in place of the rules they name. In refresh, discover and write anyway. |
| 4 | No discovery entry | Discover and write. |
| 5 | Older than the script's maximum age, or an installed @tanstack 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 |
|---|---|---|
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 commands | The repo's lint, typecheck and test commands from package.json scripts. |
versionNotes | 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 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:
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.
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 uses | Needs | Below 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.
Loaders
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 tonotFoundComponentorerrorComponentbefore render; let the error reach them, or thrownotFound(). -
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'suseSuspenseQuerystill rethrows the error to its boundary. -
Start every unawaited read before the first
await. A read started after anawaitis a waterfall. -
Independent critical reads: one
await Promise.all([...]).✗ 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
bthat needs reada's result is a waterfall that cannot be removed:abLoader critical critical const x = await query(a), thenawait query(b(x))critical unawaited const x = await query(a), thenvoid query(b(x)).catch(noop): the one unawaited read started after anawaitunawaited unawaited void query(a).then((x) => query(b(x))).catch(noop), started before the firstawait -
The loader and the component use the same
queryOptionsobject. 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
QueryClientmethods:Deprecated Replacement 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, usestaleTime: Infinityinstead: a later loader run with"static"returns the invalidated cached data, so the route decides its branch, redirect or gate from old data, whileInfinityrefetches first. AstaleTimepassed to onequery()call does not stay on the query: whether invalidation refetches a mounted page depends on the component'suseSuspenseQuerystaleTimefetchInfiniteQuery(options)infiniteQuery(options)prefetchInfiniteQuery(options)void infiniteQuery(options).catch(noop)ensureInfiniteQueryData(options)infiniteQuery(...)with the samestaleTimechoice asensureQueryData -
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 withuseSuspenseQuery.
Streaming and SSR
Scope: the router file and every route's ssr option.
- The rules here hold only when the router file calls
setupRouterSsrQueryIntegrationfrom@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.
useSuspenseQueryand loader reads run on the server.useQuerydoes not; its data loads after hydration, so a route component that reads withuseQueryis a waterfall.ssr: "data-only"runsbeforeLoadandloaderon the server and renders the component only on the client. Do not rely on unawaited reads streaming underssr: "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.
Components
Scope: components that read query data with suspense.
-
Two or more suspense queries in one component: one
useSuspenseQueriescall. SeparateuseSuspenseQuerycalls in one component are a waterfall: the second starts after the first resolves.✗ 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-routeraround<React.Suspense fallback={...}>, insideQueryErrorResetBoundarywhere 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.useSuspenseQueryhas noplaceholderData; without the transition, React replaces the content with the fallback. -
pendingUi: route-level: the router setsdefaultPendingComponent, rendered inside the app's shell layout. It renders afterdefaultPendingMs(default 1000) and stays at leastdefaultPendingMinMs(default 500).pendingUi: per-section: add nodefaultPendingComponent; if the user chose this while the router sets one, leave it until they ask for its removal.
Mutations
Scope: every useMutation.
-
The server function returns the saved entity.
onSuccesswrites it withqueryClient.setQueryData(key, saved). -
onSuccessinvalidates only keys derived from the change (lists, counts, totals) and does not await the invalidation. A promise returned or awaited inonSuccesskeepsisPendingtrue until every invalidated query has refetched.✗ 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: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 samescope.idrun 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
Scope: every createServerFn.
- Put
createServerFnexports 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.allover 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 aqueryFn. - 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.allover them saves no time; reduce the number of statements instead. - Apply the backend reference named under Discovery, "Backend rules", to every server function.
Gotchas
useSuspenseQueriesdoes 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-querymust exportuseSuspenseQueries. toBeon cached rows fails: structural sharing returns an equal copy, not the same object. UsetoEqual.- 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.