-
Fetch on navigation in route loaders (SSR + streaming); optionally seed via
queryClient.ensureQueryData. [1] -
Do server work on the server via TanStack Start server functions; after mutations call
router.invalidate()and/orqueryClient.invalidateQueries(). [2] -
Keep page/UI state in the URL with typed search params (
validateSearch,Route.useSearch,navigate). [3] -
Reserve effects for real external effects only (DOM, subscriptions, analytics). Compute derived state during render;
useMemoonly if expensive. [4][6] -
Hydration + Suspense: any update that suspends during hydration replaces SSR content with fallbacks. Wrap sync updates that might suspend in
startTransition(direct import). Avoid renderingisPendingduring hydration.useSyncExternalStorealways triggers fallbacks during hydration. [10] -
Data placement:
- Server-synced domain data → TanStack DB collections (often powered by TanStack Query via
queryCollectionOptions, or a sync engine). Read with live queries. [11][12][14] - Ephemeral UI/session (theme, modals, steppers, optimistic buffers) → zustand or local-only/localStorage collection. Do not mirror server data into zustand. [16][14]
- Derived views → compute in render or via live queries. [12]
- Server-synced domain data → TanStack DB collections (often powered by TanStack Query via
- Fetch on mount/param change → route loader (+
ensureQueryData). [1] - Submit/mutate → server function → then
router.invalidate()/qc.invalidateQueries(). [2] - Sync UI ↔ querystring → typed search params +
navigate. [3] - Derived state → compute during render (
useMemoonly if expensive). [4] - Subscribe external stores →
useSyncExternalStore(expect hydration fallbacks). [5][10] - DOM/listeners/widgets → small
useEffect/useLayoutEffect. [6] - Synced list + optimistic UI → DB query collection +
onInsert/onUpdate/onDeleteor server fn + invalidate. [11][13] - Realtime websocket/SSE patches → TanStack DB direct writes (
writeInsert/update/delete/upsert/batch). [13] - Joins/aggregations → live queries. [12]
- Local-only prefs/cross-tab → localStorage collection (no effects). [14]
- Loader:
queryClient.ensureQueryData(queryOptions({ queryKey, queryFn }))→ read viauseSuspenseQueryhydrated from loader. [1] - DB query collection:
createCollection(queryCollectionOptions({ queryKey, queryFn, queryClient, getKey }))→ read via live query. [11][12] - Mutation (server-first):
createServerFn(...).handler(...)→ on successqc.invalidateQueries,router.invalidate; supports<form action={serverFn.url}>. [2] - DB persistence handlers:
onInsert/onUpdate/onDelete→ return{ refetch?: boolean }; pair with direct writes when skipping refetch. [13] - Search params as state:
validateSearch → Route.useSearch → navigate({ search }). [3] - External store read:
useSyncExternalStore(subscribe, getSnapshot). [5] - Hydration-safe:
import { startTransition } from 'react'for sync updates; avoiduseTransition/isPendingduring hydration. [10]
- Needed at render → loader (defer/stream). [1][7]
- User changed data → server fn → invalidate; or DB handlers/direct writes. [2][13]
- Belongs in URL → typed search params. [3]
- Purely derived → render/live query. [4][12]
- External system only → effect. [6]
- Hydration sensitive →
startTransitionfor sync updates; expect fallbacks from external stores; avoidisPendingduring hydration. [10] - SSR/SEO → loader-based fetching with streaming/deferred; dehydrate/hydrate caches and DB snapshots. [7]
useActionStatefor form pending/error/result. [8]useto suspend on promises. [9]
- Rule: sync updates that suspend during hydration → fallback replaces SSR.
- Quick fix: wrap updates with
startTransition(direct import); re-wrap afterawait. - Avoid during hydration: using
useTransitionfor the update, renderingisPending,useDeferredValueunless the suspensey child is memoized, anyuseSyncExternalStoremutation. - Safe during hydration: setting same value with
useState/useReducer,startTransition-wrapped sync updates,useDeferredValuewithReact.memoaround the suspensey child. - Compiler auto-memoization may help; treat as optimization.
-
Use DB for server-synced domain data.
-
Load:
queryCollectionOptions(simple fetch; optional refetch) or sync collections (Electric/Trailbase/RxDB). -
Read: live queries (reactive, incremental; joins,
groupBy,distinct,order,limit). [12] -
Writes:
- Server-first → server fn →
router.invalidate()/qc.invalidateQueries(). [2] - Client-first →
onInsert/onUpdate/onDelete(return{ refetch: false }if reconciling via direct writes/realtime). [13] - Direct writes →
writeInsert/update/delete/upsert/batchfor websocket/SSE deltas, incremental pagination, server-computed fields; bypass optimistic layer and skip refetch. [13]
- Server-first → server fn →
-
Behaviors: query collection treats
queryFnresult as full state; empty array deletes all; merge partial fetches before returning. [13] -
Transaction merging reduces churn:
- insert+update → merged insert
- insert+delete → cancel
- update+delete → delete
- update+update → single union
- same type back-to-back → keep latest [15]
-
SSR: per-request store instances; never touch storage during SSR. [16][14]
- In loaders: seed query via
ensureQueryData; for DB, preload or dehydrate/hydrate snapshots so lists render instantly and stream updates. [1][7][12][14] - After mutations: loader-owned → invalidate router/query; DB-owned → let collection refetch or apply direct writes. [2][13]
- Avoid first-click spinner after SSR: wrap clicks with
startTransition; don't renderisPendinguntil post-hydration. [10] - External store during hydration: defer interaction or isolate the suspense boundary; expect fallbacks. [5][10]
- Paginated load-more: fetch next page, then
collection.utils.writeBatch(() => writeInsert(...))to append without refetching old pages. [13] - Realtime patches:
writeUpsert/writeDeletefrom socket callback insidewriteBatch. [13]
- Default
ssr: true(change viagetRouter({ defaultSsr: false })). SPA mode disables all server loaders/SSR. - Per-route
ssr:true|'data-only'|false. - Functional
ssr(props): runs only on server initial request; can returntrue|'data-only'|falsebased on validated params/search. - Inheritance: child can only get less SSR (true →
'data-only'or false;'data-only'→ false). - Fallback: first route with
ssr: falseor'data-only'renderspendingComponent(ordefaultPendingComponent) at leastminPendingMs(ordefaultPendingMinMs). - Root: you can disable SSR of root route component;
shellComponentis always SSRed.
- Use for client/UI/session and push-based domain state (theme, modals, wizards, optimistic UI, websocket buffers). Keep server data in loaders/Query.
- Per-request store instance to avoid SSR leaks; inject via Router context; dehydrate/hydrate via
router.dehydrate/router.hydrateso snapshots stream with the page. - After navigation resolution, clear transient UI with
router.subscribe('onResolved', ...). - Mutations: do work in server fn → optionally update store optimistically →
router.invalidateto reconcile with loader data. - Persist middleware only for client/session; avoid touching storage during SSR.
- Use atomic selectors (
useStore(s => slice)) and equality helpers.
- Use pnpm.
- All route files are TypeScript React (
.tsx). - Use alias imports:
~resolves to root./src. - Never update
.env; update.env.exampleinstead. - Never start the dev server with
pnpm run devornpm run dev. - Never create a local pnpm --store
[1] router data loading · [2] server functions · [3] search params · [4] you might not need an effect · [5] useSyncExternalStore · [6] synchronizing with effects · [7] SSR/streaming · [8] useActionState · [9] use · [10] hydration + suspense guide · [11] TanStack DB query collection · [12] live queries · [13] direct writes + persistence handlers · [14] collections catalog · [15] transactions + optimistic actions · [16] zustand in TanStack Start