tanstack
Builds type-safe React apps with TanStack Query (data fetching, caching, mutations), Router (file-based routing, search params, loaders), and Start (SSR, server functions, middleware). Use when working with react-query, server state, file-based routing, typed search params, route
Install
npx skills add https://github.com/tenequm/skills/tree/main/skills/tanstack
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tenequm-skills@llmmart
git clone https://github.com/tenequm/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tenequm/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
TanStack (Query + Router + Start)
Type-safe libraries for React applications. Query manages server state (fetching, caching, mutations). Router provides file-based routing with validated search params and data loaders. Start extends Router with SSR, server functions, and middleware for full-stack apps.
When to Use
Query - data fetching, caching, mutations, optimistic updates, infinite scroll, streaming AI/SSE responses, tRPC v11 integration Router - file-based routing, type-safe navigation, validated search params, route loaders, code splitting, preloading Start - SSR/SSG, server functions (type-safe RPCs), middleware, API routes, deployment to Cloudflare/Vercel/Node
Decision tree:
- Client-only SPA with API calls -> Router + Query
- Full-stack with SSR/server functions -> Start + Query (Start includes Router)
TanStack Query v5
Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
Queries
import { useQuery, queryOptions } from '@tanstack/react-query'
// Reusable query definition (recommended pattern)
const todosQueryOptions = queryOptions({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error('Failed to fetch')
return res.json() as Promise<Todo[]>
},
})
// In component - full type inference from queryOptions
function TodoList() {
const { data, isLoading, error } = useQuery(todosQueryOptions)
if (isLoading) return <Spinner />
if (error) return <div>Error: {error.message}</div>
return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
}
Mutations
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreateTodo() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newTodo: { title: string }) =>
fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }).then(r => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return (
<button onClick={() => mutation.mutate({ title: 'New' })}>
{mutation.isPending ? 'Creating...' : 'Create'}
</button>
)
}
Key Patterns
Query keys - hierarchical arrays for cache management:
['todos'] // all todos
['todos', 'list', { page, sort }] // filtered list
['todo', todoId] // single item
Dependent queries - chain with enabled:
const { data: user } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user!.id),
enabled: !!user?.id,
})
Important defaults: staleTime: 0, gcTime: 5min, retry: 3, refetchOnWindowFocus: true
Suspense - use useSuspenseQuery with <Suspense> boundaries
Streamed queries (experimental) - for AI chat/SSE:
import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query'
const { data: chunks } = useQuery(queryOptions({
queryKey: ['chat', sessionId],
queryFn: streamedQuery({ streamFn: () => fetchChatStream(sessionId), refetchMode: 'reset' }),
}))
DevTools
pnpm add @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
// Add inside QueryClientProvider
<ReactQueryDevtools initialIsOpen={false} />
Query Deep Dives
query-guide.md- Complete Query reference with all patternsinfinite-queries.md- useInfiniteQuery, pagination, virtual scrolloptimistic-updates.md- Optimistic UI, rollback, undoquery-performance.md- staleTime tuning, deduplication, prefetchingquery-invalidation.md- Cache invalidation strategies, filters, predicatesquery-typescript.md- Type inference, generics, custom hooks
TanStack Router v1
Setup (Vite)
pnpm add @tanstack/react-router @tanstack/router-plugin
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
tanstackRouter({ autoCodeSplitting: true }),
react(),
],
})
// src/router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({ routeTree, defaultPreload: 'intent' })
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
File-Based Routing
Files in src/routes/ auto-generate route config:
| Convention | Purpose | Example |
|---|---|---|
__root.tsx |
Root route (always rendered) | src/routes/__root.tsx |
index.tsx |
Index route | src/routes/index.tsx -> / |
$param |
Dynamic segment | posts.$postId.tsx -> /posts/:id |
_prefix |
Pathless layout | _layout.tsx wraps children |
(folder) |
Route group (no URL) | (auth)/login.tsx -> /login |
Type-Safe Navigation
<Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
// Active styling
<Link to="/posts" activeProps={{ className: 'font-bold' }}>Posts</Link>
// Imperative
const navigate = useNavigate({ from: '/posts' })
navigate({ to: '/posts/$postId', params: { postId: post.id } })
Always provide from on Link and hooks - narrows types and improves TS performance.
Search Params
import { zodValidator, fallback } from '@tanstack/zod-adapter'
import { z } from 'zod'
const searchSchema = z.object({
page: fallback(z.number(), 1).default(1),
sort: fallback(z.enum(['newest', 'oldest']), 'newest').default('newest'),
})
export const Route = createFileRoute('/products')({
validateSearch: zodValidator(searchSchema),
component: () => {
const { page, sort } = Route.useSearch()
// Writing
return <Link from={Route.fullPath} search={prev => ({ ...prev, page: prev.page + 1 })}>Next</Link>
},
})
Use fallback(...).default(...) from the Zod adapter (Zod v3); plain .catch() causes type loss. With Zod v4 the adapter is no longer needed - pass the schema directly to validateSearch, and .catch() retains type inference.
Data Loading
export const Route = createFileRoute('/posts')({
// loaderDeps: only extract what loader needs (not full search)
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ deps: { page } }) => fetchPosts({ page }),
pendingComponent: () => <Spinner />,
component: () => {
const posts = Route.useLoaderData()
return <PostList posts={posts} />
},
})
Route Context (Dependency Injection)
// __root.tsx
interface RouterContext { queryClient: QueryClient }
export const Route = createRootRouteWithContext<RouterContext>()({ component: Root })
// router.ts
const router = createRouter({ routeTree, context: { queryClient } })
// Child route - queryClient available in loader
export const Route = createFileRoute('/posts')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(postsQueryOptions()),
})
Router Deep Dives
router-guide.md- Complete Router reference with all patternssearch-params.md- Custom serialization, Standard Schema, sharing paramsdata-loading.md- Deferred loading, streaming SSR, shouldReloadrouting-patterns.md- Virtual routes, route masking, navigation blockingcode-splitting.md- Automatic/manual splitting strategiesrouter-ssr.md- SSR setup, streaming, hydration
TanStack Start
Full-stack framework extending Router with SSR, server functions, middleware. Pre-1.0 (API stable, feature-complete, preparing for 1.0). React Server Components are available as an experimental feature - opt in with tanstackStart({ rsc: { enabled: true } }) + @vitejs/plugin-rsc (requires React 19, Vite 7+). Vite is the default bundler; Rsbuild is also supported.
Setup
npx @tanstack/cli@latest create # or use TanStack Builder: https://tanstack.com/builder
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
tanstackStart(),
viteReact(), // MUST come after tanstackStart()
],
})
Server Functions
Type-safe RPCs. Server code extracted from client bundles at build time.
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
// GET - no input
export const getUsers = createServerFn({ method: 'GET' })
.handler(async () => db.users.findMany())
// POST - validated input
export const createUser = createServerFn({ method: 'POST' })
.validator(z.object({ name: z.string(), email: z.string().email() }))
.handler(async ({ data }) => db.users.create(data))
// Call from loader
export const Route = createFileRoute('/users')({
loader: () => getUsers(),
component: () => {
const users = Route.useLoaderData()
return <UserList users={users} />
},
})
Critical: Loaders are isomorphic (run on server AND client). Never put secrets in loaders - use createServerFn() instead.
Middleware
import { createMiddleware } from '@tanstack/react-start'
const authMiddleware = createMiddleware({ type: 'function' })
.server(async ({ next }) => {
const user = await getCurrentUser()
if (!user) throw redirect({ to: '/login' })
return next({ context: { user } })
})
const getProfile = createServerFn()
.middleware([authMiddleware])
.handler(async ({ context }) => context.user) // typed
Global middleware via src/start.ts:
export const startInstance = createStart(() => ({
requestMiddleware: [logger], // all requests
functionMiddleware: [auth], // all server functions
}))
CSRF: Start auto-installs createCsrfMiddleware() for server functions only when there is no src/start.ts. Once you create src/start.ts, add it back explicitly, or non-GET server functions lose same-origin protection:
requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }), logger]
SSR Modes
| Mode | Use Case |
|---|---|
true (default) |
SEO, performance |
false |
Browser-only features |
'data-only' |
Dashboards (data on server, render on client) |
SPA mode: tanstackStart({ spa: { enabled: true } }) in vite.config.ts
Deployment
- Official partners: Cloudflare Workers (
@cloudflare/vite-plugin), Netlify (@netlify/vite-plugin-tanstack-start), Railway - Node/Vercel/Bun/Docker: via Nitro
- Static:
tanstackStart({ prerender: { enabled: true, crawlLinks: true } })
Start Deep Dives
start-guide.md- Complete Start reference with all patternsserver-functions.md- Streaming, FormData, progressive enhancementmiddleware.md- sendContext, custom fetch, global configssr-modes.md- Selective SSR, shellComponent, fallback renderingserver-routes.md- Dynamic params, wildcards, pathless layouts
Best Practices
- Use
queryOptions()factory for reusable, type-safe query definitions - Structure query keys hierarchically -
['entity', 'action', { filters }] - Set staleTime per data type - static:
Infinity, dynamic:0, moderate:5min - Always validate search params with Zod via
zodValidator+fallback().default() - Provide
fromon navigation - narrows types, catches route mismatches - Use route context for DI - pass QueryClient, auth via
createRootRouteWithContext - Set
defaultPreload: 'intent'globally for perceived performance - Enforce auth at the data boundary - authorize inside the server function, server route, or API endpoint that reads/writes private data;
beforeLoad/route guards are UX, not the security boundary. Never put secrets in isomorphic loaders - usecreateServerFn() - Compose middleware hierarchically - global -> route -> function
- Use
head()on every content route for SEO (title, description, OG tags)
Resources
- Query Docs: https://tanstack.com/query/latest/docs/framework/react/overview
- Router Docs: https://tanstack.com/router/latest/docs/framework/react/overview
- Start Docs: https://tanstack.com/start/latest/docs/framework/react/overview
- GitHub: https://github.com/TanStack/query | https://github.com/TanStack/router
- Discord: https://discord.gg/tanstack
Files (skills)
-
references
-
code-splitting.md 15.6 KB
# Code Splitting and Preloading Optimize TanStack Router applications with code splitting, lazy loading, and preloading for faster initial loads and seamless navigation. Official docs: - https://tanstack.com/router/latest/docs/framework/react/guide/code-splitting - https://tanstack.com/router/latest/docs/framework/react/guide/automatic-code-splitting - https://tanstack.com/router/latest/docs/framework/react/guide/preloading - https://tanstack.com/router/latest/docs/framework/react/guide/render-optimizations ## Critical vs Non-Critical Route Configuration TanStack Router separates route configuration into two categories. **Critical (always in the main bundle)** - required to match the route and start data loading: - Path parsing and serialization - Search parameter validation - Loaders (`loader`) and before load hooks (`beforeLoad`) - Route context, static data, styles, scripts, links **Non-Critical (can be lazy-loaded)** - not required to match the route: - `component` - the route component - `errorComponent` - rendered when a loader or component throws - `pendingComponent` - rendered while the route is loading - `notFoundComponent` - rendered when a not-found error is thrown **Why the loader stays in the main bundle:** The loader is already an async boundary, so splitting it adds a second async hop (fetch the chunk, then execute the loader). Loaders are typically smaller than components and are critical for preloading on hover/touch - they must be available without additional async overhead. ## Automatic Code Splitting Enable it in the bundler plugin and TanStack Router handles everything. Only works with file-based routing and a supported bundler plugin (`@tanstack/router-plugin`), not with the standalone CLI or code-based routing. ```ts // vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { tanstackRouter } from '@tanstack/router-plugin/vite' export default defineConfig({ plugins: [ tanstackRouter({ autoCodeSplitting: true }), react(), // Must come AFTER the TanStack Router plugin ], }) ``` The plugin transforms each route file at build time into a **reference file** (original file rewritten with lazy-loading wrappers) and **virtual files** (minimal files containing only the code for a single property like `component`). ### Default Split Groupings Three separate lazy-loaded chunks are created per route by default: ```ts [['component'], ['errorComponent'], ['notFoundComponent']] ``` The `pendingComponent` and `loader` remain in the main bundle. ### Customizing Split Groupings **Global default behavior** - bundle all UI components into one chunk: ```ts // vite.config.ts tanstackRouter({ autoCodeSplitting: true, codeSplittingOptions: { defaultBehavior: [ ['component', 'pendingComponent', 'errorComponent', 'notFoundComponent'], ], }, }) ``` **Programmatic per-route-id control** with `splitBehavior`: ```ts tanstackRouter({ autoCodeSplitting: true, codeSplittingOptions: { splitBehavior: ({ routeId }) => { if (routeId.startsWith('/admin')) { return [['loader', 'component']] } // All other routes use the defaultBehavior }, }, }) ``` **Per-route override** with `codeSplitGroupings` inside the route file: ```tsx // src/routes/posts.tsx export const Route = createFileRoute('/posts')({ codeSplitGroupings: [['loader', 'component']], loader: () => fetchPosts(), component: PostsComponent, }) function PostsComponent() { const posts = Route.useLoaderData() return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul> } ``` **Precedence order:** Per-route `codeSplitGroupings` (highest) > `splitBehavior` function > `defaultBehavior` (lowest). ### Splitting the Data Loader (Automatic) Introduces an extra network round trip. Only recommended when a route's loader is unusually large: ```ts tanstackRouter({ autoCodeSplitting: true, codeSplittingOptions: { defaultBehavior: [['loader'], ['component'], ['errorComponent'], ['notFoundComponent']], }, }) ``` ### Rules for Automatic Code Splitting Do not export route properties. Exporting prevents the bundler from splitting them: ```tsx export const Route = createFileRoute('/posts')({ component: PostsComponent }) // BAD - exporting prevents code splitting export function PostsComponent() { return <div>Posts</div> } // GOOD - keep it as a local function function PostsComponent() { return <div>Posts</div> } ``` ## Manual Code Splitting with .lazy.tsx Use the `.lazy.tsx` convention when you cannot use automatic code splitting. The root route (`__root.tsx`) does not support code splitting. Split a route into two files - main for critical config, `.lazy.tsx` for non-critical: ```tsx // src/routes/posts.tsx - critical configuration import { createFileRoute } from '@tanstack/react-router' import { fetchPosts } from '../api' export const Route = createFileRoute('/posts')({ loader: fetchPosts, }) ``` ```tsx // src/routes/posts.lazy.tsx - non-critical (lazy-loaded) import { createLazyFileRoute } from '@tanstack/react-router' export const Route = createLazyFileRoute('/posts')({ component: Posts, }) function Posts() { const posts = Route.useLoaderData() return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul> } ``` `createLazyFileRoute` only supports: `component`, `errorComponent`, `pendingComponent`, `notFoundComponent`. Everything else must stay in the main route file. ### Multiple Non-Critical Properties ```tsx // src/routes/dashboard.lazy.tsx import { createLazyFileRoute } from '@tanstack/react-router' export const Route = createLazyFileRoute('/dashboard')({ component: Dashboard, errorComponent: DashboardError, pendingComponent: () => <div>Loading dashboard...</div>, notFoundComponent: () => <div>Dashboard not found</div>, }) function Dashboard() { return <div>Dashboard content</div> } function DashboardError({ error }: { error: Error }) { return <div>Error: {error.message}</div> } ``` ### Virtual Routes If a main route file becomes empty, delete it. TanStack Router generates a virtual route in the route tree: ```tsx // src/routes/about.lazy.tsx (no src/routes/about.tsx needed) import { createLazyFileRoute } from '@tanstack/react-router' export const Route = createLazyFileRoute('/about')({ component: () => <div>About page</div>, }) ``` ### Directory Encapsulation Move route files into a directory for organization. Behavior is identical: ``` src/routes/posts/route.tsx # was posts.tsx src/routes/posts/route.lazy.tsx # was posts.lazy.tsx ``` ## Code-Based Route Splitting For code-based routing, use `createLazyRoute` and the `.lazy()` method: ```tsx // src/posts.lazy.tsx import { createLazyRoute } from '@tanstack/react-router' export const Route = createLazyRoute('/posts')({ component: () => <div>Posts</div>, }) ``` ```tsx // src/app.tsx const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/posts', loader: () => fetchPosts(), }).lazy(() => import('./posts.lazy').then((d) => d.Route)) ``` **Manual loader splitting** with `lazyFn`: ```tsx import { createRoute, lazyFn } from '@tanstack/react-router' const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/posts', loader: lazyFn(() => import('./posts-loader'), 'loader'), }) ``` ```tsx // src/posts-loader.ts import type { LoaderContext } from '@tanstack/react-router' export const loader = async (context: LoaderContext) => { const response = await fetch('/api/posts') return response.json() } ``` ## Accessing Route APIs from Split Files Use `getRouteApi` for type-safe access to route hooks without importing the route: ```tsx import { getRouteApi } from '@tanstack/react-router' const postsRoute = getRouteApi('/posts') export function PostsPage() { const loaderData = postsRoute.useLoaderData() const search = postsRoute.useSearch() const params = postsRoute.useParams() return <div>{/* render */}</div> } ``` Available hooks: `useLoaderData`, `useSearch`, `useParams`, `useRouteContext`, `useMatch`, `useLoaderDeps`. ## Preloading Strategies Preloading loads a route's code and data before the user navigates to it. **Intent** (recommended default) - preloads on hover/touch of a `<Link>`: ```tsx const router = createRouter({ routeTree, defaultPreload: 'intent' }) ``` **Viewport** - uses Intersection Observer to preload when a `<Link>` scrolls into view: ```tsx <Link to="/posts" preload="viewport">Posts</Link> ``` **Render** - preloads as soon as the `<Link>` mounts in the DOM: ```tsx <Link to="/settings" preload="render">Settings</Link> ``` ## Preload Configuration ### Router-Level Defaults ```tsx const router = createRouter({ routeTree, defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false defaultPreloadDelay: 50, // ms before preloading starts (default: 50) defaultPreloadStaleTime: 30_000, // ms before preloaded data is re-fetched (default: 30_000) defaultPreloadMaxAge: 30_000, // ms before unused preloaded data is removed (default: 30_000) }) ``` ### Per-Link Overrides ```tsx <Link to="/posts/$postId" params={{ postId: post.id }} preload="intent" preloadDelay={100}> {post.title} </Link> ``` ### Per-Route Stale Time ```tsx // src/routes/posts.$postId.tsx export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => fetchPost(params.postId), preloadStaleTime: 10_000, // Re-preload if older than 10s }) ``` ### Disabling Preloading ```tsx <Link to="/expensive-route" preload={false}>Expensive Route</Link> ``` ### Preloading with External Libraries When using TanStack Query alongside TanStack Router, set `defaultPreloadStaleTime: 0` so React Query manages cache freshness: ```tsx const router = createRouter({ routeTree, defaultPreload: 'intent', defaultPreloadStaleTime: 0, // Let React Query handle stale/fresh decisions }) ``` ## Manual Preloading **`router.preloadRoute()`** - preload a route's code and data programmatically: ```tsx function Component() { const router = useRouter() useEffect(() => { router.preloadRoute({ to: '/posts/$postId', params: { postId: '1' }, }).catch(() => { /* preload is best-effort */ }) }, [router]) return <div /> } ``` **`router.loadRouteChunk()`** - load only the JS chunk without executing the loader: ```tsx function AppShell() { const router = useRouter() useEffect(() => { Promise.all([ router.loadRouteChunk(router.routesByPath['/dashboard']), router.loadRouteChunk(router.routesByPath['/settings']), ]).catch(() => {}) }, [router]) return <div>{/* app content */}</div> } ``` ## Render Optimizations ### Structural Sharing TanStack Router preserves referential identity for unchanged parts of URL-derived state. Navigating from `/details?foo=f1&bar=b1` to `/details?foo=f1&bar=b2`: - `search.foo` retains the same reference (referentially stable) - Only `search.bar` is replaced ### Fine-Grained Selectors with select Subscribe to a specific subset so the component only re-renders when that subset changes: ```tsx function DetailsPage() { // Only re-renders when foo changes, not when bar changes const foo = Route.useSearch({ select: (search) => search.foo }) return <div>Foo: {foo}</div> } ``` ### Structural Sharing with Selectors When `select` returns a new object, the component re-renders every time. Enable structural sharing to preserve referential stability: ```tsx // Enable globally const router = createRouter({ routeTree, defaultStructuralSharing: true }) // Or per hook const result = Route.useSearch({ select: (search) => ({ foo: search.foo, greeting: `hello ${search.foo}` }), structuralSharing: true, }) ``` **Constraint:** Structural sharing only works with JSON-compatible data. Class instances, Dates, Maps, and Sets are not supported. TypeScript will error if you return non-JSON values with `structuralSharing: true`. Disable it per hook with `structuralSharing: false`. ## Common Patterns ### Lazy-Loading a Heavy Component Keep the route lean, push heavy dependencies into the lazy file: ```tsx // src/routes/analytics.tsx export const Route = createFileRoute('/analytics')({ validateSearch: (s: Record<string, unknown>) => ({ range: (s.range as string) || '7d' }), loader: ({ context }) => context.api.fetchAnalytics(), }) ``` ```tsx // src/routes/analytics.lazy.tsx import { createLazyFileRoute } from '@tanstack/react-router' import { BarChart, LineChart, PieChart } from 'recharts' // Heavy - only loaded when needed export const Route = createLazyFileRoute('/analytics')({ component: AnalyticsDashboard, pendingComponent: () => <div>Loading analytics...</div>, }) function AnalyticsDashboard() { const data = Route.useLoaderData() return ( <div> <LineChart data={data.revenue} /> <BarChart data={data.signups} /> <PieChart data={data.sources} /> </div> ) } ``` ### Preloading on Hover for List-to-Detail Navigation ```tsx function PostCard({ post }: { post: Post }) { return ( <Link to="/posts/$postId" params={{ postId: post.id }} preload="intent" preloadDelay={75}> <h3>{post.title}</h3> <p>{post.excerpt}</p> </Link> ) } ``` ### Prefetching the Next Page in Pagination ```tsx export const Route = createFileRoute('/items')({ validateSearch: (s: Record<string, unknown>) => ({ page: Number(s.page) || 1 }), loaderDeps: ({ search }) => ({ page: search.page }), loader: async ({ deps }) => fetchItems(deps.page), component: ItemsList, }) function ItemsList() { const router = useRouter() const items = Route.useLoaderData() const { page } = Route.useSearch() useEffect(() => { if (items.hasNextPage) { router.preloadRoute({ to: '/items', search: { page: page + 1 } }) } }, [router, page, items.hasNextPage]) return ( <div> <ul>{items.data.map((item) => <li key={item.id}>{item.name}</li>)}</ul> <Link to="/items" search={{ page: page - 1 }} disabled={page === 1}>Previous</Link> <Link to="/items" search={{ page: page + 1 }} disabled={!items.hasNextPage}>Next</Link> </div> ) } ``` ### Preloading Critical Routes on App Startup ```tsx function AppLayout() { const router = useRouter() useEffect(() => { Promise.all([ router.loadRouteChunk(router.routesByPath['/dashboard']), router.loadRouteChunk(router.routesByPath['/settings']), ]).catch(() => {}) }, [router]) return <div><Sidebar /><main><Outlet /></main></div> } ``` ## Best Practices 1. **Start with automatic code splitting.** Set `autoCodeSplitting: true` in the Vite plugin. It handles the common case with zero route file changes. Only reach for manual `.lazy.tsx` splitting if you cannot use the bundler plugin. 2. **Keep loaders in the main bundle.** The double async hop from splitting a loader outweighs bundle size savings in most cases. The loader is critical for preloading performance. 3. **Enable intent preloading globally.** Setting `defaultPreload: 'intent'` is the single highest-impact preloading optimization. It eliminates perceived loading for most click-driven navigations. 4. **Do not export route properties.** Exporting `component`, `errorComponent`, or other split-eligible properties prevents the bundler from extracting them into separate chunks. 5. **Use `getRouteApi` in split files.** Access `useLoaderData`, `useSearch`, and `useParams` from `.lazy.tsx` files without importing the route definition directly. 6. **Set `defaultPreloadStaleTime: 0` with TanStack Query.** Defers cache freshness decisions to React Query's `staleTime`, avoiding double-caching between the router and the query library. 7. **Prefetch the next page in paginated views.** Call `router.preloadRoute()` with the next page's search params on render. Forward pagination becomes instant. -
data-loading.md 22.1 KB
# Data Loading TanStack Router provides a built-in data loading system with route loaders, SWR caching, and deep integration points for external libraries like TanStack Query. Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/data-loading ## Route Loaders The `loader` function is the primary mechanism for loading data. It runs before the route component renders and receives a single object parameter. ### Loader Function Signature ```tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/posts')({ loader: async ({ params, // Route path params (e.g., { postId: '1' }) deps, // Object from loaderDeps, or {} if not defined context, // Merged route context (parent + beforeLoad) abortController, // Cancelled on unload or outdated invocation preload, // true when preloading instead of loading cause, // 'enter' | 'stay' | 'preload' location, // Current location object route, // The route object itself parentMatchPromise, // Promise for parent match (undefined for root) }) => { return await fetchPosts() }, }) ``` ### Basic Examples ```tsx // Simple data fetch export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), }) // Using path params export const Route = createFileRoute('/posts/$postId')({ loader: ({ params: { postId } }) => fetchPost(postId), }) // Using the abort signal export const Route = createFileRoute('/posts')({ loader: ({ abortController }) => fetch('/api/posts', { signal: abortController.signal }).then((r) => r.json()), }) // Conditional logic based on preload export const Route = createFileRoute('/posts')({ loader: async ({ preload }) => fetchPosts({ maxAge: preload ? 10_000 : 0 }), }) ``` ## Consuming Loader Data ### useLoaderData and getRouteApi ```tsx export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), component: PostsComponent, }) function PostsComponent() { // Option 1: directly from Route object const posts = Route.useLoaderData() return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul> } // Option 2: getRouteApi - avoids circular imports in deep components import { getRouteApi } from '@tanstack/react-router' const routeApi = getRouteApi('/posts') function PostsList() { const posts = routeApi.useLoaderData() return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul> } ``` ### useRouteContext Access route context (including data injected via `beforeLoad`): ```tsx function PostsComponent() { const { user, permissions } = Route.useRouteContext() } ``` ## Loader Dependencies (loaderDeps) `loaderDeps` extracts dependencies from search params to serve as cache keys. When deps change between navigations, the loader re-runs regardless of `staleTime`. Search params are deliberately not available directly in `loader` - this forces explicit dependency declaration which enables correct caching, prevents preloaded data from overwriting the current view, and ensures unique cache entries per set of dependencies. ```tsx import { z } from 'zod' export const Route = createFileRoute('/posts')({ validateSearch: z.object({ page: z.number().int().nonnegative().catch(0), limit: z.number().int().positive().catch(10), }), loaderDeps: ({ search: { page, limit } }) => ({ page, limit }), loader: ({ deps: { page, limit } }) => fetchPosts({ page, limit }), }) ``` ### Common Mistake - Returning the Entire Search Object ```tsx // BAD - reloads when ANY search param changes, even unused ones loaderDeps: ({ search }) => search, loader: ({ deps }) => fetchPosts({ page: deps.page }), // GOOD - only reload when actually-used params change loaderDeps: ({ search }) => ({ page: search.page, limit: search.limit }), loader: ({ deps }) => fetchPosts(deps), ``` Deps are compared using deep equality. Returning extraneous fields causes unnecessary reloads. ## Cache Configuration The built-in SWR cache is keyed on the route's pathname and `loaderDeps`. Cached data is returned immediately while potentially being refetched in the background. ### Key Options | Option | Route-Level | Router Default | Default Value | Description | |--------|------------|----------------|---------------|-------------| | `staleTime` | `routeOptions.staleTime` | `defaultStaleTime` | `0` | How long (ms) data is fresh for navigations | | `preloadStaleTime` | `routeOptions.preloadStaleTime` | `defaultPreloadStaleTime` | `30_000` | How long (ms) data is fresh for preloads | | `gcTime` | `routeOptions.gcTime` | `defaultGcTime` | `1_800_000` (30 min) | How long (ms) unused data stays before GC | | `shouldReload` | `routeOptions.shouldReload` | - | `undefined` | Boolean or function controlling reload | ### Important Defaults - `staleTime: 0` - data is always refetched in the background on navigation (SWR). - `preloadStaleTime: 30_000` - preloaded routes won't re-preload within 30 seconds. - `gcTime: 1_800_000` - cached data GC'd after 30 minutes of inactivity. - `router.invalidate()` forces all active loaders to reload and marks all cache as stale. ### Configuring staleTime ```tsx export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), staleTime: 10_000, // fresh for 10 seconds }) export const Route = createFileRoute('/settings')({ loader: () => fetchSettings(), staleTime: Infinity, // disable SWR - always fresh once loaded }) // Or set a global default const router = createRouter({ routeTree, defaultStaleTime: Infinity }) ``` ### shouldReload and gcTime for Remix-Style Behavior Load data only on entry or when deps change (no background refetching): ```tsx export const Route = createFileRoute('/posts')({ loaderDeps: ({ search: { page, limit } }) => ({ page, limit }), loader: ({ deps }) => fetchPosts(deps), gcTime: 0, // Do not cache after route unmounts shouldReload: false, // Only reload on entry or when deps change }) ``` `shouldReload` can also be a function receiving the same parameters as `loader`: ```tsx export const Route = createFileRoute('/dashboard')({ loader: () => fetchDashboardData(), shouldReload: ({ cause }) => cause === 'enter', }) ``` ## beforeLoad Runs before `loader` and all child `beforeLoad` functions. Serial and top-down, making it suitable for auth checks, redirects, and context injection. ``` Route Matching (Top-Down, Serial): 1. params.parse 2. validateSearch Route Pre-Loading (Serial, Top-Down): 3. beforeLoad Route Loading (Parallel): 4. loader 5. component.preload ``` If `beforeLoad` throws, none of its child routes will attempt to load. ### Auth Guard with Redirect ```tsx import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated')({ beforeLoad: async ({ location }) => { if (!isAuthenticated()) { throw redirect({ to: '/login', search: { redirect: location.href }, }) } }, }) ``` ### Injecting Route Context Return an object from `beforeLoad` to merge into the route's context for `loader` and child routes: ```tsx export const Route = createFileRoute('/dashboard')({ beforeLoad: async ({ context }) => { const user = await fetchCurrentUser(context.authToken) return { user, permissions: user.permissions } }, loader: ({ context: { user, permissions } }) => fetchDashboardData(user.id, permissions), }) ``` ### Auth Check with Error Handling Use `isRedirect()` to distinguish intentional redirects from actual errors in try/catch blocks: ```tsx import { createFileRoute, redirect, isRedirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated')({ beforeLoad: async ({ location }) => { try { const user = await verifySession() if (!user) throw redirect({ to: '/login', search: { redirect: location.href } }) return { user } } catch (error) { if (isRedirect(error)) throw error // re-throw intentional redirects throw redirect({ to: '/login', search: { redirect: location.href } }) } }, }) ``` ### Router-Level Context ```tsx // routes/__root.tsx import { createRootRouteWithContext } from '@tanstack/react-router' interface RouterContext { queryClient: QueryClient auth: AuthState } export const Route = createRootRouteWithContext<RouterContext>()({ component: RootComponent, }) // router.tsx const router = createRouter({ routeTree, context: { queryClient, auth: undefined! }, }) // App.tsx - pass dynamic context function InnerApp() { const auth = useAuth() return <RouterProvider router={router} context={{ auth }} /> } ``` ## Deferred Data Loading Defer slow non-critical data to render critical content first. Return unawaited promises from the loader. Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/deferred-data-loading ### Returning Unawaited Promises ```tsx export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params: { postId } }) => { const commentsPromise = fetchComments(postId) // slow - do NOT await const post = await fetchPost(postId) // fast - await return { post, deferredComments: commentsPromise } }, }) ``` ### Await Component Resolve deferred promises using `Await`, which triggers the nearest Suspense boundary: ```tsx import { Await } from '@tanstack/react-router' import { Suspense } from 'react' function PostComponent() { const { post, deferredComments } = Route.useLoaderData() return ( <div> <h1>{post.title}</h1> <Suspense fallback={<div>Loading comments...</div>}> <Await promise={deferredComments}> {(comments) => ( <ul> {comments.map((c) => <li key={c.id}>{c.body}</li>)} </ul> )} </Await> </Suspense> </div> ) } ``` If the promise rejects, `Await` throws the serialized error to the nearest error boundary. In React 19, you can use the `use()` hook instead. ### SSR Streaming Deferred data supports server-side streaming: 1. Server renders up to Suspense boundaries and streams initial HTML. 2. Deferred promises are tracked as they resolve on the server. 3. Resolved data is serialized and streamed via inline script tags. 4. Client-side placeholder promises resolve with the streamed data. See the [SSR Streaming Guide](https://tanstack.com/router/latest/docs/framework/react/guide/ssr) for setup. ### Deferred Data with TanStack Query Use `prefetchQuery` (no await) for deferred data, `ensureQueryData` (await) for critical data: ```tsx import { useSuspenseQuery } from '@tanstack/react-query' const postOptions = (postId: string) => queryOptions({ queryKey: ['post', postId], queryFn: () => fetchPost(postId), }) const commentsOptions = (postId: string) => queryOptions({ queryKey: ['comments', postId], queryFn: () => fetchComments(postId), }) export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params: { postId }, context: { queryClient } }) => { queryClient.prefetchQuery(commentsOptions(postId)) // deferred await queryClient.ensureQueryData(postOptions(postId)) // critical }, component: PostComponent, }) function PostComponent() { const { postId } = Route.useParams() const { data: post } = useSuspenseQuery(postOptions(postId)) return ( <div> <h1>{post.title}</h1> <Suspense fallback={<div>Loading comments...</div>}> <Comments postId={postId} /> </Suspense> </div> ) } function Comments({ postId }: { postId: string }) { const { data: comments } = useSuspenseQuery(commentsOptions(postId)) return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul> } ``` ## External Data Loading - TanStack Query Integration TanStack Query is the most common integration. The router coordinates when to fetch; TanStack Query handles caching, deduplication, and background refetching. Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading ### Router Setup Set `defaultPreloadStaleTime: 0` so every event passes through to your external library: ```tsx const router = createRouter({ routeTree, defaultPreloadStaleTime: 0, context: { queryClient }, }) ``` ### ensureQueryData Pattern ```tsx import { queryOptions, useSuspenseQuery } from '@tanstack/react-query' const postsQueryOptions = queryOptions({ queryKey: ['posts'], queryFn: () => fetchPosts(), }) export const Route = createFileRoute('/posts')({ loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(postsQueryOptions), component: () => { const { data: posts } = useSuspenseQuery(postsQueryOptions) return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul> }, }) ``` ### Full SSR Setup For SSR, use `dehydrate`/`hydrate` options on the router to shuttle TanStack Query state between server and client: ```tsx import { QueryClient, QueryClientProvider, dehydrate, hydrate } from '@tanstack/react-query' export function createAppRouter() { const queryClient = new QueryClient() return createRouter({ routeTree, defaultPreloadStaleTime: 0, context: { queryClient }, dehydrate: () => ({ queryClientState: dehydrate(queryClient) }), hydrate: (dehydrated) => { hydrate(queryClient, dehydrated.queryClientState) }, Wrap: ({ children }) => ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ), }) } ``` ### Error Handling with TanStack Query Reset query error boundaries on mount to allow retry: ```tsx import { useQueryErrorResetBoundary } from '@tanstack/react-query' export const Route = createFileRoute('/posts')({ loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(postsQueryOptions), errorComponent: ({ error }) => { const router = useRouter() const queryErrorResetBoundary = useQueryErrorResetBoundary() useEffect(() => { queryErrorResetBoundary.reset() }, [queryErrorResetBoundary]) return ( <div> <p>{error.message}</p> <button onClick={() => router.invalidate()}>Retry</button> </div> ) }, }) ``` ## Data Mutations TanStack Router has no built-in mutation APIs. It reacts to URL side-effects from external mutation events. Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/data-mutations ### router.invalidate() Force all active route loaders to reload after a mutation: ```tsx const router = useRouter() const handleCreate = async () => { await createPost({ title: 'New Post' }) router.invalidate() // background reload // await router.invalidate({ sync: true }) // wait for completion } ``` ### router.subscribe for Mutation State Cleanup Reset mutation states when navigation completes: ```tsx router.subscribe('onResolved', () => { mutationCache.clear() }) ``` The `onResolved` event fires when a location path change (not just reload) has fully resolved. ### Mutation with TanStack Query The standard pattern pairs TanStack Query mutations with router invalidation: ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query' import { useRouter } from '@tanstack/react-router' function EditPost({ postId }: { postId: string }) { const router = useRouter() const queryClient = useQueryClient() const updatePost = useMutation({ mutationFn: (data: { title: string }) => fetch(`/api/posts/${postId}`, { method: 'PATCH', body: JSON.stringify(data) }) .then((r) => r.json()), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }) queryClient.invalidateQueries({ queryKey: ['post', postId] }) router.invalidate() // also reload router-cached loader data }, }) return <button onClick={() => updatePost.mutate({ title: 'Updated' })}>Save</button> } ``` ## Loader Error Handling ### Throwing Errors, redirect(), and notFound() ```tsx import { createFileRoute, redirect, notFound } from '@tanstack/react-router' // Errors are caught by the route's error boundary export const Route = createFileRoute('/posts')({ loader: async () => { const res = await fetch('/api/posts') if (!res.ok) throw new Error('Failed to fetch posts') return res.json() }, }) // redirect() accepts the same options as navigate export const Route = createFileRoute('/admin')({ beforeLoad: ({ context }) => { if (!context.auth.isAdmin) throw redirect({ to: '/', replace: true }) }, }) // notFound() renders the nearest notFoundComponent export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params: { postId } }) => { const post = await getPost(postId) if (!post) throw notFound() return { post } }, notFoundComponent: () => { const { postId } = Route.useParams() return <p>Post {postId} not found</p> }, }) ``` Note: `notFound()` in `beforeLoad` always triggers the root `notFoundComponent` since layout data may not have loaded. ### errorComponent with Retry ```tsx import { ErrorComponent, useRouter } from '@tanstack/react-router' export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), errorComponent: ({ error }) => { const router = useRouter() if (error instanceof CustomApiError) { return <div>API Error: {error.statusCode}</div> } return ( <div> <p>{error.message}</p> <button onClick={() => router.invalidate()}>Retry</button> </div> ) }, }) ``` Use `router.invalidate()` instead of just `reset()` when the error came from a loader - it coordinates both reloading data and resetting the error boundary. ### onError, onCatch, and Pending Components ```tsx export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), onError: ({ error }) => { reportToSentry(error) }, // on loader error onCatch: ({ error, errorInfo }) => { console.error(error) }, // on CatchBoundary catch }) export const Route = createFileRoute('/reports')({ loader: () => fetchLargeReport(), pendingComponent: () => <div>Generating report...</div>, pendingMs: 1_000, // Show pending after 1s (default) pendingMinMs: 500, // Show for at least 500ms to avoid flash (default) }) ``` Configure pending defaults at the router level with `defaultPendingMs` and `defaultPendingMinMs`. ## Common Patterns ### Parallel Data Loading and Preventing Waterfalls Route loaders across the same navigation run in parallel automatically. Within a single loader, use `Promise.all` for independent requests: ```tsx // routes/dashboard.tsx - runs in parallel with child loaders export const Route = createFileRoute('/dashboard')({ loader: async () => { // Independent requests - fetch in parallel const [stats, notifications] = await Promise.all([ fetchStats(), fetchNotifications(), ]) return { stats, notifications } }, }) ``` ### Dependent Data Loading Chain dependent fetches, but parallelize independent work: ```tsx export const Route = createFileRoute('/users/$userId/posts')({ loader: async ({ params: { userId } }) => { const user = await fetchUser(userId) const [team, posts] = await Promise.all([ fetchTeam(user.teamId), fetchUserPosts(userId), ]) return { user, team, posts } }, }) ``` ### Prefetch on Hover Enable at the router level with `defaultPreload: 'intent'`. Links will automatically preload on hover/focus. Disable per-link with `preload={false}`: ```tsx const router = createRouter({ routeTree, defaultPreload: 'intent' }) ``` ### Full Route: TanStack Query with Search Params Combines `validateSearch`, `loaderDeps`, `ensureQueryData`, and `useSuspenseQuery`: ```tsx import { z } from 'zod' import { queryOptions, useSuspenseQuery } from '@tanstack/react-query' const postsQueryOptions = (params: { page: number; limit: number }) => queryOptions({ queryKey: ['posts', params], queryFn: () => fetchPosts(params) }) export const Route = createFileRoute('/posts')({ validateSearch: z.object({ page: z.number().int().nonnegative().catch(0), limit: z.number().int().positive().catch(20), }), loaderDeps: ({ search: { page, limit } }) => ({ page, limit }), loader: ({ deps, context: { queryClient } }) => queryClient.ensureQueryData(postsQueryOptions(deps)), component: () => { const { page, limit } = Route.useSearch() const { data: posts } = useSuspenseQuery(postsQueryOptions({ page, limit })) return ( <div> <ul>{posts.items.map((p) => <li key={p.id}>{p.title}</li>)}</ul> <Link to="/posts" search={{ page: page + 1, limit }}>Next Page</Link> </div> ) }, }) ``` ## Router Cache vs External Cache **Use the built-in router cache when:** your app shares little data between routes, you want zero extra dependencies, coarse invalidation is acceptable, and SSR "just works" is a priority. **Use TanStack Query (or another external cache) when:** you need shared caching/deduplication across routes, fine-grained cache invalidation by query key, optimistic update APIs, mutation management, persistence adapters, or offline support. ## Best Practices 1. **Always declare loaderDeps explicitly.** Only extract search params your loader uses. Never return the entire `search` object - it causes unnecessary cache invalidation. 2. **Use beforeLoad for auth and redirects, loader for data.** `beforeLoad` runs serially and blocks children - right for access control. `loader` runs in parallel - right for data fetching. 3. **Set `defaultPreloadStaleTime: 0` with external caches.** Ensures every event passes through to your library, which handles its own deduplication. 4. **Pass the abort signal to fetch calls.** Use `abortController.signal` to cancel in-flight requests on navigation, preventing wasted bandwidth and race conditions. 5. **Prefer `router.invalidate()` over `reset()` in error components.** It coordinates both reloading data and resetting the error boundary. 6. **Defer non-critical data with unawaited promises.** Use `Await` or `useSuspenseQuery` with `prefetchQuery` to render deferred data progressively. 7. **Use `Promise.all` for independent fetches within a loader.** Sequential awaits create waterfalls. Parallelize independent requests. 8. **Avoid throwing `notFound()` in `beforeLoad`.** It always triggers the root `notFoundComponent`. Throw in `loader` instead for proper propagation. -
infinite-queries.md 16.1 KB
# Infinite Queries Infinite queries are used for implementing "load more" and infinite scroll patterns. They allow you to fetch paginated data progressively. ## Basic Infinite Query ```tsx import { useInfiniteQuery } from '@tanstack/react-query'; function Posts() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: async ({ pageParam = 0 }) => { const res = await fetch(`/api/posts?cursor=${pageParam}`); return res.json(); }, initialPageParam: 0, getNextPageParam: (lastPage, allPages) => { // Return undefined if no more pages return lastPage.nextCursor ?? undefined; }, }); if (isLoading) return <div>Loading...</div>; return ( <> {data.pages.map((page, i) => ( <div key={i}> {page.posts.map((post) => ( <div key={post.id}>{post.title}</div> ))} </div> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load More' : 'Nothing more to load'} </button> </> ); } ``` ## Data Structure The `data` object has a specific structure: ```tsx { pages: [ { posts: [...], nextCursor: 1 }, // Page 1 { posts: [...], nextCursor: 2 }, // Page 2 { posts: [...], nextCursor: 3 }, // Page 3 ], pageParams: [0, 1, 2] // The pageParam values used } ``` ## Page Parameters ### initialPageParam Required parameter that specifies the initial page parameter: ```tsx useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam }) => fetchPosts(pageParam), initialPageParam: 0, // or 1, or { cursor: null }, etc. getNextPageParam: (lastPage) => lastPage.nextCursor, }); ``` ### getNextPageParam Function that receives the last page and all pages, and returns the next page parameter: ```tsx // Cursor-based pagination getNextPageParam: (lastPage, allPages) => { return lastPage.nextCursor ?? undefined; } // Offset-based pagination getNextPageParam: (lastPage, allPages) => { if (lastPage.length === 0) return undefined; return allPages.length * 10; // Assuming 10 items per page } // Page number pagination getNextPageParam: (lastPage, allPages) => { const totalPages = lastPage.totalPages; const nextPage = allPages.length + 1; return nextPage <= totalPages ? nextPage : undefined; } // Access to all pages getNextPageParam: (lastPage, allPages) => { const totalFetched = allPages.reduce((acc, page) => acc + page.data.length, 0); return totalFetched < lastPage.total ? lastPage.nextCursor : undefined; } ``` ### getPreviousPageParam For bidirectional infinite scrolling: ```tsx useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam }) => fetchPosts(pageParam), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, getPreviousPageParam: (firstPage) => firstPage.previousCursor, }); ``` ## Fetching Pages ### Fetch Next Page ```tsx const { fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ // ...config }); // Manual trigger <button onClick={() => fetchNextPage()} disabled={!hasNextPage}> Load More </button> // Infinite scroll useEffect(() => { const handleScroll = () => { if ( window.innerHeight + window.scrollY >= document.body.offsetHeight - 500 && hasNextPage && !isFetchingNextPage ) { fetchNextPage(); } }; window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, [fetchNextPage, hasNextPage, isFetchingNextPage]); ``` ### Fetch Previous Page ```tsx const { fetchPreviousPage, hasPreviousPage, isFetchingPreviousPage } = useInfiniteQuery({ // ...config }); <button onClick={() => fetchPreviousPage()} disabled={!hasPreviousPage}> Load Previous </button> ``` ## Pagination Strategies ### Cursor-Based Pagination Best for real-time data and when items can be inserted/deleted: ```tsx useInfiniteQuery({ queryKey: ['posts'], queryFn: async ({ pageParam }) => { const res = await fetch(`/api/posts?cursor=${pageParam}&limit=10`); return res.json(); }, initialPageParam: null, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, }); // API response structure: // { // data: [...], // nextCursor: 'cursor_string' | null // } ``` ### Offset-Based Pagination Simpler but can have issues with real-time data: ```tsx useInfiniteQuery({ queryKey: ['posts'], queryFn: async ({ pageParam = 0 }) => { const res = await fetch(`/api/posts?offset=${pageParam}&limit=10`); return res.json(); }, initialPageParam: 0, getNextPageParam: (lastPage, allPages) => { if (lastPage.data.length < 10) return undefined; return allPages.length * 10; }, }); ``` ### Page Number Pagination Traditional page-based approach: ```tsx useInfiniteQuery({ queryKey: ['posts'], queryFn: async ({ pageParam = 1 }) => { const res = await fetch(`/api/posts?page=${pageParam}&size=10`); return res.json(); }, initialPageParam: 1, getNextPageParam: (lastPage, allPages) => { const currentPage = allPages.length; return currentPage < lastPage.totalPages ? currentPage + 1 : undefined; }, }); ``` ## Infinite Scroll Implementation ### Using Intersection Observer ```tsx import { useInfiniteQuery } from '@tanstack/react-query'; import { useRef, useCallback } from 'react'; function InfiniteScrollPosts() { const observerTarget = useRef(null); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, }); // Setup intersection observer useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, { threshold: 1.0 } ); if (observerTarget.current) { observer.observe(observerTarget.current); } return () => observer.disconnect(); }, [fetchNextPage, hasNextPage, isFetchingNextPage]); return ( <div> {data?.pages.map((page, i) => ( <div key={i}> {page.posts.map((post) => ( <PostCard key={post.id} post={post} /> ))} </div> ))} {hasNextPage && ( <div ref={observerTarget} className="loading-indicator"> {isFetchingNextPage ? 'Loading more...' : 'Load more'} </div> )} </div> ); } ``` ### Using react-intersection-observer ```tsx import { useInfiniteQuery } from '@tanstack/react-query'; import { useInView } from 'react-intersection-observer'; function InfiniteScrollPosts() { const { ref, inView } = useInView(); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, }); useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, [inView, fetchNextPage, hasNextPage, isFetchingNextPage]); return ( <div> {data?.pages.map((page, i) => ( <div key={i}> {page.posts.map((post) => ( <PostCard key={post.id} post={post} /> ))} </div> ))} <div ref={ref}>{isFetchingNextPage && 'Loading...'}</div> </div> ); } ``` ## Refetching and Invalidation ### Refetch All Pages ```tsx const queryClient = useQueryClient(); // Refetch all pages queryClient.invalidateQueries({ queryKey: ['posts'] }); // Or manually const { refetch } = useInfiniteQuery({ /* ... */ }); refetch(); ``` ### Limiting Stored Pages with `maxPages` **v5 removed `refetchPage`** (the v4 way to refetch only some pages) in favor of `maxPages`, which caps how many pages are stored *and* refetched. This bounds memory and keeps invalidation refetches cheap - refetching every accumulated page was the problem `refetchPage` tried to work around. ```tsx useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam }) => fetchPosts(pageParam), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, getPreviousPageParam: (firstPage) => firstPage.previousCursor, // required if maxPages drops from both ends maxPages: 3, // keep at most 3 pages; fetching a 4th drops the oldest }); ``` On invalidation the query refetches only the currently-stored pages (at most `maxPages`), sequentially from the first. Omit `maxPages` (default `undefined`) to keep every page. ## Transforming Data ### Flatten Pages ```tsx const { data } = useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, select: (data) => ({ pages: [...data.pages], pageParams: [...data.pageParams], // Flatten all posts allPosts: data.pages.flatMap(page => page.posts), }), }); // Now you can use data.allPosts directly return <div>{data?.allPosts.map(post => <PostCard key={post.id} post={post} />)}</div>; ``` ### Filter and Transform ```tsx const { data } = useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, select: (data) => ({ ...data, pages: data.pages.map(page => ({ ...page, posts: page.posts.filter(post => !post.isDeleted), })), }), }); ``` ## Bidirectional Infinite Scrolling ```tsx function BidirectionalScroll() { const { data, fetchNextPage, fetchPreviousPage, hasNextPage, hasPreviousPage, isFetchingNextPage, isFetchingPreviousPage, } = useInfiniteQuery({ queryKey: ['messages'], queryFn: ({ pageParam }) => fetchMessages(pageParam), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, getPreviousPageParam: (firstPage) => firstPage.previousCursor, }); return ( <div> <button onClick={() => fetchPreviousPage()} disabled={!hasPreviousPage || isFetchingPreviousPage} > {isFetchingPreviousPage ? 'Loading...' : 'Load Older'} </button> {data?.pages.map((page, i) => ( <div key={i}> {page.messages.map((message) => ( <MessageCard key={message.id} message={message} /> ))} </div> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading...' : 'Load Newer'} </button> </div> ); } ``` ## Advanced Patterns ### Search with Infinite Scroll ```tsx function SearchResults({ searchTerm }) { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['search', searchTerm], queryFn: ({ pageParam = 0 }) => fetch(`/api/search?q=${searchTerm}&cursor=${pageParam}`).then(r => r.json()), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, enabled: searchTerm.length > 2, // Only search if term is long enough }); return ( <div> {data?.pages.map((page, i) => ( <div key={i}> {page.results.map((result) => ( <SearchResult key={result.id} result={result} /> ))} </div> ))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}> Load More Results </button> )} </div> ); } ``` ### Infinite Query with Filters ```tsx function FilteredList({ filters }) { const { data, fetchNextPage, hasNextPage, } = useInfiniteQuery({ queryKey: ['items', filters], queryFn: ({ pageParam = 0 }) => fetchItems({ ...filters, cursor: pageParam }), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, }); // When filters change, query automatically resets and refetches from page 1 return ( <div> {data?.pages.map((page, i) => ( <div key={i}> {page.items.map((item) => ( <ItemCard key={item.id} item={item} /> ))} </div> ))} {hasNextPage && <button onClick={() => fetchNextPage()}>Load More</button>} </div> ); } ``` ### Prefetching Next Page ```tsx function Posts() { const queryClient = useQueryClient(); const { data, fetchNextPage, hasNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, }); // Prefetch next page when user is near the end useEffect(() => { if (hasNextPage) { const nextPageParam = data?.pageParams[data.pageParams.length - 1] + 1; queryClient.prefetchInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam }) => fetchPosts(pageParam), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, pages: data?.pages.length + 1, // Prefetch one more page }); } }, [data, hasNextPage, queryClient]); return <div>{/* render */}</div>; } ``` ## Streaming with Infinite Queries `experimental_streamedQuery` can be combined with infinite patterns for streaming paginated data: ```tsx import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query'; const { data, fetchStatus } = useInfiniteQuery({ queryKey: ['stream-feed'], queryFn: streamedQuery({ streamFn: ({ pageParam }) => fetchStreamedFeed(pageParam), refetchMode: 'append', }), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage?.nextCursor, }); // Each page streams in chunks while fetchStatus === 'fetching' ``` See the Streamed Queries section in the main SKILL.md for the full `streamedQuery` API. ## Common Issues and Solutions ### Duplicate Data After Invalidation When invalidating an infinite query, it refetches all stored pages. To avoid duplicates or excessive refetching: **SSR note:** v5.90.3 fixed unhandled promise rejections during dehydration/rehydration of pending infinite queries. Ensure you're on v5.90.3+ if using SSR with infinite queries. ```tsx // Option 1: Cap stored/refetched pages with maxPages (replaces v4 refetchPage) useInfiniteQuery({ queryKey: ['posts'], /* ... */, maxPages: 3 }); // Option 2: Reset to first page queryClient.resetQueries({ queryKey: ['posts'] }); ``` ### Stale Data Between Pages Set appropriate `staleTime`: ```tsx useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, staleTime: 1000 * 60 * 5, // 5 minutes }); ``` ### Managing Total Count Track total items across all pages: ```tsx const { data } = useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, select: (data) => ({ ...data, totalCount: data.pages[0]?.total || 0, // Assuming API returns total currentCount: data.pages.reduce((acc, page) => acc + page.posts.length, 0), }), }); // Display: Showing {data.currentCount} of {data.totalCount} ``` ## Best Practices 1. **Choose the Right Pagination Strategy** - Use cursor-based for real-time feeds - Use offset for simple lists - Use page numbers for traditional pagination 2. **Handle Edge Cases** - Empty states when no data - Loading states for first page - Error states with retry - End of list indicators 3. **Optimize Performance** - Use `select` to transform data once - Set appropriate `staleTime` and `gcTime` - Implement virtual scrolling for large lists (react-window, react-virtualized) 4. **Refetch Strategies** - Only refetch first page for most updates - Use `refetchPage` for targeted refetches - Consider resetting queries when filters change 5. **User Experience** - Show loading indicators for next/previous pages - Disable buttons during fetching - Provide feedback when no more data - Handle errors gracefully with retry options -
middleware.md 20.1 KB
# TanStack Start Middleware Middleware in TanStack Start lets you intercept and customize server requests (including SSR) and server functions. Middleware is composable, type-safe, and supports both client-side and server-side logic. Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/middleware ## Two Types of Middleware ### Request Middleware Runs on every server request that passes through it - server routes, SSR, and server functions. Created with `createMiddleware()` (type defaults to `'request'`). Only has a `.server()` method. ### Server Function Middleware Runs specifically for server functions. Created with `createMiddleware({ type: 'function' })`. Has `.client()`, `.server()`, and `.validator()` methods. The `.client()` method wraps the RPC call on the client side. | Feature | Request Middleware | Server Function Middleware | |------------------|----------------------------|--------------------------------| | Scope | All server requests | Server functions only | | Methods | `.server()` | `.client()`, `.server()` | | Input Validation | No | Yes (`.validator()`) | | Client-side Logic| No | Yes | | Dependencies | Request middleware only | Both request and function types | ## Creating Request Middleware Request middleware receives `next`, `context`, `request`, `pathname`, and `serverFnMeta` (present only for server function calls) in its `.server()` callback. ```typescript import { createMiddleware } from '@tanstack/react-start' const loggingMiddleware = createMiddleware().server( async ({ next, context, request }) => { const startTime = Date.now() const url = new URL(request.url) console.log(`[${request.method}] ${url.pathname} - Starting`) const result = await next() console.log( `[${request.method}] ${url.pathname} - ${result.response.status} (${Date.now() - startTime}ms)`, ) return result }, ) ``` ## Creating Server Function Middleware Method order is enforced by TypeScript: `.middleware()` -> `.validator()` -> `.client()` -> `.server()`. ```typescript import { createMiddleware } from '@tanstack/react-start' const timingMiddleware = createMiddleware({ type: 'function' }) .client(async ({ next }) => { const clientStart = Date.now() const result = await next() // triggers RPC to server console.log(`RPC round-trip: ${Date.now() - clientStart}ms`) return result }) .server(async ({ next }) => { const serverStart = Date.now() const result = await next() console.log(`Server execution: ${Date.now() - serverStart}ms`) return result }) ``` The `.client()` callback receives `next`, `context`, `data`, `method`, `signal`, `serverFnMeta`, and `filename`. The `.server()` callback receives `next`, `context`, `data`, `method`, `serverFnMeta`, and `signal`. ### The .validator() Method Validates and transforms input data before it reaches the middleware chain. Works with `zodValidator`, `valibotValidator`, and `arktypeValidator`. > The method is `.validator()`. The older `.inputValidator()` spelling is deprecated and the compiler now emits warnings for it (TanStack/router PR #7566). ```typescript import { createMiddleware } from '@tanstack/react-start' import { zodValidator } from '@tanstack/zod-adapter' import { z } from 'zod' const workspaceMiddleware = createMiddleware({ type: 'function' }) .validator(zodValidator(z.object({ workspaceId: z.string().uuid() }))) .server(({ next, data }) => { console.log('Validated workspace ID:', data.workspaceId) return next() }) ``` ## Middleware Composition Middleware depends on other middleware through `.middleware()`. Dependencies execute first, and their context accumulates. ```typescript import { createMiddleware } from '@tanstack/react-start' const loggingMiddleware = createMiddleware().server(async ({ next }) => { console.log('Logging: request started') return next() }) const authMiddleware = createMiddleware() .middleware([loggingMiddleware]) .server(async ({ next }) => { // loggingMiddleware runs first, then this return next({ context: { userId: 'user-123' } }) }) const adminMiddleware = createMiddleware() .middleware([authMiddleware]) .server(async ({ next, context }) => { // logging -> auth -> admin if (context.userId !== 'admin') { throw new Error('Forbidden') } return next() }) ``` Rules: request middleware can only depend on request middleware. Function middleware can depend on both types. ## next() and Context Management ### Providing Context Call `next()` with a `context` object to pass typed data downstream. Context merges into the parent context. ```typescript const featureFlagMiddleware = createMiddleware({ type: 'function' }).server( ({ next }) => { return next({ context: { features: { newDashboard: true, betaAPI: false } }, }) }, ) const consumer = createMiddleware({ type: 'function' }) .middleware([featureFlagMiddleware]) .server(async ({ next, context }) => { console.log('Dashboard enabled:', context.features.newDashboard) // typed return next() }) ``` ### Sending Client Context to the Server Client context is NOT sent to the server by default. Use `sendContext` in `.client()` to explicitly transmit data. ```typescript const workspaceMiddleware = createMiddleware({ type: 'function' }) .client(async ({ next, context }) => { return next({ sendContext: { workspaceId: context.workspaceId } }) }) .server(async ({ next, context }) => { // Validate dynamic data before trusting it const workspaceId = zodValidator(z.string().uuid()).parse(context.workspaceId) return next({ context: { validatedWorkspaceId: workspaceId } }) }) ``` ### Sending Server Context to the Client Use `sendContext` in `.server()` to send data back. Access it via `result.context` in the `.client()` method. ```typescript const serverTimer = createMiddleware({ type: 'function' }).server( async ({ next }) => { return next({ sendContext: { serverTimestamp: new Date().toISOString() } }) }, ) const clientLogger = createMiddleware({ type: 'function' }) .middleware([serverTimer]) .client(async ({ next }) => { const result = await next() console.log('Server time:', result.context.serverTimestamp) // typed return result }) ``` ## Authentication Middleware - Complete Flow ### Session Setup ```typescript // utils/session.ts import { useSession } from '@tanstack/react-start/server' type SessionData = { userId?: string email?: string role?: 'user' | 'admin' | 'moderator' } export function useAppSession() { return useSession<SessionData>({ name: 'app-session', password: process.env.SESSION_SECRET!, // at least 32 characters cookie: { secure: process.env.NODE_ENV === 'production', sameSite: 'lax', httpOnly: true, maxAge: 7 * 24 * 60 * 60, }, }) } ``` ### Auth Server Functions ```typescript // utils/auth.ts import { createServerFn } from '@tanstack/react-start' import { redirect } from '@tanstack/react-router' import { useAppSession } from './session' export const loginFn = createServerFn({ method: 'POST' }) .validator((d: { email: string; password: string }) => d) .handler(async ({ data }) => { const user = await authenticateUser(data.email, data.password) if (!user) { return { error: true, message: 'Invalid credentials' } } const session = await useAppSession() await session.update({ userId: user.id, email: user.email, role: user.role }) throw redirect({ to: '/dashboard' }) }) export const getCurrentUserFn = createServerFn({ method: 'GET' }).handler( async () => { const session = await useAppSession() if (!session.data.userId) return null return getUserById(session.data.userId) }, ) ``` ### Auth Middleware and Route Protection ```typescript // middleware/auth.ts import { createMiddleware } from '@tanstack/react-start' import { useAppSession } from '../utils/session' export const authMiddleware = createMiddleware().server(async ({ next }) => { const session = await useAppSession() return next({ context: { userId: session.data.userId ?? null, userRole: session.data.role ?? null, isAuthenticated: !!session.data.userId, }, }) }) ``` ```typescript // routes/_authed.tsx - layout route guard import { createFileRoute, redirect } from '@tanstack/react-router' import { getCurrentUserFn } from '../utils/auth' export const Route = createFileRoute('/_authed')({ beforeLoad: async ({ location }) => { const user = await getCurrentUserFn() if (!user) { throw redirect({ to: '/login', search: { redirect: location.href } }) } return { user } }, }) ``` ```typescript // routes/_authed/dashboard.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/_authed/dashboard')({ component: () => { const { user } = Route.useRouteContext() return <h1>Welcome, {user.email}!</h1> }, }) ``` ```typescript // routes/_authed/admin.tsx - role-based guard import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authed/admin')({ beforeLoad: async ({ context }) => { if (context.user.role !== 'admin') { throw redirect({ to: '/unauthorized' }) } }, }) ``` ## Global Middleware Configure in `src/start.ts` (not included in the default template - create it when needed). ```typescript // src/start.ts import { createStart, createMiddleware } from '@tanstack/react-start' const globalLogger = createMiddleware().server(async ({ request, next }) => { const start = Date.now() const result = await next() console.log(`${request.method} ${new URL(request.url).pathname} ${result.response.status} ${Date.now() - start}ms`) return result }) const fnTimer = createMiddleware({ type: 'function' }).server( async ({ next, serverFnMeta }) => { const start = Date.now() const result = await next() console.log(`ServerFn ${serverFnMeta.id}: ${Date.now() - start}ms`) return result }, ) export const startInstance = createStart(() => ({ requestMiddleware: [globalLogger], // runs on every request (SSR, routes, fns) functionMiddleware: [fnTimer], // runs on every server function })) ``` ### Execution Order Dependency-first, then global middleware, then the chain: ```typescript // With globalMiddleware1, globalMiddleware2 in start.ts: const a = createMiddleware({ type: 'function' }).server(async ({ next }) => { console.log('a'); return next() }) const b = createMiddleware({ type: 'function' }).middleware([a]).server(async ({ next }) => { console.log('b'); return next() }) const c = createMiddleware({ type: 'function' }).server(async ({ next }) => { console.log('c'); return next() }) const d = createMiddleware({ type: 'function' }).middleware([b, c]).server(async ({ next }) => { console.log('d'); return next() }) const fn = createServerFn().middleware([d]).handler(async () => { console.log('fn') }) // Order: globalMiddleware1 -> globalMiddleware2 -> a -> b -> c -> d -> fn ``` ## Usage with Server Functions ```typescript import { createServerFn } from '@tanstack/react-start' const getProtectedDataFn = createServerFn() .middleware([authMiddleware]) .handler(async ({ context }) => { if (!context.isAuthenticated) throw new Error('Unauthorized') return fetchDataForUser(context.userId) }) ``` ## Usage with Server Routes ### Route-Level Middleware (All Methods) ```typescript export const Route = createFileRoute('/api/data')({ server: { middleware: [corsMiddleware, authMiddleware], handlers: { GET: async () => Response.json({ items: await fetchItems() }), POST: async ({ request }) => Response.json({ created: await createItem(await request.json()) }), }, }, }) ``` ### Handler-Specific Middleware Use `createHandlers` to attach middleware to individual HTTP methods. Route-level middleware runs first, then handler-specific. ```typescript export const Route = createFileRoute('/api/items')({ server: { middleware: [corsMiddleware], handlers: ({ createHandlers }) => createHandlers({ GET: async () => Response.json({ items: await fetchItems() }), POST: { middleware: [writeAuthMiddleware], handler: async ({ request }) => Response.json(await createItem(await request.json())), }, }), }, }) ``` ## Client Request Modification ### Custom Headers ```typescript const authHeaderMiddleware = createMiddleware({ type: 'function' }).client( async ({ next }) => { return next({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }) }, ) ``` Headers merge across middleware. Precedence: call-site headers > later middleware > earlier middleware. ### Custom Fetch Provide a custom `fetch` for retries, logging, or testing. Precedence (highest to lowest): call site > later middleware > earlier middleware > `createStart({ serverFns: { fetch } })` > default `fetch`. Custom fetch only applies client-side; during SSR, server functions are called directly. ```typescript import type { CustomFetch } from '@tanstack/react-start' const retryMiddleware = createMiddleware({ type: 'function' }).client( async ({ next }) => { const retryFetch: CustomFetch = async (url, init) => { for (let attempt = 0; attempt < 3; attempt++) { try { return await fetch(url, init) } catch { await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt))) } } throw new Error('Max retries exceeded') } return next({ fetch: retryFetch }) }, ) ``` ## Validation Adapters ```typescript // Zod import { zodValidator } from '@tanstack/zod-adapter' import { z } from 'zod' const paginationMiddleware = createMiddleware({ type: 'function' }) .validator(zodValidator(z.object({ page: z.number().default(1), limit: z.number().max(100).default(20) }))) .server(({ next, data }) => { console.log(`Page ${data.page}`); return next() }) // Valibot import { valibotValidator } from '@tanstack/valibot-adapter' import * as v from 'valibot' const vMiddleware = createMiddleware({ type: 'function' }) .validator(valibotValidator(v.object({ workspaceId: v.pipe(v.string(), v.uuid()) }))) .server(({ next, data }) => next({ context: { workspaceId: data.workspaceId } })) // ArkType import { arktypeValidator } from '@tanstack/arktype-adapter' import { type } from 'arktype' const aMiddleware = createMiddleware({ type: 'function' }) .validator(arktypeValidator(type({ tenantId: 'string' }))) .server(({ next, data }) => next({ context: { tenantId: data.tenantId } })) ``` ## Common Patterns ### CORS Middleware ```typescript export const corsMiddleware = createMiddleware().server(async ({ next }) => { const result = await next() result.response.headers.set('Access-Control-Allow-Origin', process.env.ALLOWED_ORIGIN || '*') result.response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') result.response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization') result.response.headers.set('Access-Control-Max-Age', '86400') return result }) ``` ### Security Headers ```typescript export const securityHeaders = createMiddleware().server(async ({ next }) => { const result = await next() result.response.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'") result.response.headers.set('X-Content-Type-Options', 'nosniff') result.response.headers.set('X-Frame-Options', 'DENY') result.response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') return result }) ``` ### Rate Limiting ```typescript const rateLimitStore = new Map<string, { count: number; resetTime: number }>() export const rateLimitMiddleware = createMiddleware().server( async ({ request, next }) => { const ip = request.headers.get('x-forwarded-for') || 'unknown' const now = Date.now() const entry = rateLimitStore.get(ip) if (!entry || now > entry.resetTime) { rateLimitStore.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }) } else if (entry.count >= 100) { return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': Math.ceil((entry.resetTime - now) / 1000).toString() }, }) } else { entry.count++ } return next() }, ) ``` ### Composition Chain Example ```typescript import { createMiddleware, createServerFn } from '@tanstack/react-start' import { useAppSession } from '../utils/session' // Layer 1: Logging const logging = createMiddleware().server(async ({ request, next }) => { const start = Date.now() const result = await next() console.log(`${request.method} ${new URL(request.url).pathname} ${Date.now() - start}ms`) return result }) // Layer 2: Auth context - depends on logging const auth = createMiddleware() .middleware([logging]) .server(async ({ next }) => { const session = await useAppSession() return next({ context: { userId: session.data.userId ?? null, isAuthenticated: !!session.data.userId }, }) }) // Layer 3: Require auth - depends on auth const requireAuth = createMiddleware() .middleware([auth]) .server(async ({ next, context }) => { if (!context.isAuthenticated) return new Response('Unauthorized', { status: 401 }) return next() }) // Usage: logging -> auth -> requireAuth -> handler const getProfileFn = createServerFn() .middleware([requireAuth]) .handler(async ({ context }) => fetchUserProfile(context.userId)) ``` ## Response Modification and Short-Circuiting Request middleware can modify responses or return a `Response` directly to short-circuit. ```typescript const debugMiddleware = createMiddleware().server(async ({ next }) => { const result = await next() if (process.env.NODE_ENV === 'development') { result.response.headers.set('X-Debug-Timestamp', new Date().toISOString()) } return result }) const maintenanceMiddleware = createMiddleware().server(async ({ next }) => { if (process.env.MAINTENANCE_MODE === 'true') { return new Response('Service temporarily unavailable', { status: 503 }) } return next() }) ``` ## Environment Tree Shaking - **Server bundle**: All middleware code is included - **Client bundle**: `.server()` and `data` validation code are removed You can safely import server-only dependencies in `.server()` callbacks. ## Best Practices 1. **Start with request middleware for cross-cutting concerns.** Logging, security headers, CORS, and auth session resolution apply to all requests including SSR. 2. **Use function middleware only when you need `.client()` or `.validator()`.** If you only need server-side logic, request middleware is simpler. 3. **Always return the result of next().** Forgetting to return breaks the pipeline. 4. **Validate sendContext on the server.** Client-sent context is type-safe but not runtime-validated. Validate dynamic data before trusting it. 5. **Create src/start.ts for global middleware.** This file is not in the default template. Create it for `requestMiddleware` or `functionMiddleware`. 6. **Compose small units.** Build focused middleware and compose via `.middleware([...])` rather than duplicating logic. 7. **The auth boundary is the endpoint, not the route.** Authorize every server function and server route that reads or writes private data inside its handler or middleware - they are reachable independently of whichever route rendered the calling UI. `beforeLoad` on layout routes like `_authed.tsx` is route UX (redirects, navigation control), not the data-authorization boundary. Use both, but never rely on a route guard to protect data. Test direct unauthenticated calls to protected server functions - they must reject before returning data. ## Official References - Middleware: https://tanstack.com/start/latest/docs/framework/react/guide/middleware - Authentication: https://tanstack.com/start/latest/docs/framework/react/guide/authentication - Server functions: https://tanstack.com/start/latest/docs/framework/react/guide/server-functions - Server routes: https://tanstack.com/start/latest/docs/framework/react/guide/server-routes - DIY auth example: https://github.com/TanStack/router/tree/main/examples/react/start-basic-auth -
optimistic-updates.md 16.3 KB
# Optimistic Updates Optimistic updates allow you to update the UI immediately before a mutation completes, providing a better user experience. If the mutation fails, you can roll back to the previous state. ## Basic Optimistic Update ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query'; function TodoList() { const queryClient = useQueryClient(); const toggleTodo = useMutation({ mutationFn: (todoId) => { return fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' }); }, onMutate: async (todoId) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['todos'] }); // Snapshot the previous value const previousTodos = queryClient.getQueryData(['todos']); // Optimistically update queryClient.setQueryData(['todos'], (old) => old.map((todo) => todo.id === todoId ? { ...todo, done: !todo.done } : todo ) ); // Return context with previous value return { previousTodos }; }, onError: (err, todoId, context) => { // Rollback on error queryClient.setQueryData(['todos'], context.previousTodos); }, onSettled: () => { // Refetch after mutation completes queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); return ( <div> {/* render todos with toggle */} <button onClick={() => toggleTodo.mutate(todoId)}>Toggle</button> </div> ); } ``` ## Mutation Lifecycle Understanding the mutation lifecycle is crucial for optimistic updates: ```tsx const mutation = useMutation({ mutationFn: updateTodo, // 1. Before mutation function runs onMutate: async (variables) => { // Cancel queries, snapshot data, optimistically update // Return context object return { previousData }; }, // 2. If mutation succeeds onSuccess: (data, variables, context) => { // Handle successful mutation // data = mutation function response // variables = mutation variables // context = returned from onMutate }, // 3. If mutation fails onError: (error, variables, context) => { // Rollback optimistic update // error = error object // context = returned from onMutate }, // 4. Always runs after success or error onSettled: (data, error, variables, context) => { // Refetch to sync with server }, }); ``` ## Optimistic Update Patterns ### Adding an Item ```tsx const addTodo = useMutation({ mutationFn: (newTodo) => { return fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo), }).then(res => res.json()); }, onMutate: async (newTodo) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); // Add optimistic todo with temporary ID queryClient.setQueryData(['todos'], (old) => [ ...old, { ...newTodo, id: 'temp-' + Date.now(), status: 'pending' }, ]); return { previousTodos }; }, onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context.previousTodos); }, onSuccess: (data) => { // Replace temporary item with real server response queryClient.setQueryData(['todos'], (old) => old.map((todo) => todo.id.toString().startsWith('temp-') ? data : todo ) ); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); ``` ### Updating an Item ```tsx const updateTodo = useMutation({ mutationFn: ({ id, updates }) => { return fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify(updates), }).then(res => res.json()); }, onMutate: async ({ id, updates }) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => old.map((todo) => todo.id === id ? { ...todo, ...updates } : todo ) ); return { previousTodos }; }, onError: (err, variables, context) => { queryClient.setQueryData(['todos'], context.previousTodos); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); // Usage updateTodo.mutate({ id: 1, updates: { title: 'Updated title' } }); ``` ### Deleting an Item ```tsx const deleteTodo = useMutation({ mutationFn: (todoId) => { return fetch(`/api/todos/${todoId}`, { method: 'DELETE' }); }, onMutate: async (todoId) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => old.filter((todo) => todo.id !== todoId) ); return { previousTodos }; }, onError: (err, todoId, context) => { queryClient.setQueryData(['todos'], context.previousTodos); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); ``` ## Multiple Query Updates Update multiple related queries optimistically: ```tsx const updateUser = useMutation({ mutationFn: ({ userId, updates }) => { return fetch(`/api/users/${userId}`, { method: 'PATCH', body: JSON.stringify(updates), }).then(res => res.json()); }, onMutate: async ({ userId, updates }) => { // Cancel all related queries await queryClient.cancelQueries({ queryKey: ['users'] }); await queryClient.cancelQueries({ queryKey: ['user', userId] }); // Snapshot previous data const previousUsers = queryClient.getQueryData(['users']); const previousUser = queryClient.getQueryData(['user', userId]); // Update users list queryClient.setQueryData(['users'], (old) => old?.map((user) => user.id === userId ? { ...user, ...updates } : user ) ); // Update individual user queryClient.setQueryData(['user', userId], (old) => ({ ...old, ...updates, })); return { previousUsers, previousUser }; }, onError: (err, { userId }, context) => { // Rollback both queries queryClient.setQueryData(['users'], context.previousUsers); queryClient.setQueryData(['user', userId], context.previousUser); }, onSettled: (data, error, { userId }) => { queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['user', userId] }); }, }); ``` ## Optimistic Updates with Infinite Queries ```tsx const addPost = useMutation({ mutationFn: (newPost) => { return fetch('/api/posts', { method: 'POST', body: JSON.stringify(newPost), }).then(res => res.json()); }, onMutate: async (newPost) => { await queryClient.cancelQueries({ queryKey: ['posts'] }); const previousPosts = queryClient.getQueryData(['posts']); // Add to first page queryClient.setQueryData(['posts'], (old) => { if (!old?.pages.length) return old; return { ...old, pages: [ { ...old.pages[0], posts: [ { ...newPost, id: 'temp-' + Date.now() }, ...old.pages[0].posts, ], }, ...old.pages.slice(1), ], }; }); return { previousPosts }; }, onError: (err, newPost, context) => { queryClient.setQueryData(['posts'], context.previousPosts); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }); }, }); ``` ## UI Feedback During Optimistic Updates ### Show Pending State ```tsx function TodoItem({ todo }) { const queryClient = useQueryClient(); const toggleTodo = useMutation({ mutationFn: (todoId) => fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' }), onMutate: async (todoId) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => old.map((t) => t.id === todoId ? { ...t, done: !t.done, isPending: true } // Mark as pending : t ) ); return { previousTodos }; }, onSuccess: (data, todoId) => { // Remove pending state queryClient.setQueryData(['todos'], (old) => old.map((t) => t.id === todoId ? { ...t, isPending: false } : t ) ); }, onError: (err, todoId, context) => { queryClient.setQueryData(['todos'], context.previousTodos); }, }); return ( <div className={todo.isPending ? 'opacity-50' : ''}> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo.mutate(todo.id)} disabled={todo.isPending} /> {todo.title} </div> ); } ``` ### Show Error State ```tsx const [error, setError] = useState(null); const updateTodo = useMutation({ mutationFn: updateTodoApi, onMutate: async (updates) => { setError(null); // Clear previous errors // ... optimistic update }, onError: (err, variables, context) => { setError(err.message); // ... rollback }, }); return ( <div> {error && <div className="error">{error}</div>} {/* render todo */} </div> ); ``` ## Advanced Patterns ### Optimistic Update with Retry ```tsx const updateTodo = useMutation({ mutationFn: updateTodoApi, retry: 3, onMutate: async (updates) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => old.map((todo) => todo.id === updates.id ? { ...todo, ...updates, _optimistic: true } : todo ) ); return { previousTodos }; }, onSuccess: (data, variables) => { // Remove optimistic flag queryClient.setQueryData(['todos'], (old) => old.map((todo) => todo.id === variables.id ? { ...todo, _optimistic: false } : todo ) ); }, onError: (err, variables, context) => { // Only rollback if all retries failed if (err.retryCount >= 3) { queryClient.setQueryData(['todos'], context.previousTodos); } }, }); ``` ### Debounced Optimistic Updates For rapid updates like typing in a search or editing text: ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useDebouncedCallback } from 'use-debounce'; function TodoTitle({ todo }) { const queryClient = useQueryClient(); const [localTitle, setLocalTitle] = useState(todo.title); const updateTodo = useMutation({ mutationFn: ({ id, title }) => { return fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ title }), }).then(res => res.json()); }, onMutate: async ({ id, title }) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => old.map((t) => (t.id === id ? { ...t, title } : t)) ); return { previousTodos }; }, onError: (err, variables, context) => { queryClient.setQueryData(['todos'], context.previousTodos); setLocalTitle(context.previousTodos.find(t => t.id === variables.id).title); }, }); const debouncedUpdate = useDebouncedCallback( (id, title) => updateTodo.mutate({ id, title }), 500 ); const handleChange = (e) => { const newTitle = e.target.value; setLocalTitle(newTitle); debouncedUpdate(todo.id, newTitle); }; return <input value={localTitle} onChange={handleChange} />; } ``` ### Optimistic Delete with Undo ```tsx function TodoItem({ todo }) { const queryClient = useQueryClient(); const [showUndo, setShowUndo] = useState(false); const deleteTodo = useMutation({ mutationFn: (todoId) => { return fetch(`/api/todos/${todoId}`, { method: 'DELETE' }); }, onMutate: async (todoId) => { setShowUndo(true); await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => old.filter((t) => t.id !== todoId) ); // Auto-hide undo after 5 seconds setTimeout(() => setShowUndo(false), 5000); return { previousTodos }; }, onError: (err, todoId, context) => { queryClient.setQueryData(['todos'], context.previousTodos); setShowUndo(false); }, }); const handleUndo = () => { deleteTodo.reset(); // Reset mutation state queryClient.invalidateQueries({ queryKey: ['todos'] }); setShowUndo(false); }; if (showUndo) { return ( <div className="undo-banner"> Todo deleted <button onClick={handleUndo}>Undo</button> </div> ); } return ( <div> {todo.title} <button onClick={() => deleteTodo.mutate(todo.id)}>Delete</button> </div> ); } ``` ### Batch Optimistic Updates ```tsx const markAllDone = useMutation({ mutationFn: () => { return fetch('/api/todos/mark-all-done', { method: 'POST' }); }, onMutate: async () => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => old.map((todo) => ({ ...todo, done: true })) ); return { previousTodos }; }, onError: (err, variables, context) => { queryClient.setQueryData(['todos'], context.previousTodos); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); ``` ## Cancel In-Flight Mutations Cancel mutations that are no longer needed: ```tsx function QuickEdit({ todo }) { const queryClient = useQueryClient(); const updateTodo = useMutation({ mutationFn: ({ id, title }) => { return fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ title }), }).then(res => res.json()); }, onMutate: async ({ id, title }) => { // Cancel previous mutations for this todo queryClient.cancelMutations({ mutationKey: ['updateTodo', id] }); await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => old.map((t) => (t.id === id ? { ...t, title } : t)) ); return { previousTodos }; }, }); return ( <input onChange={(e) => updateTodo.mutate({ id: todo.id, title: e.target.value })} /> ); } ``` ### Known Issues (Fixed) - **initialData mount timing (fixed v5.87.1):** When using `initialData` with optimistic updates, a race condition could cause the optimistic value to be overwritten by `initialData` on component remount during the mutation. Fixed in v5.87.1 - if you see stale data flash after optimistic updates, upgrade to this version or later. ## Best Practices 1. **Always Cancel Queries** ```tsx await queryClient.cancelQueries({ queryKey: ['todos'] }); ``` Prevents race conditions between optimistic update and ongoing fetches. 2. **Always Return Context** ```tsx onMutate: async (variables) => { const previousData = queryClient.getQueryData(['todos']); // ... update return { previousData }; // Critical for rollback } ``` 3. **Always Handle Errors** ```tsx onError: (err, variables, context) => { queryClient.setQueryData(['todos'], context.previousData); } ``` 4. **Use onSettled for Refetch** ```tsx onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); } ``` Ensures data stays in sync with server. 5. **Show Visual Feedback** - Add loading/pending states to optimistically updated items - Show error messages on failure - Provide undo functionality where appropriate 6. **Handle Multiple Related Queries** - Update all queries that display the same data - Rollback all queries on error 7. **Consider Using Temporary IDs** - For created items, use temp IDs until server responds - Replace with server IDs on success 8. **Test Error Cases** - Verify rollback works correctly - Test network failures - Test validation errors from server 9. **Use queryOptions() for Type Safety** ```tsx const todosOptions = queryOptions({ queryKey: ['todos'], queryFn: fetchTodos, }); // Reuse in mutation callbacks for consistent types onMutate: async () => { const previous = queryClient.getQueryData(todosOptions.queryKey); // TypeScript knows the exact type of previous return { previous }; } ``` -
query-guide.md 21.4 KB
# TanStack Query (React Query) v5 Powerful asynchronous state management for React. TanStack Query makes fetching, caching, synchronizing, and updating server state in your React applications a breeze. ## When to Use This Skill - Fetching data from REST APIs or GraphQL endpoints - Managing server state and cache lifecycle - Implementing mutations (create, update, delete operations) - Building infinite scroll or load-more patterns - Handling optimistic UI updates - Rendering streaming/chunked data from AI or SSE endpoints - Integrating with tRPC v11 queryOptions pattern - Synchronizing data across components - Implementing background data refetching - Managing complex async state without Redux or other state managers ## Quick Start Workflow ### 1. Installation ```bash npm install @tanstack/react-query # or pnpm add @tanstack/react-query # or yarn add @tanstack/react-query ``` ### 2. Setup QueryClient Wrap your application with `QueryClientProvider`: ```tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient(); function App() { return ( <QueryClientProvider client={queryClient}> <YourApp /> </QueryClientProvider> ); } ``` ### 3. Basic Query ```tsx import { useQuery } from '@tanstack/react-query'; function TodoList() { const { data, isLoading, error } = useQuery({ queryKey: ['todos'], queryFn: async () => { const res = await fetch('https://api.example.com/todos'); if (!res.ok) throw new Error('Network response was not ok'); return res.json(); }, }); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <ul> {data.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ); } ``` ### 4. Basic Mutation ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query'; function CreateTodo() { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: async (newTodo) => { const res = await fetch('https://api.example.com/todos', { method: 'POST', body: JSON.stringify(newTodo), headers: { 'Content-Type': 'application/json' }, }); return res.json(); }, onSuccess: () => { // Invalidate and refetch todos queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); return ( <button onClick={() => mutation.mutate({ title: 'New Todo' })}> {mutation.isPending ? 'Creating...' : 'Create Todo'} </button> ); } ``` ## Core Concepts ### Query Keys Query keys uniquely identify queries and are used for caching. They must be arrays. ```tsx // Simple key useQuery({ queryKey: ['todos'], queryFn: fetchTodos }); // Key with variables useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) }); // Hierarchical keys useQuery({ queryKey: ['todos', 'list', { filters, page }], queryFn: fetchTodos }); ``` **Query key matching:** - `['todos']` - exact match - `['todos', { page: 1 }]` - exact match with object - `{ queryKey: ['todos'] }` - matches all queries starting with 'todos' **Footgun - include every runtime dimension in the key.** If the result depends on a dimension that is *not* in the key (active network/chain, tenant, workspace, locale), switching that dimension serves cached data from the previous context. Add every input that changes the response: `['todos', { tenantId, network }]`. **Footgun - keep key inputs referentially stable.** A queryKey is compared structurally, but a *new reference produced every render* still churns downstream code that treats it as a dependency. The classic loop: a hook returns `query.data ?? []`, handing back a fresh `[]` each render; feed that into a `useEffect` dep array or another queryKey and you get infinite refetches or "Maximum update depth exceeded". Fixes: hoist a stable `const EMPTY: Item[] = []` and return `query.data ?? EMPTY`; use `select` to derive stable values; and avoid putting freshly-constructed objects/arrays (or proxy getters that return a new object per access) directly into a key or dep array. ### Query Functions Query functions must return a promise that resolves data or throws an error: ```tsx // Using fetch queryFn: async () => { const res = await fetch(url); if (!res.ok) throw new Error('Failed to fetch'); return res.json(); } // Using axios queryFn: () => axios.get(url).then(res => res.data) // With query key access queryFn: ({ queryKey }) => { const [_, todoId] = queryKey; return fetchTodo(todoId); } ``` ### Important Defaults Understanding defaults is crucial for optimal usage: - **staleTime: 0** - Queries become stale immediately by default - **gcTime: 5 minutes** - Unused/inactive cache data remains in memory for 5 minutes - **retry: 3** - Failed queries retry 3 times with exponential backoff - **refetchOnWindowFocus: true** - Queries refetch when window regains focus - **refetchOnReconnect: true** - Queries refetch when network reconnects ```tsx // Override defaults globally const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 10, // 10 minutes }, }, }); // Or per query useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 1000 * 60, // 1 minute retry: 5, }); ``` ### Query Status and Fetch Status Queries have two important states: **Query Status:** - `pending` - No cached data, query is executing - `error` - Query encountered an error - `success` - Query succeeded and data is available **Fetch Status:** - `fetching` - Query function is executing - `paused` - Query wants to fetch but is paused (offline) - `idle` - Query is not fetching ```tsx const { data, status, fetchStatus, isLoading, isFetching } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, }); // isLoading = status === 'pending' // isFetching = fetchStatus === 'fetching' ``` ### App-Wide Loading Indicators `useIsFetching` / `useIsMutating` return the *number* of queries currently fetching / mutations currently running across the whole cache - ideal for a global top-bar spinner without threading state through components. Both accept optional filters to scope the count. ```tsx import { useIsFetching, useIsMutating } from '@tanstack/react-query'; function GlobalSpinner() { const fetching = useIsFetching(); // all queries const mutating = useIsMutating({ mutationKey: ['todos'] }); // scoped return fetching + mutating > 0 ? <TopProgressBar /> : null; } ``` ### Query Invalidation Mark queries as stale to trigger refetches: ```tsx const queryClient = useQueryClient(); // Invalidate all todos queries queryClient.invalidateQueries({ queryKey: ['todos'] }); // Invalidate specific query queryClient.invalidateQueries({ queryKey: ['todo', todoId] }); // Invalidate and refetch immediately queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'active' // only refetch active queries }); ``` ### Mutations Mutations are used for creating, updating, or deleting data: ```tsx const mutation = useMutation({ mutationFn: (newTodo) => { return fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo), }); }, onSuccess: (data, variables, context) => { console.log('Success!', data); }, onError: (error, variables, context) => { console.error('Error:', error); }, onSettled: (data, error, variables, context) => { console.log('Mutation finished'); }, }); // Trigger mutation mutation.mutate({ title: 'New Todo' }); // With async/await mutation.mutateAsync({ title: 'New Todo' }) .then(data => console.log(data)) .catch(error => console.error(error)); ``` **Two recurring mutation pitfalls:** - Mutations do **not** auto-invalidate or refetch related queries. Call `queryClient.invalidateQueries(...)` (or `setQueryData`) in `onSuccess`/`onSettled` yourself. - Under React StrictMode a submit handler can fire the mutation twice. Guard event handlers: `if (mutation.isPending) return;` before calling `mutate`. ### Reusable Mutation Definitions with `mutationOptions` Just as `queryOptions()` co-locates query config, the native `mutationOptions()` helper (stable since v5; companion to `queryOptions`) shares a typed mutation definition across components, `useMutation`, and `queryClient.getMutationDefaults`: ```tsx import { mutationOptions, useMutation, useQueryClient } from '@tanstack/react-query'; function createTodoOptions(queryClient: QueryClient) { return mutationOptions({ mutationKey: ['todos', 'create'], mutationFn: (todo: { title: string }) => fetch('/api/todos', { method: 'POST', body: JSON.stringify(todo) }).then(r => r.json()), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), }); } // usage: const queryClient = useQueryClient(); const mutation = useMutation(createTodoOptions(queryClient)); ``` ### Reading mutation state across components with `useMutationState` `useMutationState` reads state from the mutation cache without holding the `useMutation` instance - useful for shared pending/optimistic UI (e.g. showing in-flight items in a list the mutation does not own): ```tsx import { useMutationState } from '@tanstack/react-query'; // All variables of currently-pending create-todo mutations const pendingTodos = useMutationState({ filters: { mutationKey: ['todos', 'create'], status: 'pending' }, select: (mutation) => mutation.state.variables as { title: string }, }); ``` ### React Suspense Integration TanStack Query supports React Suspense with dedicated hooks: ```tsx import { useSuspenseQuery } from '@tanstack/react-query'; function TodoList() { // This will suspend the component until data is ready const { data } = useSuspenseQuery({ queryKey: ['todos'], queryFn: fetchTodos, }); // No need for loading states - handled by Suspense boundary return ( <ul> {data.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ); } // In parent component function App() { return ( <Suspense fallback={<div>Loading todos...</div>}> <TodoList /> </Suspense> ); } ``` ### Streamed Queries (Experimental) Consume `AsyncIterable` streams as query data - ideal for AI chat, SSE, and streaming responses: ```tsx import { useQuery, queryOptions } from '@tanstack/react-query'; import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query'; async function* fetchChatStream(sessionId: string): AsyncIterable<string> { const response = await fetch(`/api/chat/${sessionId}`, { method: 'POST' }); const reader = response.body!.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; yield decoder.decode(value); } } function ChatMessages({ sessionId }: { sessionId: string }) { const { data: chunks, status, fetchStatus } = useQuery( queryOptions({ queryKey: ['chat', sessionId], queryFn: streamedQuery({ streamFn: () => fetchChatStream(sessionId), // Optional: customize how chunks accumulate // reducer: (acc, chunk) => [...acc, chunk], // initialValue: [], refetchMode: 'reset', // 'reset' | 'append' | 'replace' }), }) ); // status === 'pending' until first chunk arrives // status === 'success' after first chunk, fetchStatus === 'fetching' until stream ends if (status === 'pending') return <div>Waiting for response...</div>; return ( <div> {chunks?.map((chunk, i) => <span key={i}>{chunk}</span>)} {fetchStatus === 'fetching' && <span className="cursor" />} </div> ); } ``` **`refetchMode` options:** - `'reset'` - clear data and start fresh on refetch - `'append'` - keep existing chunks and add new ones - `'replace'` - replace data chunk-by-chunk on refetch **Note:** The API stabilized at v5.86.0. Earlier versions used `queryFn` instead of `streamFn` and `maxChunks` instead of `reducer`. ### Prefetch in Render (Experimental) Use React 19's `React.use()` with TanStack Query for "render-as-you-fetch": ```tsx // Enable the feature flag const queryClient = new QueryClient({ defaultOptions: { queries: { experimental_prefetchInRender: true, }, }, }); // Component that suspends with React.use() function TodoList({ query }: { query: UseQueryResult<Todo[]> }) { const data = React.use(query.promise); // Suspends until resolved return <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>; } function App() { const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }); return ( <React.Suspense fallback={<div>Loading...</div>}> <TodoList query={query} /> </React.Suspense> ); } ``` **Known limitations:** queries may run twice on unsuspend, incompatible with `useQueries`, `skipToken` and `refetch()` cannot be used together. ## Advanced Topics For detailed information on advanced patterns, see the reference files: ### Infinite Queries For implementing infinite scroll and load-more patterns: - See `infinite-queries.md` for comprehensive guide - Covers `useInfiniteQuery` hook - Bidirectional pagination - `getNextPageParam` and `getPreviousPageParam` - Refetching and background updates ### Optimistic Updates For updating UI before server confirmation: - See `optimistic-updates.md` for detailed patterns - Optimistic mutations - Rollback on error - Context for cancellation - UI feedback strategies ### TypeScript Support For full type safety and inference: - See `query-typescript.md` for complete TypeScript guide - Type inference from query functions - Generic type parameters - Typing query options - Custom hooks with types - Error type narrowing ### Query Invalidation Patterns For advanced cache invalidation strategies: - See `query-invalidation.md` - Partial matching - Predicate functions - Refetch strategies - Query filters ### Performance Optimization For optimizing query performance: - See `query-performance.md` - Query deduplication - Structural sharing - Memory management - Query splitting strategies ## DevTools TanStack Query DevTools provide visual insights into query state: ```bash npm install @tanstack/react-query-devtools ``` ```tsx import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; function App() { return ( <QueryClientProvider client={queryClient}> <YourApp /> <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> ); } ``` **DevTools features:** - View all queries and their states - Inspect query data and errors - Manually trigger refetches - Invalidate queries - Monitor cache lifecycle - Visual indicator for `staleTime: Infinity` ("static") queries (v5.80.0+) ## Common Patterns ### Dependent Queries Run queries in sequence when one depends on another: ```tsx // First query const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), }); // Second query depends on first const { data: projects } = useQuery({ queryKey: ['projects', user?.id], queryFn: () => fetchProjects(user.id), enabled: !!user?.id, // Only run when user.id is available }); ``` ### Parallel Queries Multiple independent queries in one component: ```tsx function Dashboard() { const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers }); const posts = useQuery({ queryKey: ['posts'], queryFn: fetchPosts }); const stats = useQuery({ queryKey: ['stats'], queryFn: fetchStats }); if (users.isLoading || posts.isLoading || stats.isLoading) { return <div>Loading...</div>; } // All queries succeeded return <DashboardView users={users.data} posts={posts.data} stats={stats.data} />; } ``` ### Dynamic Parallel Queries Use `useQueries` for dynamic number of queries: ```tsx import { useQueries } from '@tanstack/react-query'; function TodoLists({ listIds }) { const results = useQueries({ queries: listIds.map((id) => ({ queryKey: ['list', id], queryFn: () => fetchList(id), })), }); const isLoading = results.some(result => result.isLoading); const data = results.map(result => result.data); return <Lists data={data} />; } ``` ### Prefetching Prefetch data before it's needed: ```tsx const queryClient = useQueryClient(); // Prefetch on hover function TodoListLink({ id }) { const prefetch = () => { queryClient.prefetchQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id), staleTime: 1000 * 60 * 5, // Cache for 5 minutes }); }; return ( <Link to={`/todo/${id}`} onMouseEnter={prefetch}> View Todo </Link> ); } ``` ### Initial Data Provide initial data to avoid loading states: ```tsx function TodoDetail({ todoId, initialTodo }) { const { data } = useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId), initialData: initialTodo, // Use this data immediately staleTime: 1000 * 60, // Consider fresh for 1 minute }); return <div>{data.title}</div>; } ``` ### Placeholder Data Show placeholder while loading: ```tsx const { data, isPlaceholderData } = useQuery({ queryKey: ['todos', page], queryFn: () => fetchTodos(page), placeholderData: (previousData) => previousData, // Keep previous data while loading }); // Or use static placeholder const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, placeholderData: { items: [], total: 0 }, }); // TypeScript: isPlaceholderData now narrows data type (v5.65.0+) // When isPlaceholderData is true, data is typed as the placeholder type ``` ### tRPC v11 Integration tRPC v11 exposes `queryOptions` and `mutationOptions` directly, removing the need for custom hook wrappers: ```tsx import { useTRPC } from '@trpc/tanstack-react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; function TodoList() { const trpc = useTRPC(); const queryClient = useQueryClient(); // Direct queryOptions pattern (replaces trpc.todo.list.useQuery()) const { data } = useQuery(trpc.todo.list.queryOptions()); const createTodo = useMutation( trpc.todo.create.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries(trpc.todo.list.queryOptions()); }, }) ); // Prefetching also works queryClient.prefetchQuery(trpc.todo.list.queryOptions()); } ``` Requires `@tanstack/react-query@5.62.8+` and `@trpc/tanstack-react-query`. ## Error Handling ### Query Errors ```tsx const { error, isError } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }); if (isError) { return <div>Error: {error.message}</div>; } ``` ### Global Error Handling Use `QueryCache` and `MutationCache` callbacks for global error handling: ```tsx import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query'; const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { console.error(`Query ${query.queryKey} failed:`, error); // Show toast notification, etc. }, }), mutationCache: new MutationCache({ onError: (error, _variables, _context, mutation) => { console.error('Mutation failed:', error); }, }), }); ``` ### Error Boundaries Combine with React Error Boundaries: ```tsx import { useQuery } from '@tanstack/react-query'; import { ErrorBoundary } from 'react-error-boundary'; function TodoList() { const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, throwOnError: true, // Throw errors to error boundary }); return <div>{/* render data */}</div>; } function App() { return ( <ErrorBoundary fallback={<div>Something went wrong</div>}> <TodoList /> </ErrorBoundary> ); } ``` ## Best Practices 1. **Use Query Keys Wisely** - Structure keys hierarchically: `['todos', 'list', { filters }]` - Include all variables in the key - Keep keys consistent across your app 2. **Set Appropriate staleTime** - Static data: `staleTime: Infinity` - Frequently changing: `staleTime: 0` (default) - Moderately changing: `staleTime: 1000 * 60 * 5` (5 minutes) 3. **Handle Loading and Error States** - Always check `isLoading` and `error` - Provide meaningful loading indicators - Show user-friendly error messages 4. **Optimize Refetching** - Disable unnecessary refetches with `refetchOnWindowFocus: false` - Use `staleTime` to reduce refetches - Consider using `refetchInterval` for polling 5. **Invalidate Efficiently** - Invalidate specific queries, not all queries - Use query key prefixes for related queries - Combine with optimistic updates for better UX 6. **Use TypeScript** - Type your query functions for type inference - Use generic type parameters when needed - Enable strict type checking 7. **Leverage DevTools** - Install DevTools in development - Monitor query behavior - Debug cache issues ## Resources - **Official Documentation**: https://tanstack.com/query/latest/docs/framework/react/overview - **GitHub Repository**: https://github.com/TanStack/query - **Examples**: https://tanstack.com/query/latest/docs/framework/react/examples - **Community**: https://discord.gg/tanstack - **TypeScript Guide**: https://tanstack.com/query/latest/docs/framework/react/typescript ## Migration from v4 If you're upgrading from React Query v4: - `cacheTime` renamed to `gcTime` - `useInfiniteQuery` pageParam changes - New `useSuspenseQuery` hooks - Improved TypeScript inference - v4.42.0 added React 19 support for teams not yet migrated to v5 - See official migration guide: https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5 -
query-invalidation.md 16.6 KB
# Query Invalidation Query invalidation is the process of marking queries as stale and potentially refetching them. This is essential for keeping your cache in sync with server state after mutations. ## Basic Invalidation ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query'; function CreateTodo() { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (newTodo) => { return fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo), }).then(res => res.json()); }, onSuccess: () => { // Mark todos queries as stale and refetch queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); return ( <button onClick={() => mutation.mutate({ title: 'New Todo' })}> Create Todo </button> ); } ``` ## Invalidation Methods ### invalidateQueries Marks queries as stale and triggers refetch of active queries: ```tsx // Invalidate all queries queryClient.invalidateQueries(); // Invalidate specific query queryClient.invalidateQueries({ queryKey: ['todos'] }); // Invalidate query with exact match queryClient.invalidateQueries({ queryKey: ['todo', todoId], exact: true }); // Invalidate and wait for refetch await queryClient.invalidateQueries({ queryKey: ['todos'] }); ``` ### refetchQueries Directly refetch queries without marking as stale first: ```tsx // Refetch all queries queryClient.refetchQueries(); // Refetch specific queries queryClient.refetchQueries({ queryKey: ['todos'] }); // Refetch only active queries queryClient.refetchQueries({ queryKey: ['todos'], type: 'active' }); // Refetch only inactive queries queryClient.refetchQueries({ queryKey: ['todos'], type: 'inactive' }); ``` ### resetQueries Reset queries to their initial state: ```tsx // Reset and refetch queryClient.resetQueries({ queryKey: ['todos'] }); // Reset specific query queryClient.resetQueries({ queryKey: ['todo', todoId] }); ``` ## Query Key Matching ### Prefix Matching By default, invalidateQueries uses prefix matching: ```tsx // This query useQuery({ queryKey: ['todos', 'list', { page: 1 }], queryFn: fetchTodos }); // Is invalidated by any of these: queryClient.invalidateQueries({ queryKey: ['todos'] }); queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }); queryClient.invalidateQueries({ queryKey: ['todos', 'list', { page: 1 }] }); // But NOT by these: queryClient.invalidateQueries({ queryKey: ['todos', 'detail'] }); queryClient.invalidateQueries({ queryKey: ['users'] }); ``` ### Exact Matching Use `exact: true` for precise matching: ```tsx // Only invalidate this exact query key queryClient.invalidateQueries({ queryKey: ['todos', 'list', { page: 1 }], exact: true, }); // This would invalidate: useQuery({ queryKey: ['todos', 'list', { page: 1 }], ... }); // But NOT these: useQuery({ queryKey: ['todos', 'list', { page: 2 }], ... }); useQuery({ queryKey: ['todos', 'list'], ... }); useQuery({ queryKey: ['todos'], ... }); ``` ### Predicate Functions Use custom matching logic: ```tsx // Invalidate all todos queries except detail queries queryClient.invalidateQueries({ predicate: (query) => { return query.queryKey[0] === 'todos' && query.queryKey[1] !== 'detail'; }, }); // Invalidate stale queries only queryClient.invalidateQueries({ predicate: (query) => { return query.state.isInvalidated; }, }); // Invalidate based on query data queryClient.invalidateQueries({ predicate: (query) => { const data = query.state.data as Todo[] | undefined; return data?.some((todo) => todo.userId === targetUserId) ?? false; }, }); ``` ## Invalidation Timing ### Immediate Invalidation Invalidate and refetch immediately: ```tsx const mutation = useMutation({ mutationFn: createTodo, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); ``` ### Delayed Invalidation Wait for mutation to settle: ```tsx const mutation = useMutation({ mutationFn: createTodo, onSettled: () => { // Runs after success or error queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); ``` ### Conditional Invalidation Only invalidate under certain conditions: ```tsx const mutation = useMutation({ mutationFn: updateTodo, onSuccess: (data, variables) => { if (data.isPublished) { // Only invalidate if todo was published queryClient.invalidateQueries({ queryKey: ['todos', 'published'] }); } }, }); ``` ## Refetch Strategies ### Refetch Active Queries Only ```tsx queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'active', // Only refetch active queries (default) }); ``` ### Refetch All Queries ```tsx queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'all', // Refetch both active and inactive queries }); ``` ### Don't Refetch ```tsx queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'none', // Only mark as stale, don't refetch }); ``` ## Invalidation Patterns ### After Create ```tsx const createTodo = useMutation({ mutationFn: (newTodo) => fetch('/api/todos', { method: 'POST', ... }), onSuccess: () => { // Invalidate list queries to show new item queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }); }, }); ``` ### After Update ```tsx const updateTodo = useMutation({ mutationFn: ({ id, updates }) => fetch(`/api/todos/${id}`, { method: 'PATCH', ... }), onSuccess: (data, { id }) => { // Invalidate specific item queryClient.invalidateQueries({ queryKey: ['todo', id] }); // Invalidate list in case item moved categories, etc. queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }); }, }); ``` ### After Delete ```tsx const deleteTodo = useMutation({ mutationFn: (id) => fetch(`/api/todos/${id}`, { method: 'DELETE' }), onSuccess: (_, id) => { // Remove specific item from cache queryClient.removeQueries({ queryKey: ['todo', id] }); // Invalidate lists queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }); }, }); ``` ### Bulk Operations ```tsx const markAllDone = useMutation({ mutationFn: () => fetch('/api/todos/mark-all-done', { method: 'POST' }), onSuccess: () => { // Invalidate all todo-related queries queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); ``` ## Related Query Invalidation ### Update Multiple Related Queries ```tsx const updateUser = useMutation({ mutationFn: ({ userId, updates }) => updateUserApi(userId, updates), onSuccess: (data, { userId }) => { // Invalidate user detail queryClient.invalidateQueries({ queryKey: ['user', userId] }); // Invalidate user list queryClient.invalidateQueries({ queryKey: ['users'] }); // Invalidate user's posts queryClient.invalidateQueries({ queryKey: ['posts', 'user', userId] }); // Invalidate user's comments queryClient.invalidateQueries({ queryKey: ['comments', 'user', userId] }); }, }); ``` ### Hierarchical Invalidation ```tsx // Query key structure: // ['todos'] - all todos // ['todos', 'list'] - todo lists // ['todos', 'list', filters] - filtered lists // ['todos', 'detail'] - todo details // ['todos', 'detail', id] - specific todo const updateTodo = useMutation({ mutationFn: updateTodoApi, onSuccess: (data, { id }) => { // Invalidate specific todo detail queryClient.invalidateQueries({ queryKey: ['todos', 'detail', id] }); // Invalidate all list queries (they might show this todo) queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }); }, }); ``` ## Invalidation with Infinite Queries ### Invalidate All Pages ```tsx const createPost = useMutation({ mutationFn: (newPost) => createPostApi(newPost), onSuccess: () => { // Refetches all pages queryClient.invalidateQueries({ queryKey: ['posts'] }); }, }); ``` ### Invalidate Specific Pages ```tsx queryClient.invalidateQueries({ queryKey: ['posts'], refetchPage: (page, index) => { // Only refetch first page return index === 0; }, }); ``` ### Selective Page Refetch ```tsx const updatePost = useMutation({ mutationFn: ({ id, updates }) => updatePostApi(id, updates), onSuccess: (data, { id }) => { queryClient.invalidateQueries({ queryKey: ['posts'], refetchPage: (page, index) => { // Only refetch pages containing this post return page.posts.some((post) => post.id === id); }, }); }, }); ``` ## Advanced Invalidation ### Cascading Invalidation ```tsx const deleteProject = useMutation({ mutationFn: (projectId) => deleteProjectApi(projectId), onSuccess: async (_, projectId) => { // Step 1: Remove project from cache queryClient.removeQueries({ queryKey: ['project', projectId] }); // Step 2: Invalidate project list await queryClient.invalidateQueries({ queryKey: ['projects'] }); // Step 3: Invalidate related resources await queryClient.invalidateQueries({ queryKey: ['tasks', 'project', projectId] }); await queryClient.invalidateQueries({ queryKey: ['members', 'project', projectId] }); // Step 4: Invalidate summary/stats await queryClient.invalidateQueries({ queryKey: ['stats'] }); }, }); ``` ### Debounced Invalidation For frequent updates, debounce invalidation: ```tsx import { useDebouncedCallback } from 'use-debounce'; function SearchableList() { const queryClient = useQueryClient(); const debouncedInvalidate = useDebouncedCallback(() => { queryClient.invalidateQueries({ queryKey: ['search-results'] }); }, 500); const updateFilters = (newFilters) => { setFilters(newFilters); debouncedInvalidate(); }; return <FilterPanel onChange={updateFilters} />; } ``` ### Throttled Invalidation ```tsx import { throttle } from 'lodash'; const throttledInvalidate = throttle(() => { queryClient.invalidateQueries({ queryKey: ['live-data'] }); }, 1000); // In a websocket listener socket.on('update', () => { throttledInvalidate(); }); ``` ## Query Filters Use query filters for more complex matching. v5.90.8+ supports partial query keys and preserves `readonly` from `as const` assertions: ```tsx import { QueryFilters } from '@tanstack/react-query'; const filters: QueryFilters = { queryKey: ['todos'], type: 'active', // 'active' | 'inactive' | 'all' stale: true, // Only stale queries exact: false, // Prefix matching (supports partial keys) predicate: (query) => { // Custom logic return query.state.dataUpdatedAt > Date.now() - 60000; }, }; queryClient.invalidateQueries(filters); // Partial key matching with as const (v5.90.8+) const todoKeys = { all: ['todos'] as const }; queryClient.invalidateQueries({ queryKey: todoKeys.all }); // readonly works ``` ### Filter by State ```tsx // Only invalidate stale queries queryClient.invalidateQueries({ queryKey: ['todos'], stale: true, }); // Only invalidate fetching queries queryClient.invalidateQueries({ predicate: (query) => query.state.fetchStatus === 'fetching', }); ``` ### Filter by Type ```tsx // Only active queries (currently mounted) queryClient.invalidateQueries({ queryKey: ['todos'], type: 'active', }); // Only inactive queries (not mounted) queryClient.invalidateQueries({ queryKey: ['todos'], type: 'inactive', }); // All queries queryClient.invalidateQueries({ queryKey: ['todos'], type: 'all', }); ``` ## Performance Considerations ### Batch Invalidations ```tsx // ❌ Multiple separate invalidations queryClient.invalidateQueries({ queryKey: ['todos'] }); queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); // ✅ Batch with predicate queryClient.invalidateQueries({ predicate: (query) => { const key = query.queryKey[0]; return key === 'todos' || key === 'users' || key === 'projects'; }, }); ``` ### Smart Invalidation Only invalidate what's needed: ```tsx const updateTodo = useMutation({ mutationFn: updateTodoApi, onSuccess: (data, { id, updates }) => { // If only title changed, no need to invalidate lists if (Object.keys(updates).length === 1 && 'title' in updates) { queryClient.invalidateQueries({ queryKey: ['todo', id], exact: true }); } else { // Status/category changed, invalidate lists too queryClient.invalidateQueries({ queryKey: ['todo', id] }); queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }); } }, }); ``` ### Prevent Over-Invalidation ```tsx // ❌ Too broad - invalidates everything queryClient.invalidateQueries(); // ❌ Still too broad - invalidates all todo queries queryClient.invalidateQueries({ queryKey: ['todos'] }); // ✅ Specific - only invalidates affected queries queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] }); queryClient.invalidateQueries({ queryKey: ['todo', todoId] }); ``` ## Alternatives to Invalidation Sometimes you don't need invalidation: ### Direct Cache Update ```tsx const toggleTodo = useMutation({ mutationFn: (todoId) => toggleTodoApi(todoId), onSuccess: (data, todoId) => { // Directly update cache instead of invalidating queryClient.setQueryData(['todo', todoId], data); queryClient.setQueryData(['todos'], (old) => old?.map((todo) => (todo.id === todoId ? data : todo)) ); }, }); ``` ### Optimistic Updates ```tsx const updateTodo = useMutation({ mutationFn: updateTodoApi, onMutate: async ({ id, updates }) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previous = queryClient.getQueryData(['todos']); // Update cache optimistically queryClient.setQueryData(['todos'], (old) => old?.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo)) ); return { previous }; }, onError: (err, vars, context) => { queryClient.setQueryData(['todos'], context.previous); }, // No need for invalidation if optimistic update is accurate }); ``` ### Polling ```tsx // Instead of manual invalidation, use automatic refetching useQuery({ queryKey: ['live-data'], queryFn: fetchLiveData, refetchInterval: 5000, // Auto-refetch every 5 seconds }); ``` ### Known Issues (Fixed) These bugs can manifest as invalidation-related surprises. They're fixed in latest versions but worth knowing: - **`combine` stable reference bug (fixed v5.90.19):** When passing a stable (non-inline) `combine` function to `useQueries`, it was not called with correct parameters when queries changed dynamically. Workaround: pass `combine` inline until fix is applied. - **`useSuspenseQueries` duplicate keys (fixed v5.90.11):** Passing duplicate `queryKeys` to `useSuspenseQueries` caused infinite render loops instead of an error. - **SSR dehydration (fixed v5.90.3):** Unhandled promise rejections during de/rehydration of pending queries. ### Pitfall: invalidate-then-navigate shows stale data `invalidateQueries` marks the query stale and kicks off a **background** refetch - it does not block. If you navigate (or render the destination) immediately after invalidating, the new view paints with the old cached data before the refetch lands (e.g. a just-closed item still appears in the list). Two fixes: ```tsx // Option A: optimistically update the cache so the change is visible instantly queryClient.setQueryData(['todos'], (old) => old?.filter((t) => t.id !== id)); // Option B: await the refetch before navigating await queryClient.invalidateQueries({ queryKey: ['todos'] }); navigate({ to: '/todos' }); ``` ## Best Practices 1. **Be Specific with Query Keys** ```tsx // ✅ Good - specific invalidation queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] }); // ❌ Bad - too broad queryClient.invalidateQueries({ queryKey: ['todos'] }); ``` 2. **Use Exact Matching When Appropriate** ```tsx queryClient.invalidateQueries({ queryKey: ['todo', todoId], exact: true, // Only this specific todo }); ``` 3. **Invalidate in onSuccess for Success-Only** ```tsx onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); } ``` 4. **Invalidate in onSettled for Always** ```tsx onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); } ``` 5. **Consider Alternatives** - Direct cache updates for simple changes - Optimistic updates for better UX - Polling for real-time data 6. **Batch Related Invalidations** ```tsx await Promise.all([ queryClient.invalidateQueries({ queryKey: ['todos'] }), queryClient.invalidateQueries({ queryKey: ['stats'] }), ]); ``` 7. **Use Predicate Functions for Complex Logic** ```tsx queryClient.invalidateQueries({ predicate: (query) => { // Custom matching logic return shouldInvalidate(query); }, }); ``` 8. **Monitor Invalidation Performance** - Use React Query DevTools - Check for unnecessary refetches - Optimize query key structure -
query-performance.md 18.1 KB
# Performance Optimization Optimize TanStack Query for better performance, reduced network requests, and improved user experience. ## Query Configuration ### staleTime Control how long data is considered fresh: ```tsx // ❌ Default - data stale immediately useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 0, // default }); // ✅ Optimized - data fresh for 5 minutes useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 1000 * 60 * 5, // 5 minutes }); // ✅ Static data - never stale useQuery({ queryKey: ['config'], queryFn: fetchConfig, staleTime: Infinity, }); ``` **`staleTime` is not persistence.** It only suppresses refetches while data is fresh *and the query is mounted*. The cache is in-memory and is wiped on a full page reload - `staleTime` does nothing across reloads. Also note a low `staleTime` (the default is `0`) makes every navigation back to a route refetch; raise it for data that does not change per-visit. For cross-reload persistence, wire up the persist-client plugin (see below). ### Cross-reload persistence The cache is in-memory only, so every full reload refetches from scratch (spinners, empty states). For React, persist it with `PersistQueryClientProvider` from `@tanstack/react-query-persist-client` plus a storage persister: ```tsx import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 1000 * 60 * 60 * 24 } }, // gcTime >= maxAge }) const persister = createSyncStoragePersister({ storage: window.localStorage }) <PersistQueryClientProvider client={queryClient} persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24, // discard restored cache older than this buster: import.meta.env.VITE_BUILD_ID, // bump to invalidate on cache-shape changes dehydrateOptions: { // Never persist credential-bearing or per-identity queries to storage. shouldDehydrateQuery: (q) => q.queryKey[0] !== 'session', }, }} > <App /> </PersistQueryClientProvider> ``` Footguns: - **`gcTime` must be >= `maxAge`** or garbage collection discards the cache before the persister can restore it (defaults: `gcTime` 5min, `maxAge` 24h). - **Set `buster`** to a build/schema version. Without it, a changed cache shape silently hydrates a stale snapshot into new code. - **Exclude credential/identity queries** via `shouldDehydrateQuery` - tokens, API keys, and per-user data should not land in `localStorage`. - **SSR**: persistence is client-only; guard `window` access (`typeof window !== 'undefined'`) so it does not run on the server. - **Version-match** the persist-client package to your installed `@tanstack/react-query` version (TanStack companion packages must share the same version). ### gcTime (formerly cacheTime) Control how long unused data stays in cache: ```tsx // Default - 5 minutes useQuery({ queryKey: ['todos'], queryFn: fetchTodos, gcTime: 1000 * 60 * 5, // default }); // Extended cache for frequently accessed data useQuery({ queryKey: ['user-profile'], queryFn: fetchUserProfile, gcTime: 1000 * 60 * 30, // 30 minutes }); // Immediate cleanup for sensitive data useQuery({ queryKey: ['payment-info'], queryFn: fetchPaymentInfo, gcTime: 0, // Remove immediately when unused }); ``` ### Disable Unnecessary Refetching ```tsx // Disable all automatic refetching useQuery({ queryKey: ['static-data'], queryFn: fetchStaticData, refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: false, }); // Global defaults const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, refetchOnReconnect: false, }, }, }); ``` ## Query Deduplication TanStack Query automatically deduplicates identical requests: ```tsx // These three components all request the same data function Component1() { useQuery({ queryKey: ['user', userId], queryFn: fetchUser }); } function Component2() { useQuery({ queryKey: ['user', userId], queryFn: fetchUser }); } function Component3() { useQuery({ queryKey: ['user', userId], queryFn: fetchUser }); } // Result: Only ONE network request is made // All three components share the same cached data ``` ## Structural Sharing TanStack Query preserves referential equality when data hasn't changed: ```tsx const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, // Structural sharing is enabled by default structuralSharing: true, }); // If server returns identical data, React won't re-render // because data reference hasn't changed ``` Disable for very large datasets: ```tsx useQuery({ queryKey: ['large-dataset'], queryFn: fetchLargeDataset, structuralSharing: false, // Skip structural sharing for performance }); ``` **Note:** v5.85.7 improved the internal `replaceEqualDeep` function to use `Set` for O(1) lookups instead of `Array.indexOf`, improving performance for queries returning large arrays. ## Prefetching Load data before it's needed: ### Hover Prefetch ```tsx const queryClient = useQueryClient(); function TodoListItem({ todo }) { const prefetchTodo = () => { queryClient.prefetchQuery({ queryKey: ['todo', todo.id], queryFn: () => fetchTodo(todo.id), staleTime: 1000 * 60 * 5, }); }; return ( <Link to={`/todo/${todo.id}`} onMouseEnter={prefetchTodo} onFocus={prefetchTodo} > {todo.title} </Link> ); } ``` ### Route-Based Prefetch ```tsx // In router loader or component async function todoLoader({ params }) { await queryClient.prefetchQuery({ queryKey: ['todo', params.id], queryFn: () => fetchTodo(params.id), }); } // Or in a parent component function TodoLayout() { const navigate = useNavigate(); useEffect(() => { // Prefetch common routes queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos, }); }, []); return <Outlet />; } ``` ### Predictive Prefetch ```tsx function PaginatedList({ page }) { const { data } = useQuery({ queryKey: ['items', page], queryFn: () => fetchItems(page), }); // Prefetch next page useEffect(() => { if (page < totalPages) { queryClient.prefetchQuery({ queryKey: ['items', page + 1], queryFn: () => fetchItems(page + 1), }); } }, [page]); return <div>{/* render items */}</div>; } ``` ### Prefetch During Render (`usePrefetchQuery`) The imperative `queryClient.prefetchQuery` above runs in an effect or event handler. `usePrefetchQuery` / `usePrefetchInfiniteQuery` prefetch during the render phase instead - use them to warm a child's data in the parent that renders it (typically alongside `useSuspenseQuery` in the child), so the request starts before the child mounts. They return nothing and never suspend the caller. ```tsx import { usePrefetchQuery, useSuspenseQuery } from '@tanstack/react-query'; function Parent() { // Kicks off the fetch during render; child reads it from cache usePrefetchQuery(commentsQueryOptions); return ( <Suspense fallback={<Spinner />}> <Comments /> </Suspense> ); } function Comments() { const { data } = useSuspenseQuery(commentsQueryOptions); return <CommentList comments={data} />; } ``` ## Data Transformation ### Use select for Transformation ```tsx // ❌ Transform in component - runs on every render function TodoList() { const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, }); const completedTodos = data?.filter(todo => todo.completed); return <div>{completedTodos?.map(/* ... */)}</div>; } // ✅ Transform with select - memoized automatically function TodoList() { const { data: completedTodos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select: (todos) => todos.filter(todo => todo.completed), }); return <div>{completedTodos?.map(/* ... */)}</div>; } ``` ### Select is Memoized ```tsx // select function only runs when data changes useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select: (todos) => { console.log('Transforming...'); // Only logs when data changes return todos.map(todo => ({ ...todo, displayName: `${todo.id}: ${todo.title}`, })); }, }); ``` ## Pagination Optimization ### Offset Pagination ```tsx function PaginatedTodos() { const [page, setPage] = useState(1); const { data } = useQuery({ queryKey: ['todos', page], queryFn: () => fetchTodos(page), staleTime: 1000 * 60 * 5, // Keep pages fresh placeholderData: (previousData) => previousData, // Keep previous data while loading }); return ( <div> {data?.items.map(todo => <TodoItem key={todo.id} todo={todo} />)} <button onClick={() => setPage(p => p - 1)} disabled={page === 1}> Previous </button> <button onClick={() => setPage(p => p + 1)}> Next </button> </div> ); } ``` ### Infinite Queries with Windowing For very long lists, use virtual scrolling: ```tsx import { useInfiniteQuery } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; function VirtualizedInfiniteList() { const { data, fetchNextPage, hasNextPage, } = useInfiniteQuery({ queryKey: ['items'], queryFn: ({ pageParam = 0 }) => fetchItems(pageParam), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, }); const allItems = data?.pages.flatMap(page => page.items) ?? []; const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: hasNextPage ? allItems.length + 1 : allItems.length, getScrollElement: () => parentRef.current, estimateSize: () => 100, }); useEffect(() => { const lastItem = virtualizer.getVirtualItems()[virtualizer.getVirtualItems().length - 1]; if (!lastItem) return; if (lastItem.index >= allItems.length - 1 && hasNextPage) { fetchNextPage(); } }, [hasNextPage, fetchNextPage, allItems.length, virtualizer.getVirtualItems()]); return ( <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}> <div style={{ height: `${virtualizer.getTotalSize()}px` }}> {virtualizer.getVirtualItems().map((virtualRow) => ( <div key={virtualRow.index} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualRow.size}px`, transform: `translateY(${virtualRow.start}px)`, }} > {allItems[virtualRow.index] ? ( <Item item={allItems[virtualRow.index]} /> ) : ( 'Loading...' )} </div> ))} </div> </div> ); } ``` ## Parallel Queries Optimization ### Using useQueries ```tsx // ❌ Sequential queries async function fetchAllData() { const users = await fetchUsers(); const posts = await fetchPosts(); const comments = await fetchComments(); return { users, posts, comments }; } // ✅ Parallel queries function Dashboard() { const results = useQueries({ queries: [ { queryKey: ['users'], queryFn: fetchUsers }, { queryKey: ['posts'], queryFn: fetchPosts }, { queryKey: ['comments'], queryFn: fetchComments }, ], }); const [users, posts, comments] = results; const isLoading = results.some(r => r.isLoading); return <div>{/* render */}</div>; } ``` ### Dynamic Parallel Queries ```tsx function UserPosts({ userIds }) { const queries = useQueries({ queries: userIds.map(id => ({ queryKey: ['user-posts', id], queryFn: () => fetchUserPosts(id), staleTime: 1000 * 60 * 5, })), }); return <div>{/* render */}</div>; } ``` ## Memory Management ### Limit Cache Size ```tsx const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 1000 * 60 * 5, // 5 minutes }, }, }); // Manually clear old queries queryClient.clear(); // Clear all cache // Remove specific queries queryClient.removeQueries({ queryKey: ['old-data'] }); ``` ### Remove Queries on Unmount ```tsx function ExpensiveComponent() { const { data } = useQuery({ queryKey: ['expensive-data'], queryFn: fetchExpensiveData, gcTime: 0, // Remove immediately when component unmounts }); return <div>{/* render */}</div>; } ``` ## Network Optimization ### Batch Requests If your API supports batching: ```tsx // Collect query keys and batch them const batchedQueryFn = async (keys) => { const ids = keys.map(key => key[1]); const results = await fetch(`/api/items?ids=${ids.join(',')}`); return results.json(); }; // Use in queries useQuery({ queryKey: ['item', itemId], queryFn: () => batchedQueryFn([['item', itemId]]), }); ``` ### Request Cancellation ```tsx useQuery({ queryKey: ['search', searchTerm], queryFn: async ({ signal }) => { // AbortSignal automatically provided const res = await fetch(`/api/search?q=${searchTerm}`, { signal }); return res.json(); }, }); // When searchTerm changes, previous request is cancelled ``` ### Retry Configuration ```tsx // ❌ Retry immediately 3 times useQuery({ queryKey: ['todos'], queryFn: fetchTodos, retry: 3, }); // ✅ Exponential backoff useQuery({ queryKey: ['todos'], queryFn: fetchTodos, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }); ``` ### Network Mode `networkMode` controls how queries and mutations behave without a network connection: ```tsx useQuery({ queryKey: ['todos'], queryFn: fetchTodos, networkMode: 'online' }); ``` - `'online'` (default) - only fetches when online; offline queries are paused (status stays as-is, `fetchStatus: 'paused'`). - `'always'` - always runs the `queryFn`, ignoring online status (use for cache-only or in-memory sources). - `'offlineFirst'` - runs once, then pauses retries when offline (use when a service worker / HTTP cache may serve the first request). ## Dependent Queries Avoid waterfalls by enabling queries in parallel when possible: ```tsx // ❌ Waterfall - queries run sequentially function UserDashboard({ userId }) { const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), }); const { data: posts } = useQuery({ queryKey: ['posts', user?.id], queryFn: () => fetchPosts(user.id), enabled: !!user?.id, // Waits for user }); const { data: comments } = useQuery({ queryKey: ['comments', user?.id], queryFn: () => fetchComments(user.id), enabled: !!user?.id, // Also waits for user }); } // ✅ Optimized - posts and comments fetch in parallel after user loads ``` ## Code Splitting ### Lazy Load Query Client ```tsx import { lazy, Suspense } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const ReactQueryDevtools = lazy(() => import('@tanstack/react-query-devtools').then(mod => ({ default: mod.ReactQueryDevtools, })) ); function App() { return ( <QueryClientProvider client={queryClient}> <YourApp /> <Suspense fallback={null}> <ReactQueryDevtools /> </Suspense> </QueryClientProvider> ); } ``` ## Monitoring Performance ### Using DevTools ```tsx import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; function App() { return ( <QueryClientProvider client={queryClient}> <YourApp /> <ReactQueryDevtools initialIsOpen={false} position="bottom-right" /> </QueryClientProvider> ); } ``` ### Query/Mutation Cache Callbacks Use cache-level callbacks for global logging (the `logger` property was removed in v5): ```tsx import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query'; const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { console.error(`[Query Error] ${query.queryKey}:`, error); }, onSuccess: (data, query) => { console.log(`[Query Success] ${query.queryKey}`); }, }), mutationCache: new MutationCache({ onError: (error) => { console.error('[Mutation Error]:', error); }, }), }); ``` DevTools now shows a visual indicator for queries with `staleTime: Infinity` ("static" queries), making it easier to distinguish intentionally never-refetched queries (v5.80.0+). ### timeoutManager (Testing) Replace `setTimeout`/`setInterval` with custom implementations for testing or non-browser environments (v5.87.0+): ```tsx import { timeoutManager } from '@tanstack/query-core'; // In test setup - use fake timers timeoutManager.setTimeout = vi.fn(setTimeout); timeoutManager.clearTimeout = vi.fn(clearTimeout); ``` ### defaultScheduler The internal notification batching scheduler is now a public export (v5.70.0+), enabling custom scheduling strategies: ```tsx import { defaultScheduler } from '@tanstack/query-core'; // Use for advanced batching customization ``` ### Performance Metrics ```tsx useQuery({ queryKey: ['todos'], queryFn: async () => { const start = performance.now(); const data = await fetchTodos(); const duration = performance.now() - start; console.log(`Query took ${duration}ms`); return data; }, }); ``` ## Best Practices 1. **Set Appropriate staleTime** ```tsx // Static data staleTime: Infinity // Frequently changing staleTime: 0 // Moderate staleTime: 1000 * 60 * 5 // 5 minutes ``` 2. **Use Prefetching** - Hover intent - Route prediction - Next page in pagination 3. **Optimize with select** ```tsx select: (data) => data.filter(/* ... */) ``` 4. **Disable Unnecessary Refetching** ```tsx refetchOnWindowFocus: false refetchOnReconnect: false ``` 5. **Use Structural Sharing** - Enabled by default - Disable for very large datasets 6. **Implement Virtual Scrolling** - For long lists - For infinite queries 7. **Monitor with DevTools** - Watch for unnecessary refetches - Check cache effectiveness - Identify slow queries 8. **Batch Parallel Queries** - Use useQueries - Reduce waterfalls 9. **Clean Up Unused Cache** ```tsx gcTime: 1000 * 60 * 5 ``` 10. **Use Request Cancellation** - Automatically handled by TanStack Query - Ensures old requests don't override new ones -
query-typescript.md 19.1 KB
# TypeScript Guide TanStack Query v5 is written in TypeScript and provides excellent type safety and inference out of the box. ## Basic Type Inference TanStack Query infers types from your query function return values: ```tsx import { useQuery } from '@tanstack/react-query'; interface Todo { id: number; title: string; done: boolean; } function useTodos() { return useQuery({ queryKey: ['todos'], queryFn: async (): Promise<Todo[]> => { const res = await fetch('/api/todos'); return res.json(); }, }); } function TodoList() { const { data } = useTodos(); // data is automatically typed as Todo[] | undefined return ( <ul> {data?.map((todo) => ( // todo is typed as Todo <li key={todo.id}>{todo.title}</li> ))} </ul> ); } ``` ## Typing Query Functions ### Inline Query Functions ```tsx const { data } = useQuery({ queryKey: ['todo', todoId], queryFn: async (): Promise<Todo> => { const res = await fetch(`/api/todos/${todoId}`); if (!res.ok) throw new Error('Failed to fetch'); return res.json(); }, }); // data is typed as Todo | undefined ``` ### Extracted Query Functions ```tsx async function fetchTodo(id: number): Promise<Todo> { const res = await fetch(`/api/todos/${id}`); if (!res.ok) throw new Error('Failed to fetch'); return res.json(); } const { data } = useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId), }); // data is automatically typed as Todo | undefined ``` ### Query Functions with QueryKey Access the query key in your function with proper typing: ```tsx import { QueryFunction } from '@tanstack/react-query'; const fetchTodo: QueryFunction<Todo, ['todo', number]> = async ({ queryKey }) => { const [_, id] = queryKey; // id is typed as number const res = await fetch(`/api/todos/${id}`); return res.json(); }; const { data } = useQuery({ queryKey: ['todo', todoId], queryFn: fetchTodo, }); ``` ## Typing Mutations ### Basic Mutation Types ```tsx import { useMutation } from '@tanstack/react-query'; interface CreateTodoInput { title: string; done?: boolean; } interface CreateTodoResponse { id: number; title: string; done: boolean; createdAt: string; } const mutation = useMutation({ mutationFn: async (input: CreateTodoInput): Promise<CreateTodoResponse> => { const res = await fetch('/api/todos', { method: 'POST', body: JSON.stringify(input), }); return res.json(); }, }); // TypeScript knows: // - mutation.mutate expects CreateTodoInput // - mutation.data is CreateTodoResponse | undefined mutation.mutate({ title: 'New Todo' }); ``` ### Generic Mutation Type ```tsx import { UseMutationResult } from '@tanstack/react-query'; type CreateTodoMutation = UseMutationResult< CreateTodoResponse, // TData - successful response Error, // TError - error type CreateTodoInput, // TVariables - mutation input unknown // TContext - context from onMutate >; function useCreateTodo(): CreateTodoMutation { return useMutation({ mutationFn: async (input: CreateTodoInput): Promise<CreateTodoResponse> => { const res = await fetch('/api/todos', { method: 'POST', body: JSON.stringify(input), }); return res.json(); }, }); } ``` ## Error Typing ### Typed Errors ```tsx interface ApiError { message: string; code: string; details?: Record<string, string>; } const { data, error } = useQuery<Todo[], ApiError>({ queryKey: ['todos'], queryFn: async () => { const res = await fetch('/api/todos'); if (!res.ok) { const errorData: ApiError = await res.json(); throw errorData; } return res.json(); }, }); if (error) { // error is typed as ApiError console.log(error.message); console.log(error.code); } ``` ### Error Type Narrowing ```tsx function TodoList() { const { data, error, isError } = useQuery<Todo[], ApiError>({ queryKey: ['todos'], queryFn: fetchTodos, }); if (isError) { // TypeScript knows error is ApiError here return <div>Error: {error.message} (Code: {error.code})</div>; } // TypeScript knows data is Todo[] | undefined here return <div>{data?.map(todo => <TodoItem key={todo.id} todo={todo} />)}</div>; } ``` ## Generic Type Parameters ### useQuery Generics ```tsx useQuery< TData, // Type of data returned (inferred from queryFn) TError, // Type of errors (default: Error) TQueryKey // Type of query key (inferred) >({ /* ... */ }); ``` Example with all generics: ```tsx interface User { id: number; name: string; } interface UserError { message: string; statusCode: number; } const { data, error } = useQuery<User, UserError, ['user', number]>({ queryKey: ['user', userId], queryFn: async ({ queryKey }): Promise<User> => { const [_, id] = queryKey; const res = await fetch(`/api/users/${id}`); if (!res.ok) { throw { message: 'Failed to fetch', statusCode: res.status }; } return res.json(); }, }); ``` ### useMutation Generics ```tsx useMutation< TData, // Type of successful response TError, // Type of error TVariables, // Type of mutation variables TContext // Type of context from onMutate >({ /* ... */ }); ``` Example: ```tsx interface UpdateTodoInput { id: number; title?: string; done?: boolean; } interface UpdateTodoResponse { id: number; title: string; done: boolean; updatedAt: string; } interface UpdateTodoContext { previousTodos: Todo[]; } const mutation = useMutation< UpdateTodoResponse, ApiError, UpdateTodoInput, UpdateTodoContext >({ mutationFn: async (input) => { const res = await fetch(`/api/todos/${input.id}`, { method: 'PATCH', body: JSON.stringify(input), }); return res.json(); }, onMutate: async (variables) => { const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? []; // Must return UpdateTodoContext return { previousTodos }; }, onError: (error, variables, context) => { // error: ApiError // variables: UpdateTodoInput // context: UpdateTodoContext | undefined if (context) { queryClient.setQueryData(['todos'], context.previousTodos); } }, }); ``` ## Typing QueryClient Methods ### setQueryData ```tsx const queryClient = useQueryClient(); // Type-safe setQueryData queryClient.setQueryData<Todo[]>(['todos'], (old) => { // old is typed as Todo[] | undefined return old ? [...old, newTodo] : [newTodo]; }); ``` ### getQueryData ```tsx const todos = queryClient.getQueryData<Todo[]>(['todos']); // todos is typed as Todo[] | undefined if (todos) { // TypeScript knows todos is Todo[] here console.log(todos.length); } // v5.80.0+: When using queryOptions(), getQueryData infers types automatically import { queryOptions } from '@tanstack/react-query'; const todosOptions = queryOptions({ queryKey: ['todos'], queryFn: fetchTodos, // returns Todo[] }); const cached = queryClient.getQueryData(todosOptions.queryKey); // cached is typed as Todo[] | undefined - no manual generic needed ``` ### invalidateQueries ```tsx // Type-safe query key queryClient.invalidateQueries({ queryKey: ['todos'] }); queryClient.invalidateQueries({ queryKey: ['todo', todoId] }); ``` ## Typing Infinite Queries ### Basic Infinite Query ```tsx interface PostsPage { posts: Post[]; nextCursor: number | null; } const { data } = useInfiniteQuery<PostsPage>({ queryKey: ['posts'], queryFn: async ({ pageParam = 0 }): Promise<PostsPage> => { const res = await fetch(`/api/posts?cursor=${pageParam}`); return res.json(); }, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, }); // data.pages is typed as PostsPage[] data?.pages.forEach((page) => { // page is typed as PostsPage page.posts.forEach((post) => { // post is typed as Post console.log(post.title); }); }); ``` ### Infinite Query with Generics ```tsx useInfiniteQuery< TData, // Type of page data TError, // Type of error TQueryData, // Type of transformed data (from select) TQueryKey, // Type of query key TPageParam // Type of page parameter >({ /* ... */ }); ``` Example: ```tsx const { data } = useInfiniteQuery< PostsPage, ApiError, PostsPage, ['posts', string], number >({ queryKey: ['posts', filter], queryFn: async ({ pageParam }): Promise<PostsPage> => { const res = await fetch(`/api/posts?cursor=${pageParam}&filter=${filter}`); return res.json(); }, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, }); ``` ## Typing Select Transformations ### Transform Query Data ```tsx interface TodoApiResponse { id: number; title: string; completed: boolean; } interface Todo { id: number; title: string; done: boolean; // renamed from completed } const { data } = useQuery({ queryKey: ['todos'], queryFn: async (): Promise<TodoApiResponse[]> => { const res = await fetch('/api/todos'); return res.json(); }, select: (data): Todo[] => { // data is typed as TodoApiResponse[] return data.map(todo => ({ id: todo.id, title: todo.title, done: todo.completed, // transform })); }, }); // data is now typed as Todo[] | undefined ``` ### Partial Selection ```tsx interface User { id: number; name: string; email: string; role: string; metadata: Record<string, unknown>; } const { data } = useQuery({ queryKey: ['user', userId], queryFn: async (): Promise<User> => { const res = await fetch(`/api/users/${userId}`); return res.json(); }, select: (user) => ({ id: user.id, name: user.name, }), }); // data is typed as { id: number; name: string } | undefined ``` ## isPlaceholderData Type Narrowing Since v5.65.0, `isPlaceholderData` properly narrows the data type: ```tsx const { data, isPlaceholderData } = useQuery({ queryKey: ['todos', page], queryFn: () => fetchTodos(page), placeholderData: (previousData) => previousData, }); if (isPlaceholderData) { // data is narrowed to the placeholder type // TypeScript knows this is stale/previous data } if (!isPlaceholderData && data) { // data is narrowed to the fresh query result type // Safe to use without additional type guards } ``` ## Custom Hooks with Types ### Reusable Typed Hooks ```tsx interface UseTodoOptions { refetchInterval?: number; enabled?: boolean; } function useTodo(id: number, options?: UseTodoOptions) { return useQuery<Todo, ApiError>({ queryKey: ['todo', id], queryFn: async (): Promise<Todo> => { const res = await fetch(`/api/todos/${id}`); if (!res.ok) { const error: ApiError = await res.json(); throw error; } return res.json(); }, refetchInterval: options?.refetchInterval, enabled: options?.enabled, }); } // Usage with full type safety const { data, error, isLoading } = useTodo(1, { refetchInterval: 5000 }); ``` ### Generic Custom Hooks ```tsx function useResource<T>(resourceType: string, id: number) { return useQuery<T, ApiError>({ queryKey: [resourceType, id], queryFn: async (): Promise<T> => { const res = await fetch(`/api/${resourceType}/${id}`); if (!res.ok) throw await res.json(); return res.json(); }, }); } // Usage const { data: user } = useResource<User>('users', 1); // data is typed as User | undefined const { data: post } = useResource<Post>('posts', 123); // data is typed as Post | undefined ``` ## Typing Query Keys ### Const Query Keys ```tsx const todoKeys = { all: ['todos'] as const, lists: () => [...todoKeys.all, 'list'] as const, list: (filters: string) => [...todoKeys.lists(), filters] as const, details: () => [...todoKeys.all, 'detail'] as const, detail: (id: number) => [...todoKeys.details(), id] as const, }; // Type-safe query keys useQuery({ queryKey: todoKeys.detail(todoId), queryFn: () => fetchTodo(todoId), }); // Type-safe invalidation queryClient.invalidateQueries({ queryKey: todoKeys.all }); queryClient.invalidateQueries({ queryKey: todoKeys.detail(todoId) }); ``` **QueryFilters with partial keys and readonly (v5.90.8+):** `QueryFilters` now supports partial query keys and preserves `readonly` from `as const` assertions, so you can filter by key prefix without losing type safety: ```tsx // as const works seamlessly with QueryFilters queryClient.invalidateQueries({ queryKey: todoKeys.all }); // readonly ['todos'] ``` ### QueryKey Type Helper ```tsx import { QueryKey } from '@tanstack/react-query'; type TodoQueryKey = ['todos'] | ['todos', 'list', string] | ['todos', 'detail', number]; function useTodoQuery(key: TodoQueryKey) { return useQuery({ queryKey: key, queryFn: async () => { // Implementation based on key }, }); } ``` ## Typing Mutation Context ### Context with Optimistic Updates ```tsx interface UpdateTodoVariables { id: number; updates: Partial<Todo>; } interface UpdateTodoContext { previousTodos: Todo[]; previousTodo: Todo; rollback: () => void; } const mutation = useMutation< Todo, ApiError, UpdateTodoVariables, UpdateTodoContext >({ mutationFn: async ({ id, updates }) => { const res = await fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify(updates), }); return res.json(); }, onMutate: async ({ id, updates }): Promise<UpdateTodoContext> => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? []; const previousTodo = queryClient.getQueryData<Todo>(['todo', id])!; // Optimistic update queryClient.setQueryData<Todo[]>(['todos'], (old) => old?.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo)) ); const rollback = () => { queryClient.setQueryData(['todos'], previousTodos); queryClient.setQueryData(['todo', id], previousTodo); }; return { previousTodos, previousTodo, rollback }; }, onError: (_error, _variables, context) => { // context is typed as UpdateTodoContext | undefined context?.rollback(); }, }); ``` ## Type-Safe Query Options ### queryOptions() Helper (Recommended) The `queryOptions()` helper creates reusable, fully type-safe query configurations: ```tsx import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; function todoQueryOptions(id: number) { return queryOptions({ queryKey: ['todo', id] as const, queryFn: () => fetchTodo(id), staleTime: 1000 * 60 * 5, }); } // Full type inference - no manual generics needed const { data } = useQuery(todoQueryOptions(todoId)); // data is typed as Todo | undefined // Works with prefetching queryClient.prefetchQuery(todoQueryOptions(todoId)); // Works with getQueryData const cached = queryClient.getQueryData(todoQueryOptions(todoId).queryKey); // cached is typed as Todo | undefined // Works with invalidation queryClient.invalidateQueries({ queryKey: todoQueryOptions(todoId).queryKey }); ``` ### Manual Query Options (Alternative) ```tsx import { UseQueryOptions } from '@tanstack/react-query'; type TodoQueryOptions = UseQueryOptions<Todo, ApiError, Todo, ['todo', number]>; const defaultTodoOptions: Partial<TodoQueryOptions> = { staleTime: 1000 * 60 * 5, retry: 3, }; function useTodo(id: number, options?: Partial<TodoQueryOptions>) { return useQuery<Todo, ApiError>({ queryKey: ['todo', id], queryFn: () => fetchTodo(id), ...defaultTodoOptions, ...options, }); } ``` ## Strict Type Safety ### Enable Strict Mode ```tsx // tsconfig.json { "compilerOptions": { "strict": true, "strictNullChecks": true, "noImplicitAny": true } } ``` ### Avoid Type Assertions ```tsx // ❌ Bad - using type assertion const data = queryClient.getQueryData(['todos']) as Todo[]; // ✅ Good - proper type checking const data = queryClient.getQueryData<Todo[]>(['todos']); if (data) { // TypeScript knows data is Todo[] here console.log(data.length); } ``` ### Non-Null Assertions Use sparingly and only when you're certain: ```tsx // ❌ Risky const todos = queryClient.getQueryData<Todo[]>(['todos'])!; // ✅ Better const todos = queryClient.getQueryData<Todo[]>(['todos']); if (!todos) { throw new Error('Todos not found in cache'); } // Now safe to use todos ``` ## Typing DevTools ```tsx import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; function App() { return ( <QueryClientProvider client={queryClient}> <YourApp /> {/* TypeScript will check props */} <ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" /> </QueryClientProvider> ); } ``` ## Common Type Issues ### Issue: Cannot infer type from async function ```tsx // ❌ Problem const { data } = useQuery({ queryKey: ['todos'], queryFn: async () => { const res = await fetch('/api/todos'); return res.json(); // Returns any }, }); // ✅ Solution 1: Add return type to queryFn const { data } = useQuery({ queryKey: ['todos'], queryFn: async (): Promise<Todo[]> => { const res = await fetch('/api/todos'); return res.json(); }, }); // ✅ Solution 2: Use generic parameter const { data } = useQuery<Todo[]>({ queryKey: ['todos'], queryFn: async () => { const res = await fetch('/api/todos'); return res.json(); }, }); ``` ### Issue: Context type mismatch ```tsx // ❌ Problem - context type doesn't match const mutation = useMutation({ mutationFn: updateTodo, onMutate: () => { return { previous: [] }; // Returns wrong type }, onError: (err, vars, context) => { context.previousTodos; // Error: previousTodos doesn't exist }, }); // ✅ Solution - Define context interface interface MutationContext { previousTodos: Todo[]; } const mutation = useMutation<Todo, Error, UpdateInput, MutationContext>({ mutationFn: updateTodo, onMutate: async (): Promise<MutationContext> => { const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? []; return { previousTodos }; }, onError: (err, vars, context) => { if (context) { context.previousTodos; // ✅ Properly typed } }, }); ``` ## Best Practices 1. **Always type your query functions** ```tsx queryFn: async (): Promise<Todo[]> => { /* ... */ } ``` 2. **Use type inference when possible** ```tsx // Let TanStack Query infer types from queryFn return type const { data } = useQuery({ queryKey: ['todos'], queryFn: async (): Promise<Todo[]> => fetchTodos(), }); // data is automatically Todo[] | undefined ``` 3. **Define error types** ```tsx const { error } = useQuery<Todo[], ApiError>({ /* ... */ }); ``` 4. **Use const assertions for query keys** ```tsx const todoKeys = { all: ['todos'] as const, detail: (id: number) => ['todos', id] as const, }; ``` 5. **Create reusable typed hooks** ```tsx function useTodo(id: number) { return useQuery<Todo, ApiError>({ /* ... */ }); } ``` 6. **Type mutation context for optimistic updates** ```tsx useMutation<TData, TError, TVariables, TContext>({ /* ... */ }) ``` 7. **Use strict TypeScript settings** ```json { "strict": true, "strictNullChecks": true } ``` 8. **Avoid type assertions** - Use type parameters instead - Check for undefined/null before using data -
router-guide.md 14.8 KB
# TanStack Router v1 A fully type-safe router for React with first-class search param APIs, built-in data loading with SWR caching, file-based route generation, and 100% inferred TypeScript support. ## When to Use This Skill - Setting up file-based or code-based routing in a React application - Building type-safe navigation with Link, useNavigate, or router.navigate - Validating and managing URL search params as typed state - Loading data in route loaders with SWR caching - Code splitting routes for optimal bundle size - Handling not-found errors and error boundaries per route - Implementing route context for dependency injection - Configuring preloading strategies (intent, viewport, render) - Integrating TanStack Query with route loaders - Adding navigation blocking for unsaved changes - Building SSR applications (for full SSR, see `start-guide.md`) ## Quick Start Workflow ### 1. Install and Configure (Vite) ```bash npm install @tanstack/react-router @tanstack/router-plugin ``` ```ts // vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { tanstackRouter } from '@tanstack/router-plugin/vite' export default defineConfig({ plugins: [ tanstackRouter({ autoCodeSplitting: true }), react(), ], }) ``` ### 2. Create Routes ```tsx // src/routes/__root.tsx import { createRootRoute, Link, Outlet } from '@tanstack/react-router' export const Route = createRootRoute({ component: () => ( <> <nav> <Link to="/">Home</Link> <Link to="/about">About</Link> </nav> <Outlet /> </> ), }) ``` ```tsx // src/routes/index.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/')({ component: () => <div>Welcome Home</div>, }) ``` ### 3. Create and Register the Router ```tsx // src/router.ts import { createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' export const router = createRouter({ routeTree }) declare module '@tanstack/react-router' { interface Register { router: typeof router } } ``` ```tsx // src/main.tsx import { RouterProvider } from '@tanstack/react-router' import { router } from './router' function App() { return <RouterProvider router={router} /> } ``` ## File-Based Routing Files in `src/routes/` are automatically converted to route configuration by the Vite plugin or CLI. ### Naming Conventions | Convention | Purpose | Example | |---|---|---| | `__root.tsx` | Root route (always rendered) | `src/routes/__root.tsx` | | `index.tsx` | Index route for parent path | `src/routes/index.tsx` matches `/` | | `.` separator | Nested route (flat files) | `posts.tsx` = `/posts` | | `$param` | Dynamic path parameter | `posts.$postId.tsx` = `/posts/:postId` | | `_` prefix | Pathless layout route | `_layout.tsx` wraps children, no URL segment | | `_` suffix | Non-nested route | `posts_.edit.tsx` breaks out of `posts` nesting | | `-` prefix | Excluded from routing | `-components/Button.tsx` for colocated files | | `(folder)` | Route group (no URL segment) | `(auth)/login.tsx` = `/login` | | `.lazy.tsx` | Lazy-loaded component | `posts.lazy.tsx` for code-split components | | `.route.tsx` | Directory route file | `posts/route.tsx` instead of `posts.tsx` | Flat (`posts.$postId.tsx`) and directory (`posts/$postId.tsx`) structures can be mixed freely. ## Type-Safe Navigation All navigation APIs share `to`, `from`, `params`, `search`, and `hash` options. ### Link Component ```tsx import { Link } from '@tanstack/react-router' <Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link> // Relative navigation <Link from="/posts/$postId" to="..">Back to Posts</Link> // Active styling <Link to="/posts" activeProps={{ className: 'font-bold' }} activeOptions={{ exact: true }}> Posts </Link> ``` ### useNavigate Hook For imperative navigation from side effects: ```tsx const navigate = useNavigate({ from: '/posts' }) const handleSubmit = async (data: PostInput) => { const post = await createPost(data) navigate({ to: '/posts/$postId', params: { postId: post.id } }) } ``` ### linkOptions Helper Reusable type-safe link configuration: ```tsx import { linkOptions } from '@tanstack/react-router' const postLink = linkOptions({ to: '/posts/$postId', params: { postId: '123' } }) <Link {...postLink}>View Post</Link> ``` **`to` rejects arbitrary strings.** Because routes are typed, `Link`/`useNavigate` `to` will not accept a plain `string` variable - passing a dynamic URL is a type error. For a known internal route use `linkOptions()`; for genuinely dynamic or external URLs, render a plain `<a href={url}>` (the external-link escape hatch) rather than casting. Always provide `from` on Link and hooks to narrow types and improve TS performance. Without `from`, TypeScript must check against all routes. ### Custom Link Components (`createLink` / `useLinkProps`) To give a third-party or styled component (a UI-library `<Button>`, a framer-motion anchor) the same type-safe `to`/`params`/`search`/`activeProps` API as `Link`, wrap it with `createLink`. For full manual control, `useLinkProps` returns the resolved anchor props (href, active state, click handler) to spread onto your own element. ```tsx import { createLink } from '@tanstack/react-router' // Component must forward a ref and accept anchor props const BasicLink = React.forwardRef<HTMLAnchorElement, React.AnchorHTMLAttributes<HTMLAnchorElement>>( (props, ref) => <a ref={ref} {...props} className="my-link" />, ) export const AppLink = createLink(BasicLink) // now typed: <AppLink to="/posts/$postId" params={{ postId: '1' }} /> ``` If the wrapped component defines its own props, intersect them so both sets stay typed. Prefer `createLink` over hand-rolling an `<a>` when you need active styling or preloading. ## Search Params Search params are first-class - validated, typed, JSON-serialized, and subscribable with fine-grained selectors. ### Validation with Zod ```tsx import { zodValidator, fallback } from '@tanstack/zod-adapter' import { z } from 'zod' const productSearchSchema = z.object({ page: fallback(z.number(), 1).default(1), filter: fallback(z.string(), '').default(''), sort: fallback(z.enum(['newest', 'oldest', 'price']), 'newest').default('newest'), }) export const Route = createFileRoute('/shop/products')({ validateSearch: zodValidator(productSearchSchema), }) ``` Use `fallback(...).default(...)` from the Zod adapter to retain types. Plain `.catch()` causes type loss. Valibot and ArkType work without adapters via Standard Schema support. ### Reading and Writing ```tsx // Reading (type-safe) const { page, sort } = Route.useSearch() // From code-split component (avoids circular imports) const search = getRouteApi('/shop/products').useSearch() // Loose typing for shared components const search = useSearch({ strict: false }) // Writing via Link <Link from={Route.fullPath} search={(prev) => ({ ...prev, page: prev.page + 1 })}>Next</Link> // Writing via useNavigate const navigate = useNavigate({ from: Route.fullPath }) navigate({ search: (prev) => ({ ...prev, page: 2 }) }) ``` ### Search Middlewares ```tsx import { retainSearchParams, stripSearchParams } from '@tanstack/react-router' export const Route = createFileRoute('/shop/products')({ validateSearch: zodValidator(productSearchSchema), search: { middlewares: [ retainSearchParams(['globalFilter']), stripSearchParams({ sort: 'newest' }), ], }, }) ``` ## Data Loading Route loaders run in parallel before rendering with built-in SWR caching. ### Basic Loader ```tsx export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), component: () => { const posts = Route.useLoaderData() return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul> }, }) ``` ### loaderDeps - Search Params as Cache Keys ```tsx export const Route = createFileRoute('/posts')({ validateSearch: z.object({ page: z.number().catch(1), limit: z.number().catch(10) }), loaderDeps: ({ search: { page, limit } }) => ({ page, limit }), loader: ({ deps: { page, limit } }) => fetchPosts({ page, limit }), }) ``` Only include deps you actually use - returning the entire `search` object causes unnecessary cache invalidation. ### Caching and Staleness - `staleTime` - How long data is fresh (default: 0 for navigation, 30s for preload) - `gcTime` - How long unused data stays in cache (default: 30 minutes) - `shouldReload` - Custom reload logic beyond staleTime ### beforeLoad - Guards and Context Runs serially before loaders. Use for auth redirects or injecting route-specific context: ```tsx export const Route = createFileRoute('/dashboard')({ beforeLoad: ({ context }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: '/login', search: { redirect: '/dashboard' } }) } return { user: context.auth.user } }, loader: ({ context: { user } }) => fetchDashboard(user.id), }) ``` ### Pending Components ```tsx export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), pendingComponent: () => <Spinner />, pendingMs: 1000, // Wait before showing (default: 1000) pendingMinMs: 500, // Minimum display time to avoid flash (default: 500) }) ``` ## Route Context Hierarchical dependency injection via `createRootRouteWithContext`. Context merges down the tree and is fully type-safe. ```tsx // src/routes/__root.tsx import { createRootRouteWithContext } from '@tanstack/react-router' interface RouterContext { queryClient: QueryClient } export const Route = createRootRouteWithContext<RouterContext>()({ component: RootComponent, }) // src/router.ts - context is required by the type const router = createRouter({ routeTree, context: { queryClient } }) // Child routes access context in loaders export const Route = createFileRoute('/posts')({ loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(postsQueryOptions()), }) ``` Pass React hooks/state at runtime via `RouterProvider`: ```tsx function App() { const auth = useAuth() return <RouterProvider router={router} context={{ auth }} /> } ``` ## Error Handling ### errorComponent ```tsx export const Route = createFileRoute('/posts/$postId')({ loader: ({ params }) => fetchPost(params.postId), errorComponent: ({ error }) => { const router = useRouter() return ( <div> <p>{error.message}</p> <button onClick={() => router.invalidate()}>Retry</button> </div> ) }, }) ``` ### notFoundComponent Two modes via `notFoundMode` on the router (default: `'fuzzy'`): - **fuzzy** - nearest parent route with children and a notFoundComponent handles it - **root** - root route always handles it ```tsx import { notFound } from '@tanstack/react-router' export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => { const post = await getPost(params.postId) if (!post) throw notFound() return { post } }, notFoundComponent: () => <p>Post not found</p>, }) ``` Set `defaultNotFoundComponent` on the router for app-wide fallback. ## Code Splitting **Automatic (recommended):** Enable `autoCodeSplitting: true` in the Vite plugin. Non-critical config (component, errorComponent, pendingComponent, notFoundComponent) is split into separate chunks automatically. **Manual with .lazy.tsx:** Split into two files - critical config in `posts.tsx` (loader, validateSearch), non-critical in `posts.lazy.tsx` (component via `createLazyFileRoute`). The root route (`__root.tsx`) does not support code splitting since it always renders. ## Preloading ```tsx const router = createRouter({ routeTree, defaultPreload: 'intent', // Preload on hover/touch defaultPreloadDelay: 50, // ms delay (default: 50) }) ``` Strategies: `'intent'` (hover/touch), `'viewport'` (Intersection Observer), `'render'` (on mount). Override per-link with `preload` prop. Manual: `router.preloadRoute({ to, params })`. ## TanStack Query Integration ```tsx const postQueryOptions = (postId: string) => queryOptions({ queryKey: ['post', postId], queryFn: () => fetchPost(postId) }) export const Route = createFileRoute('/posts/$postId')({ loader: async ({ context: { queryClient }, params: { postId } }) => { await queryClient.ensureQueryData(postQueryOptions(postId)) }, component: () => { const { postId } = Route.useParams() const { data } = useSuspenseQuery(postQueryOptions(postId)) return <div>{data.title}</div> }, }) ``` Set `defaultPreloadStaleTime: 0` on the router when using external caching so loaders always fire. ## Advanced Topics See reference files for deep dives: - `search-params.md` - Custom serialization, Standard Schema validation, arrays/objects, sharing across routes - `data-loading.md` - Deferred data loading with Await, external data loading, shouldReload, streaming SSR - `routing-patterns.md` - Virtual file routes, route masking, navigation blocking, authenticated routes, parallel routes - `code-splitting.md` - Automatic splitting options, loader splitting, directory encapsulation, code-based splitting - `router-ssr.md` - SSR setup, streaming, dehydration/hydration, data serialization, TanStack Start integration ## DevTools ```bash npm install @tanstack/react-router-devtools ``` ```tsx // src/routes/__root.tsx import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' export const Route = createRootRoute({ component: () => ( <> <Outlet /> <TanStackRouterDevtools /> </> ), }) ``` Automatically excluded from production. Use `TanStackRouterDevtoolsInProd` if needed in prod. ## Best Practices 1. **Use file-based routing with autoCodeSplitting** - Generates the route tree and optimizes bundles. Fall back to code-based only when you need programmatic control. 2. **Always validate search params** - Use `validateSearch` with Zod (via `zodValidator`) or any Standard Schema library. Use `fallback(...).default(...)` to retain types. 3. **Provide `from` on navigation hooks and components** - Narrows types, improves TS performance, catches route mismatches at runtime. 4. **Extract only needed deps in loaderDeps** - Return only params your loader uses, not the full search object. 5. **Use route context for dependency injection** - Pass QueryClient, auth, or services via `createRootRouteWithContext` instead of importing singletons. 6. **Set preload to 'intent' globally** - Dramatically improves perceived performance with minimal effort. 7. **Use router.invalidate() in error components** - Reloads data and resets the error boundary together. ## Resources - **Official Docs**: https://tanstack.com/router/latest/docs/framework/react/overview - **GitHub**: https://github.com/TanStack/router - **Examples**: https://tanstack.com/router/latest/docs/framework/react/examples - **Query Integration**: https://tanstack.com/router/latest/docs/router/integrations/query - **Discord**: https://discord.gg/tanstack - **Migrate from React Router**: https://tanstack.com/router/latest/docs/framework/react/installation/migrate-from-react-router -
router-ssr.md 17.8 KB
# Server-Side Rendering (SSR) TanStack Router provides low-level SSR primitives for rendering your application on the server and hydrating it on the client. It supports both non-streaming and streaming SSR, automatic data serialization, and document head management. > **For most SSR use cases, [TanStack Start](https://tanstack.com/start/latest) is the recommended approach.** Start provides SSR, streaming, server functions, and deployment with zero configuration. The APIs documented here are lower-level building blocks intended for custom server setups or integration with existing server frameworks. These APIs share internal implementations with TanStack Start and should be considered experimental until Start reaches stable status. Official documentation: https://tanstack.com/router/latest/docs/framework/react/guide/ssr ## SSR Overview Two flavors of SSR are supported: - **Non-Streaming SSR** - The entire page is rendered on the server and sent as a single HTML response, including serialized data needed for client hydration. - **Streaming SSR** - The critical first paint is sent immediately, and remaining content is progressively streamed to the client as it becomes available. Key SSR utilities are split across two import paths: - `@tanstack/react-router/ssr/server` - `createRequestHandler`, `defaultRenderHandler`, `renderRouterToString`, `defaultStreamHandler`, `renderRouterToStream`, `RouterServer` - `@tanstack/react-router/ssr/client` - `RouterClient` ## Shared Router Configuration Since your router exists on both the server and the client, create it in a shared file: ```tsx // src/router.tsx import { createRouter as createTanstackRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' export function createRouter() { return createTanstackRouter({ routeTree, context: { head: '' }, defaultPreload: 'intent', scrollRestoration: true, }) } declare module '@tanstack/react-router' { interface Register { router: ReturnType<typeof createRouter> } } ``` ## Non-Streaming SSR ### Server Entry with defaultRenderHandler The simplest approach - handles wrapping and hydration automatically: ```tsx // src/entry-server.tsx import { createRequestHandler, defaultRenderHandler, } from '@tanstack/react-router/ssr/server' import { createRouter } from './router' export async function render({ request }: { request: Request }) { const handler = createRequestHandler({ request, createRouter }) return await handler(defaultRenderHandler) } ``` ### Server Entry with renderRouterToString For more control, use `renderRouterToString` with `RouterServer` to manually specify wrapping providers: ```tsx // src/entry-server.tsx import { createRequestHandler, renderRouterToString, RouterServer, } from '@tanstack/react-router/ssr/server' import { createRouter } from './router' export function render({ request }: { request: Request }) { const handler = createRequestHandler({ request, createRouter }) return handler(({ responseHeaders, router }) => renderRouterToString({ responseHeaders, router, children: <RouterServer router={router} />, }), ) } ``` ### Client Entry The client entry is the same for both streaming and non-streaming SSR: ```tsx // src/entry-client.tsx import { hydrateRoot } from 'react-dom/client' import { RouterClient } from '@tanstack/react-router/ssr/client' import { createRouter } from './router' const router = createRouter() hydrateRoot(document, <RouterClient router={router} />) ``` ## Streaming SSR Streaming SSR sends the critical first paint immediately, then progressively streams remaining content. Useful for pages with slow or high-latency data fetching. ### Server Entry with defaultStreamHandler ```tsx // src/entry-server.tsx import { createRequestHandler, defaultStreamHandler, } from '@tanstack/react-router/ssr/server' import { createRouter } from './router' export async function render({ request }: { request: Request }) { const handler = createRequestHandler({ request, createRouter }) return await handler(defaultStreamHandler) } ``` ### Server Entry with renderRouterToStream ```tsx // src/entry-server.tsx import { createRequestHandler, renderRouterToStream, RouterServer, } from '@tanstack/react-router/ssr/server' import { createRouter } from './router' export function render({ request }: { request: Request }) { const handler = createRequestHandler({ request, createRouter }) return handler(({ request, responseHeaders, router }) => renderRouterToStream({ request, responseHeaders, router, children: <RouterServer router={router} />, }), ) } ``` ### Vite Configuration for Streaming Enable streaming support in the router plugin: ```ts // vite.config.ts import { tanstackRouter } from '@tanstack/router-plugin/vite' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [ tanstackRouter({ autoCodeSplitting: true, enableStreaming: true }), react(), ], ssr: { optimizeDeps: { include: ['@tanstack/react-router/ssr/server'], }, }, }) ``` ## Express Integration `createRequestHandler` requires a Web API standard `Request` and returns a `Response` promise. When using Express, convert between the two: ```tsx // src/entry-server.tsx import { pipeline } from 'node:stream/promises' import { RouterServer, createRequestHandler, renderRouterToStream, } from '@tanstack/react-router/ssr/server' import { createRouter } from './router' import type express from 'express' export async function render({ req, res, head = '', }: { head?: string req: express.Request res: express.Response }) { const url = new URL(req.originalUrl || req.url, 'https://localhost:3000').href const request = new Request(url, { method: req.method, headers: (() => { const headers = new Headers() for (const [key, value] of Object.entries(req.headers)) { headers.set(key, value as any) } return headers })(), }) const handler = createRequestHandler({ request, createRouter: () => { const router = createRouter() router.update({ context: { ...router.options.context, head }, }) return router }, }) const response = await handler(({ request, responseHeaders, router }) => renderRouterToStream({ request, responseHeaders, router, children: <RouterServer router={router} />, }), ) res.statusMessage = response.statusText res.status(response.status) response.headers.forEach((value, name) => res.setHeader(name, value)) return pipeline(response.body as any, res) } ``` ## Data Serialization Resolved loader data is automatically dehydrated on the server and rehydrated on the client. TanStack Router uses a lightweight serializer that supports types beyond `JSON.stringify`/`JSON.parse`: | Type | Supported | |------|-----------| | `string`, `number`, `boolean`, `null` | Yes (standard JSON) | | `Date` | Yes | | `Error` | Yes | | `FormData` | Yes | | `undefined` | Yes | For complex types like `Map`, `Set`, or `BigInt`, a custom serializer is needed. Custom serializer support is under active development. When using deferred data streaming, the streaming SSR pattern (`defaultStreamHandler` or `renderRouterToStream`) is required for proper dehydration/hydration of streamed data. ## History Types TanStack Router uses `@tanstack/history` to manage routing history. Three types are available: ```tsx import { createBrowserHistory, createHashHistory, createMemoryHistory, createRouter, } from '@tanstack/react-router' // Browser history (default on client) - uses browser History API const browserHistory = createBrowserHistory() // Hash history - uses URL hash fragment, useful without server URL rewrites const hashHistory = createHashHistory() // Memory history - required on the server where window does not exist const memoryHistory = createMemoryHistory({ initialEntries: ['/'] }) const router = createRouter({ routeTree, history: memoryHistory }) ``` When using `RouterServer`, memory history is configured automatically. You do not need to manually create a memory history instance for SSR. On the client, `RouterClient` defaults to browser history. ## Document Head Management TanStack Router provides built-in document head management for `<title>`, `<meta>`, `<link>`, `<style>`, and `<script>` tags. Official documentation: https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management ### HeadContent and Scripts Components `<HeadContent />` renders head-related tags. `<Scripts />` renders body scripts before the main entry point. Both are required for SSR: ```tsx import { HeadContent, Outlet, Scripts, createRootRoute, } from '@tanstack/react-router' export const Route = createRootRoute({ component: () => ( <html lang="en"> <head> <HeadContent /> </head> <body> <Outlet /> <Scripts /> </body> </html> ), }) ``` For SPAs without server-rendered HTML structure, render `<HeadContent />` as high as possible in the component tree (no `<head>` wrapper needed). ### ScriptOnce Renders an inline script that executes before React hydration and removes itself from the DOM. On client-side navigation, nothing renders (prevents duplicate execution): ```tsx import { ScriptOnce } from '@tanstack/react-router' const themeScript = `(function() { try { const theme = localStorage.getItem('theme') || 'auto'; const resolved = theme === 'auto' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; document.documentElement.classList.add(resolved); } catch (e) {} })();` function ThemeProvider({ children }: { children: React.ReactNode }) { return ( <> <ScriptOnce children={themeScript} /> {children} </> ) } ``` When `ScriptOnce` modifies the DOM before hydration, add `suppressHydrationWarning` to the affected element (e.g. `<html lang="en" suppressHydrationWarning>`). ### Route head() Property Define head metadata for any route. Returns an object with `meta`, `links`, `styles`, and `scripts` arrays: ```tsx export const Route = createRootRouteWithContext<RouterContext>()({ head: () => ({ meta: [ { charSet: 'UTF-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1.0' }, { name: 'description', content: 'My application description' }, { title: 'My App' }, ], links: [ { rel: 'icon', href: '/favicon.ico' }, ], styles: [ { media: 'all and (max-width: 500px)', children: `p { color: blue; }`, }, ], scripts: [ { src: 'https://www.google-analytics.com/analytics.js' }, ], }), }) ``` Body scripts use the top-level `scripts` route option (rendered via `<Scripts />`): ```tsx export const Route = createRootRoute({ scripts: () => [{ children: 'console.log("Hello from body script")' }], }) ``` ### Meta Tag Deduping TanStack Router automatically dedupes `title` and `meta` tags across nested routes: - `title` tags in child routes override parent route titles - `meta` tags with the same `name` or `property` are overridden by the last occurrence in nested routes ### Dynamic Head with loaderData The `head()` function receives `loaderData` from the route's loader: ```tsx export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params: { postId } }) => { const post = await fetchPost(postId) return { title: post.title, description: post.excerpt } }, head: ({ loaderData }) => ({ meta: loaderData ? [ { title: loaderData.title }, { name: 'description', content: loaderData.description }, ] : undefined, }), component: PostComponent, }) ``` ### Composable Nested Route Heads Each route in a hierarchy can define its own `head()`. Tags merge from parent to child, with child routes overriding duplicates: ```tsx // routes/__root.tsx - base defaults export const Route = createRootRoute({ head: () => ({ meta: [ { charSet: 'UTF-8' }, { title: 'My App' }, { name: 'description', content: 'Default description' }, ], }), }) // routes/blog/$slug.tsx - overrides title and description with loader data export const Route = createFileRoute('/blog/$slug')({ loader: async ({ params }) => fetchPost(params.slug), head: ({ loaderData }) => ({ meta: loaderData ? [ { title: `${loaderData.title} - My App` }, { name: 'description', content: loaderData.excerpt }, ] : undefined, }), }) ``` ## SEO Patterns ### Open Graph and Twitter Cards ```tsx export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => fetchPost(params.postId), head: ({ loaderData }) => ({ meta: loaderData ? [ { title: loaderData.title }, { name: 'description', content: loaderData.excerpt }, { property: 'og:title', content: loaderData.title }, { property: 'og:description', content: loaderData.excerpt }, { property: 'og:image', content: loaderData.coverImage }, { property: 'og:type', content: 'article' }, { property: 'og:url', content: `https://example.com/posts/${loaderData.slug}` }, { name: 'twitter:card', content: 'summary_large_image' }, { name: 'twitter:title', content: loaderData.title }, { name: 'twitter:description', content: loaderData.excerpt }, { name: 'twitter:image', content: loaderData.coverImage }, ] : undefined, links: loaderData ? [{ rel: 'canonical', href: `https://example.com/posts/${loaderData.slug}` }] : undefined, }), }) ``` ### Structured Data (JSON-LD) Use the `scripts` array in `head()` to inject JSON-LD: ```tsx export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => fetchPost(params.postId), head: ({ loaderData }) => ({ meta: loaderData ? [{ title: loaderData.title }] : undefined, scripts: loaderData ? [ { type: 'application/ld+json', children: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: loaderData.title, description: loaderData.excerpt, image: loaderData.coverImage, author: { '@type': 'Person', name: loaderData.author.name }, datePublished: loaderData.publishedAt, dateModified: loaderData.updatedAt, }), }, ] : undefined, }), }) ``` ### Reusable SEO Helper Create a shared utility for consistent meta tags across routes: ```tsx // src/utils/seo.ts interface SeoOptions { title: string description: string image?: string url?: string type?: string twitterCard?: 'summary' | 'summary_large_image' twitterSite?: string } export function seo(options: SeoOptions) { const { title, description, image, url, type = 'website', twitterCard = 'summary_large_image', twitterSite, } = options return { meta: [ { title }, { name: 'description', content: description }, { property: 'og:title', content: title }, { property: 'og:description', content: description }, { property: 'og:type', content: type }, ...(image ? [{ property: 'og:image', content: image }] : []), ...(url ? [{ property: 'og:url', content: url }] : []), { name: 'twitter:card', content: twitterCard }, { name: 'twitter:title', content: title }, { name: 'twitter:description', content: description }, ...(image ? [{ name: 'twitter:image', content: image }] : []), ...(twitterSite ? [{ name: 'twitter:site', content: twitterSite }] : []), ], links: url ? [{ rel: 'canonical', href: url }] : [], } } // Usage in a route: export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => fetchPost(params.postId), head: ({ loaderData }) => { if (!loaderData) return {} return seo({ title: `${loaderData.title} - My Blog`, description: loaderData.excerpt, image: loaderData.coverImage, url: `https://example.com/posts/${loaderData.slug}`, type: 'article', }) }, }) ``` ## Best Practices 1. **Use TanStack Start for new projects.** Start handles SSR, streaming, server functions, code splitting, and deployment out of the box. Only use manual Router SSR when integrating with an existing custom server or when you need fine-grained control over the rendering pipeline. 2. **Always create the router in a shared file.** Both server and client entry points must import the same `createRouter` function to ensure consistent route trees and configuration between environments. 3. **Prefer streaming SSR for pages with slow data.** Use `renderRouterToStream` or `defaultStreamHandler` when routes fetch from slow APIs. The critical first paint arrives immediately while deferred data streams in progressively. 4. **Place HeadContent in the head and Scripts at the end of body.** `<HeadContent />` must be rendered inside `<head>` (or as high as possible in SPAs). `<Scripts />` goes inside `<body>`, after `<Outlet />`, so body scripts load before hydration but after the DOM is available. 5. **Use the seo helper pattern for consistent meta tags.** A shared utility that generates Open Graph, Twitter Card, and canonical URL tags from a single options object prevents tag omissions and keeps structure consistent. 6. **Set `suppressHydrationWarning` on elements modified by ScriptOnce.** If an inline script modifies the DOM before React hydrates (like adding a theme class to `<html>`), add `suppressHydrationWarning` to the affected element. 7. **Handle Express-to-Web-API conversion carefully.** `createRequestHandler` expects a standard `Request` and returns a `Response`. When using Express, convert `req` to `Request` and pipe `response.body` back through `res` using `pipeline` from `node:stream/promises`. -
routing-patterns.md 21.1 KB
# Routing Patterns TanStack Router provides a flexible routing system with multiple approaches: file-based routing (recommended), code-based routing, and virtual file routes. This reference covers all routing patterns, naming conventions, and advanced features. > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/file-based-routing ## File-Based Routing File-based routing uses the filesystem to define route hierarchy. The TanStack Router bundler plugin automatically generates `routeTree.gen.ts` from your route directory. ### Vite Plugin Setup ```ts // vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { tanstackRouter } from '@tanstack/router-plugin/vite' export default defineConfig({ plugins: [ tanstackRouter({ target: 'react', // routesDirectory defaults to './src/routes' // generatedRouteTree defaults to './src/routeTree.gen.ts' }), react(), ], }) ``` The plugin watches `src/routes/` during development and regenerates `routeTree.gen.ts` on route file changes. ### Route File Anatomy Every route file uses `createFileRoute` (path auto-managed by the plugin): ```tsx // src/routes/about.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/about')({ component: AboutComponent, }) function AboutComponent() { return <div>About</div> } ``` The root route uses `createRootRoute` and must be named `__root.tsx`: ```tsx // src/routes/__root.tsx import { createRootRoute, Outlet } from '@tanstack/react-router' export const Route = createRootRoute({ component: () => ( <div> <nav>{/* navigation */}</nav> <Outlet /> </div> ), }) ``` ### Directory Routes Directories represent route hierarchy: ``` src/routes/ __root.tsx -> always rendered index.tsx -> / (exact) about.tsx -> /about posts.tsx -> /posts (layout) posts/ index.tsx -> /posts (exact) $postId.tsx -> /posts/$postId settings.tsx -> /settings (layout) settings/ profile.tsx -> /settings/profile notifications.tsx -> /settings/notifications ``` ### Flat Routes (Dot Notation) Use `.` in filenames to denote nesting without directories: ``` src/routes/ __root.tsx index.tsx -> / posts.tsx -> /posts posts.index.tsx -> /posts (exact) posts.$postId.tsx -> /posts/$postId settings.tsx -> /settings settings.profile.tsx -> /settings/profile settings.notifications.tsx -> /settings/notifications ``` Both styles can be freely combined in a single project. ## File Naming Conventions > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/file-naming-conventions ### `$param` - Dynamic Segments The `$` token followed by a label creates a dynamic path parameter: ```tsx // src/routes/posts.$postId.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/posts/$postId')({ loader: ({ params }) => fetchPost(params.postId), component: PostComponent, }) function PostComponent() { const { postId } = Route.useParams() return <div>Post ID: {postId}</div> } ``` Multiple dynamic segments work at each level: `/posts/$postId/$revisionId` captures both. ### `$` - Splat / Catch-All A path of only `$` captures all remaining segments into `params._splat`: ```tsx // src/routes/files/$.tsx -> /files/* export const Route = createFileRoute('/files/$')({ component: () => { const { _splat } = Route.useParams() // URL /files/documents/report.pdf -> _splat = "documents/report.pdf" return <div>File path: {_splat}</div> }, }) ``` ### `_` Prefix - Pathless Layout Routes Wraps children without consuming a URL segment. The part after `_` is the route's unique ID. ``` # Flat # Directory _auth.tsx (layout only) _auth/ _auth.login.tsx /login route.tsx (layout only) _auth.register.tsx /register login.tsx /login register.tsx /register ``` ```tsx // src/routes/_auth.tsx import { Outlet, createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/_auth')({ component: () => ( <div className="auth-layout"> <h1>Authentication</h1> <Outlet /> </div> ), }) ``` | URL | Rendered Components | |-----|-------------------| | `/login` | `<Root><AuthLayout><Login>` | | `/register` | `<Root><AuthLayout><Register>` | Pathless layout routes cannot use dynamic segments in their ID (no `_$postId/`). ### `_` Suffix - Non-Nested Routes Breaks a route out of its parent's component nesting while keeping the URL path: ``` src/routes/ posts.tsx -> /posts (layout) posts.$postId.tsx -> /posts/$postId (nested under posts) posts_.$postId.edit.tsx -> /posts/$postId/edit (NOT nested) ``` | URL | Rendered Components | |-----|-------------------| | `/posts/123` | `<Root><Posts><Post>` | | `/posts/123/edit` | `<Root><PostEditor>` (outside Posts layout) | ### `-` Prefix - Excluded Files Files and directories prefixed with `-` are excluded from route generation. Use for colocation: ``` src/routes/ posts.tsx -posts-table.tsx -> ignored by router -components/ -> ignored by router header.tsx ``` Import normally: `import { PostsTable } from './-posts-table'` **Also silently excluded: dot-prefixed files and directories.** Anything starting with `.` (e.g. `.well-known/`) is ignored by route generation and never appears in `routeTree.gen.ts` - with no error. This bites server routes for OAuth/discovery endpoints like `/.well-known/oauth-authorization-server`. Serve those another way (a non-dotted path, a server route file named via escaped matching, or framework-level static handling). ### `()` - Route Group Directories Purely organizational directories that do not affect URL or component tree: ``` src/routes/ (app)/ dashboard.tsx -> /dashboard settings.tsx -> /settings (auth)/ login.tsx -> /login ``` ### Other Conventions - **`route.tsx`** - In directories, provides the route config at that path level (e.g., `account/route.tsx` for `/account`) - **`index` token** - Matches parent route exactly when no child segments follow - **`[x]` escaping** - Square brackets escape special chars: `script[.]js.tsx` becomes `/script.js` - **`{-$param}`** - Optional path parameters matching with or without the segment. `/posts/{-$category}` matches both `/posts` and `/posts/tech`. Ranked lower than exact matches. ## Code-Based Routing Define routes entirely in code using `createRoute` and `createRootRoute`. Use when file-based routing does not fit. > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/code-based-routing ```tsx import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router' const rootRoute = createRootRoute({ component: () => <div><Outlet /></div>, }) const aboutRoute = createRoute({ getParentRoute: () => rootRoute, path: 'about', component: () => <div>About</div>, }) const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts', component: PostsLayout, }) const postsIndexRoute = createRoute({ getParentRoute: () => postsRoute, path: '/', // index route uses '/' }) const postRoute = createRoute({ getParentRoute: () => postsRoute, path: '$postId', loader: ({ params }) => fetchPost(params.postId), }) // Pathless layout: use 'id' instead of 'path' const pathlessLayout = createRoute({ getParentRoute: () => rootRoute, id: 'authLayout', component: AuthLayout, }) // Non-nested: set parent to root with full path const postEditorRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts/$postId/edit', }) // Build tree with .addChildren() const routeTree = rootRoute.addChildren([ aboutRoute, postsRoute.addChildren([postsIndexRoute, postRoute]), postEditorRoute, pathlessLayout.addChildren([loginRoute, registerRoute]), ]) const router = createRouter({ routeTree }) ``` Every route (except root) requires `getParentRoute` for type safety. Use code-based routing for runtime route generation, external config, incremental migration, or projects without bundler plugin support. ## Virtual File Routes Define route trees programmatically while referencing real files. Gives full control over organization without filesystem constraints. > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/virtual-file-routes ### Setup ```ts // vite.config.ts import { tanstackRouter } from '@tanstack/router-plugin/vite' export default defineConfig({ plugins: [ tanstackRouter({ target: 'react', virtualRouteConfig: './src/routes.ts', }), react(), ], }) ``` ### API Functions | Function | Purpose | |----------|---------| | `rootRoute(file, children)` | Creates root route pointing to a file | | `route(path, file, children)` | Creates route at path pointing to a file | | `index(file)` | Creates index route | | `layout(file, children)` | Creates pathless layout (optional ID as first arg) | | `physical(pathPrefix, directory)` | Mounts directory using standard file-based conventions | ### Full Example ```tsx // src/routes.ts import { rootRoute, route, index, layout, physical } from '@tanstack/virtual-file-routes' export const routes = rootRoute('root.tsx', [ index('index.tsx'), layout('pathlessLayout.tsx', [ route('/dashboard', 'app/dashboard.tsx', [ index('app/dashboard-index.tsx'), route('/invoices', 'app/dashboard-invoices.tsx', [ index('app/invoices-index.tsx'), route('$id', 'app/invoice-detail.tsx'), ]), ]), physical('/posts', 'posts'), // mounts posts/ dir with file-based conventions ]), ]) ``` Routes without a file set a common path prefix: `route('/hello', [route('/world', 'world.tsx')])`. Use `physical('features')` (no path prefix) to merge a directory at the current level. ### `__virtual.ts` - Inline Virtual Configuration Drop a `__virtual.ts` in any directory within a file-based route tree to switch to virtual config for that subtree: ```tsx // src/routes/posts/__virtual.ts import { defineVirtualSubtreeConfig, index, route } from '@tanstack/virtual-file-routes' export default defineVirtualSubtreeConfig([ index('home.tsx'), route('$id', 'details.tsx'), ]) ``` Virtual and file-based routing can be nested as many levels deep as needed. ## Layout Routes Layout routes wrap child routes with shared UI, loaders, search param validation, error boundaries, and context. **Path-based layout** - A route with children uses `<Outlet />` to render child content: ```tsx // src/routes/app.tsx import { Outlet, createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/app')({ component: () => ( <div> <h1>App Layout</h1> <Outlet /> </div> ), }) ``` **Pathless layout** - Wraps children without adding a URL segment. Use `_` prefix in file-based or `id` in code-based: ```tsx // src/routes/_authenticated.tsx export const Route = createFileRoute('/_authenticated')({ beforeLoad: async ({ context }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: '/login' }) } }, component: () => <Outlet />, }) ``` ## Route Masking Displays a different URL in the browser than the one actually navigated to. When shared or reloaded, the displayed (masked) URL is used. > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/route-masking ### Imperative (Link / navigate) ```tsx <Link to="/photos/$photoId/modal" params={{ photoId: '5' }} mask={{ to: '/photos/$photoId', params: { photoId: '5' } }} > Open Photo </Link> ``` ```tsx const navigate = useNavigate() navigate({ to: '/photos/$photoId/modal', params: { photoId: '5' }, mask: { to: '/photos/$photoId', params: { photoId: '5' } }, }) ``` ### Declarative (createRouteMask) ```tsx import { createRouteMask, createRouter } from '@tanstack/react-router' const photoModalMask = createRouteMask({ routeTree, from: '/photos/$photoId/modal', to: '/photos/$photoId', params: (prev) => ({ photoId: prev.photoId }), }) const router = createRouter({ routeTree, routeMasks: [photoModalMask] }) ``` ### unmaskOnReload By default, masked URLs persist through reloads. To unmask: pass `unmaskOnReload: true` on Link, createRouteMask, or as a router default. Route masking stores the actual target in `location.state.__tempLocation`. When copied/shared, mask data is lost and the displayed URL is used directly. ## Navigation Blocking Prevents route transitions for unsaved form data or in-progress operations. > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/navigation-blocking ### useBlocker Hook ```tsx import { useBlocker } from '@tanstack/react-router' function EditForm() { const [formIsDirty, setFormIsDirty] = useState(false) useBlocker({ shouldBlockFn: () => { if (!formIsDirty) return false return !confirm('Are you sure you want to leave?') }, }) } ``` ### Custom UI with withResolver ```tsx const { proceed, reset, status } = useBlocker({ shouldBlockFn: () => formIsDirty, withResolver: true, }) // Render custom dialog when status === 'blocked' // Call proceed() to allow navigation, reset() to cancel ``` ### shouldBlockFn with Type-Safe Locations ```tsx useBlocker({ shouldBlockFn: ({ current, next }) => { return current.routeId === '/foo' && next.fullPath === '/bar/$id' }, withResolver: true, }) ``` ### enableBeforeUnload Controls the browser's native beforeunload dialog independently: ```tsx useBlocker({ shouldBlockFn: () => !formIsDirty ? false : !confirm('Leave?'), enableBeforeUnload: formIsDirty, // or () => formIsDirty }) ``` ### Block Component Declarative alternative: ```tsx <Block shouldBlockFn={() => formIsDirty} enableBeforeUnload={formIsDirty} withResolver> {({ status, proceed, reset }) => ( status === 'blocked' && <ConfirmDialog onConfirm={proceed} onCancel={reset} /> )} </Block> ``` ## URL Rewrites Bidirectional URL transformation between browser display and router interpretation. > Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/url-rewrites ### Basic Configuration ```tsx const router = createRouter({ routeTree, rewrite: { input: ({ url }) => { /* browser URL -> router internal */ return url }, output: ({ url }) => { /* router internal -> browser URL */ return url }, }, }) ``` Rewrite functions receive a `URL` object and return the mutated `url`, a new `URL`, a string href, or `undefined` to skip. ### i18n Locale Prefix ```tsx const router = createRouter({ routeTree, rewrite: { input: ({ url }) => { const segments = url.pathname.split('/').filter(Boolean) if (segments[0] && locales.includes(segments[0])) { url.pathname = '/' + segments.slice(1).join('/') || '/' } return url }, output: ({ url }) => { url.pathname = `/${getLocale()}${url.pathname === '/' ? '' : url.pathname}` return url }, }, }) ``` ### Subdomain Routing ```tsx rewrite: { input: ({ url }) => { const subdomain = url.hostname.split('.')[0] if (subdomain === 'admin') url.pathname = '/admin' + url.pathname return url }, output: ({ url }) => { if (url.pathname.startsWith('/admin')) { url.hostname = 'admin.example.com' url.pathname = url.pathname.replace(/^\/admin/, '') || '/' } return url }, } ``` ### Composing Rewrites ```tsx import { composeRewrites } from '@tanstack/react-router' const router = createRouter({ routeTree, rewrite: composeRewrites([localeRewrite, legacyRewrite]) }) ``` Input rewrites execute first-to-last. Output rewrites execute last-to-first (reverse). ### location.href vs location.publicHref - `location.href` - Internal URL (after input rewrite), used for routing - `location.publicHref` - External URL (after output rewrite), shown in browser Use `publicHref` for sharing links, canonical URLs, analytics, and clipboard operations. ## History Types TanStack Router supports three history types from `@tanstack/history`: | Type | Function | Use Case | |------|----------|----------| | Browser | `createBrowserHistory()` | Default. Uses browser History API | | Hash | `createHashHistory()` | Servers without URL rewriting support | | Memory | `createMemoryHistory()` | Testing, SSR, non-browser environments | ```tsx import { createMemoryHistory, createRouter } from '@tanstack/react-router' const memoryHistory = createMemoryHistory({ initialEntries: ['/'] }) const router = createRouter({ routeTree, history: memoryHistory }) ``` ## Route Types Summary | Route Type | File Convention | URL Behavior | Component Behavior | |-----------|----------------|-------------|-------------------| | Root | `__root.tsx` | Always matches | Always rendered, wraps all routes | | Index | `index.tsx` | Exact parent match | Rendered when parent matches exactly | | Static | `about.tsx` | `/about` | Rendered at exact path | | Dynamic | `$postId.tsx` | `/123` (captures param) | Receives params via `useParams()` | | Splat | `$.tsx` | Captures all remaining | Receives `_splat` param | | Optional | `{-$param}.tsx` | With or without segment | Param `undefined` when absent | | Layout | `posts.tsx` + `posts/` | Matches path prefix | Wraps children via `<Outlet />` | | Pathless Layout | `_layout.tsx` | No URL segment consumed | Wraps children without URL impact | | Non-Nested | `posts_.$id.edit.tsx` | Matches full path | Renders outside parent layout | | Excluded | `-helpers.tsx` | Not a route | Ignored by router, for colocation | | Group | `(auth)/login.tsx` | Directory ignored | Organizational only | ## Route Lifecycle Callbacks & Remounting Routes expose transition hooks and control over when their component remounts. ```tsx export const Route = createFileRoute('/posts/$postId')({ onEnter: (match) => analytics.view(match.params.postId), // matched after not being matched previously onStay: (match) => {}, // still matched across a navigation onLeave: (match) => cleanup(match.params.postId), // no longer matched // By default a param change (/posts/1 -> /posts/2) reuses the component instance and just re-runs the loader. // Return a different value to force a full remount (reset local state, re-run effects): remountDeps: ({ params }) => params.postId, }) ``` Use `remountDeps` when a route's component holds per-item local state that must reset on param change; omit it to keep the cheaper reuse-and-reload default. ## View Transitions TanStack Router integrates the browser [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) for animated navigations. Enable per-navigation or globally. ```tsx // Per navigation <Link to="/posts/$postId" params={{ postId: '1' }} viewTransition>Post</Link> navigate({ to: '/posts', viewTransition: true }) // Globally for every navigation const router = createRouter({ routeTree, defaultViewTransition: true }) // Scope named transitions with the object form (types via ViewTransitionOptions) <Link to="/posts" viewTransition={{ types: ['slide-left'] }}>Posts</Link> ``` Falls back to an instant navigation in browsers without `document.startViewTransition`; style the animation with the standard `::view-transition-*` CSS pseudo-elements. ## Best Practices 1. **Prefer file-based routing for most projects.** It provides automatic code splitting, type-safe route generation, and eliminates manual route tree assembly. Use code-based routing only when runtime generation or non-standard tooling requires it. 2. **Mix flat and directory structures intentionally.** Use directories for route groups with multiple siblings. Use dot notation for isolated deep routes (e.g., `posts.$postId.edit.tsx`). 3. **Use pathless layouts for shared UI without URL segments.** Common patterns include authentication wrappers (`_auth`), dashboard shells (`_dashboard`), and sidebar layouts. Name descriptively since the `_` prefix ID must be unique. 4. **Colocate route-specific code with the `-` prefix.** Place helper components, utilities, and data-fetching logic next to route files using `-` prefixed names. They are ignored by the router but accessible via standard imports. 5. **Use route masking for modal patterns.** When a modal has its own route for deep-linking but should display a simpler URL, masking keeps URLs clean. Set `unmaskOnReload: true` if masks should not persist across reloads. 6. **Use `enableBeforeUnload` intentionally.** Only trigger the native beforeunload dialog for genuinely unsaved data. Pair `shouldBlockFn` with `enableBeforeUnload` tied to the same dirty state. 7. **Use virtual file routes for custom project structures.** Map existing file organizations to routes with full programmatic control while retaining file-based routing performance benefits. 8. **Use `publicHref` for external-facing URLs.** When URL rewrites are active, always use `location.publicHref` for sharing, canonical URLs, and analytics. -
search-params.md 21.3 KB
# Search Params TanStack Router treats URL search params as first-class application state - typed, validated, and managed with the same DX you expect from dedicated state management libraries. Official docs: https://tanstack.com/router/latest/docs/framework/react/guide/search-params ## Search Params as State Search params are the "OG" state manager - global state living inside the URL. TanStack Router builds on this with JSON-first serialization that goes far beyond `URLSearchParams`: - Automatic JSON parsing/serialization of nested structures - Type-safe validation at the route level - Structural sharing to preserve referential identity between navigations - First-level values stay URLSearchParams-compatible while nested values are JSON-encoded ```tsx <Link to="/shop" search={{ pageIndex: 3, includeCategories: ['electronics', 'gifts'], sortBy: 'price', desc: true, }} /> // URL: /shop?pageIndex=3&includeCategories=%5B%22electronics%22%2C%22gifts%22%5D&sortBy=price&desc=true // Parsed back: { pageIndex: 3, includeCategories: ["electronics", "gifts"], sortBy: "price", desc: true } ``` First-level primitives (numbers, booleans) are preserved as actual types, not strings. Nested objects and arrays are JSON-stringified. The first level remains flat for compatibility with tools that read `URLSearchParams`. ## Defining Search Params All definitions start with the `validateSearch` option on a route. It receives raw JSON-parsed search params and must return a typed object. ### Inline Validation ```tsx // src/routes/shop/products.tsx type ProductSearch = { page: number filter: string sort: 'newest' | 'oldest' | 'price' } export const Route = createFileRoute('/shop/products')({ validateSearch: (search: Record<string, unknown>): ProductSearch => ({ page: Number(search?.page ?? 1), filter: (search.filter as string) || '', sort: (search.sort as ProductSearch['sort']) || 'newest', }), }) ``` ### Zod (with .catch() Shorthand) Zod schemas work directly since `validateSearch` accepts objects with a `parse` property: ```tsx import { z } from 'zod' const productSearchSchema = z.object({ page: z.number().catch(1), filter: z.string().catch(''), sort: z.enum(['newest', 'oldest', 'price']).catch('newest'), }) export const Route = createFileRoute('/shop/products')({ validateSearch: productSearchSchema, // shorthand for (s) => productSearchSchema.parse(s) }) ``` `.catch()` silently provides a fallback when validation fails. `.default()` throws an error that triggers `onError`/`errorComponent` (with `error.routerCode === 'VALIDATE_SEARCH'`). ### Zod Adapter for Input/Output Types When using `.default()`, Zod's input type differs from output. The `zodValidator` adapter handles this so navigation does not require defaulted params: ```tsx import { zodValidator } from '@tanstack/zod-adapter' import { z } from 'zod' const productSearchSchema = z.object({ page: z.number().default(1), filter: z.string().default(''), sort: z.enum(['newest', 'oldest', 'price']).default('newest'), }) export const Route = createFileRoute('/shop/products/')({ validateSearch: zodValidator(productSearchSchema), }) // This Link is valid - search params are optional because of defaults <Link to="/shop/products" /> ``` The `fallback` helper retains types while providing fallback values (unlike `.catch()` which makes fields `unknown`): ```tsx import { fallback, zodValidator } from '@tanstack/zod-adapter' import { z } from 'zod' const productSearchSchema = z.object({ page: fallback(z.number(), 1).default(1), filter: fallback(z.string(), '').default(''), sort: fallback(z.enum(['newest', 'oldest', 'price']), 'newest').default('newest'), }) export const Route = createFileRoute('/shop/products/')({ validateSearch: zodValidator(productSearchSchema), }) ``` You can configure which type to use for navigation vs reading: ```tsx validateSearch: zodValidator({ schema: productSearchSchema, input: 'output', // Use output type for navigation output: 'input', // Use input type for reading }) ``` ### Valibot (Standard Schema - No Adapter Needed) Valibot 1.0+ implements Standard Schema, so it works directly: ```tsx import * as v from 'valibot' const productSearchSchema = v.object({ page: v.optional(v.fallback(v.number(), 1), 1), filter: v.optional(v.fallback(v.string(), ''), ''), sort: v.optional( v.fallback(v.picklist(['newest', 'oldest', 'price']), 'newest'), 'newest', ), }) export const Route = createFileRoute('/shop/products/')({ validateSearch: productSearchSchema, }) ``` ### ArkType (Standard Schema - No Adapter Needed) ```tsx import { type } from 'arktype' const productSearchSchema = type({ page: 'number = 1', filter: 'string = ""', sort: '"newest" | "oldest" | "price" = "newest"', }) export const Route = createFileRoute('/shop/products/')({ validateSearch: productSearchSchema, }) ``` ## Reading Search Params ### useSearch in Components ```tsx export const Route = createFileRoute('/shop/products')({ validateSearch: productSearchSchema, }) function ProductList() { const { page, filter, sort } = Route.useSearch() return <div>Page {page}, Filter: {filter}, Sort: {sort}</div> } ``` ### Fine-Grained Selectors with select Use `select` to subscribe to a subset of search params. The component only re-renders when the selected value changes: ```tsx // Only re-renders when page changes const page = Route.useSearch({ select: (s) => s.page }) ``` When `select` returns a new object, enable structural sharing to prevent unnecessary re-renders: ```tsx const filterState = Route.useSearch({ select: (s) => ({ filter: s.filter, label: `Filtering by: ${s.filter}` }), structuralSharing: true, }) ``` Enable globally via `createRouter({ routeTree, defaultStructuralSharing: true })`. Note: structural sharing only works with JSON-compatible data (not class instances). ### Outside Route Components Use `getRouteApi` to avoid importing the route (prevents circular deps in code-split routes): ```tsx import { getRouteApi } from '@tanstack/react-router' const routeApi = getRouteApi('/shop/products') function ProductSidebar() { const { filter, sort } = routeApi.useSearch() return <div>Filter: {filter}, Sort: {sort}</div> } ``` Or use `useSearch` with `from` for type safety, or `strict: false` for loose access: ```tsx // Type-safe: requires from const search = useSearch({ from: '/shop/products' }) // Loose: all params are T | undefined const search = useSearch({ strict: false }) ``` ### In Loaders via loaderDeps Only extract params your loader actually uses - returning the entire search object causes unnecessary cache invalidation: ```tsx export const Route = createFileRoute('/posts')({ validateSearch: z.object({ offset: z.number().catch(0), limit: z.number().catch(20), viewMode: z.enum(['grid', 'list']).catch('list'), }), // Only extract what the loader needs loaderDeps: ({ search: { offset, limit } }) => ({ offset, limit }), loader: ({ deps: { offset, limit } }) => fetchPosts({ offset, limit }), }) // BAD: loaderDeps: ({ search }) => search // reloads when viewMode changes too ``` ## Writing Search Params ### Link with search Prop ```tsx // Replace all search params <Link to="/shop/products" search={{ page: 1, filter: '', sort: 'newest' }}> Reset </Link> // Functional update - preserves other params <Link from={Route.fullPath} search={(prev) => ({ ...prev, page: prev.page + 1 })}> Next Page </Link> // Generic component on multiple routes: use to="." for loose types <Link to="." search={(prev) => ({ ...prev, page: prev.page + 1 })}> Next Page </Link> // Component in a specific subtree: specify from <Link from="/posts" to="." search={(prev) => ({ ...prev, page: prev.page + 1 })}> Next Page </Link> ``` ### useNavigate For programmatic updates in event handlers: ```tsx const navigate = useNavigate({ from: '/shop/products' }) // Functional update navigate({ search: (prev) => ({ ...prev, sort: 'price', page: 1 }) }) // Full replacement navigate({ search: { page: 1, filter: '', sort: 'newest' } }) ``` ### router.navigate and Navigate Component ```tsx // Outside React components router.navigate({ to: '/shop/products', search: (prev) => ({ ...prev, page: 1 }) }) // Declarative redirect inside a component <Navigate to="/shop/products" search={{ page: 1, filter: '', sort: 'newest' }} /> ``` ## Search Middlewares Search middlewares transform search params when generating link hrefs and during navigation after validation. Defined in `search.middlewares` on a route. ### Custom Middleware Each middleware receives `{ search, next }`. `search` is the current params, `next` produces the result from downstream: ```tsx export const Route = createRootRoute({ validateSearch: zodValidator(z.object({ rootValue: z.string().optional() })), search: { middlewares: [ ({ search, next }) => { const result = next(search) return { rootValue: search.rootValue, ...result } // retain unless overridden }, ], }, }) ``` ### retainSearchParams Preserves specified params across navigations. If a link explicitly sets a param, that value wins. ```tsx import { retainSearchParams } from '@tanstack/react-router' // Retain specific keys search: { middlewares: [retainSearchParams(['rootValue', 'locale'])] } // Retain ALL current params search: { middlewares: [retainSearchParams(true)] } ``` ### stripSearchParams Removes params from URLs when they match defaults, keeping URLs clean. ```tsx import { stripSearchParams } from '@tanstack/react-router' // Strip by default values (deep equality comparison) const defaults = { one: 'abc', two: 'xyz' } search: { middlewares: [stripSearchParams(defaults)] } // URL: /hello when one='abc' and two='xyz' // URL: /hello?one=changed when one differs // Strip specific keys (always remove, regardless of value) search: { middlewares: [stripSearchParams(['hello'])] } // Strip ALL params (only works when no params are required) search: { middlewares: [stripSearchParams(true)] } ``` ### Chaining Middlewares Middlewares execute in order. Combine for complex behaviors: ```tsx export const Route = createFileRoute('/search')({ validateSearch: zodValidator(z.object({ retainMe: z.string().optional(), arrayWithDefaults: z.string().array().default(['foo', 'bar']), required: z.string(), })), search: { middlewares: [ retainSearchParams(['retainMe']), stripSearchParams({ arrayWithDefaults: ['foo', 'bar'] }), ], }, }) ``` ## Search Param Inheritance Child routes automatically inherit and merge parent search params. Types merge down the tree: ```tsx // src/routes/shop/products.tsx - parent defines page, filter, sort export const Route = createFileRoute('/shop/products')({ validateSearch: z.object({ page: z.number().catch(1), filter: z.string().catch(''), sort: z.enum(['newest', 'oldest', 'price']).catch('newest'), }), }) // src/routes/shop/products/$productId.tsx - child has full access export const Route = createFileRoute('/shop/products/$productId')({ validateSearch: z.object({ tab: z.enum(['details', 'reviews', 'related']).catch('details'), }), beforeLoad: ({ search }) => { search.page // number (from parent) search.filter // string (from parent) search.sort // 'newest' | 'oldest' | 'price' (from parent) search.tab // 'details' | 'reviews' | 'related' (own) }, }) ``` ## Complex Types ### Arrays and Nested Objects ```tsx import { fallback, zodValidator } from '@tanstack/zod-adapter' import { z } from 'zod' const searchSchema = z.object({ categories: fallback(z.array(z.string()), []).default([]), tags: fallback(z.array(z.number()), []).default([]), priceRange: fallback( z.object({ min: z.number(), max: z.number() }), { min: 0, max: 1000 }, ).default({ min: 0, max: 1000 }), }) export const Route = createFileRoute('/products/')({ validateSearch: zodValidator(searchSchema), }) // <Link to="/products" search={{ categories: ['electronics'], tags: [1, 2], priceRange: { min: 10, max: 500 } }} /> ``` ### Dates as Strings Search params must be JSON-serializable, so store dates as ISO strings: ```tsx const searchSchema = z.object({ startDate: fallback(z.string(), '').default(''), endDate: fallback(z.string(), '').default(''), }) // In component: parse to Date when needed const start = startDate ? new Date(startDate) : null ``` ### Enum/Union Types ```tsx const viewModes = ['grid', 'list', 'table'] as const const searchSchema = z.object({ view: fallback(z.enum(viewModes), 'grid').default('grid'), columns: fallback(z.array(z.string()), ['name', 'date']).default(['name', 'date']), }) ``` ## Custom Serialization Replace the default JSON serializer at the router level via `parseSearch`/`stringifySearch`. The default behavior is `parseSearchWith(JSON.parse)` and `stringifySearchWith(JSON.stringify)`. ### query-string ```tsx import qs from 'query-string' const router = createRouter({ routeTree, stringifySearch: stringifySearchWith((value) => qs.stringify(value)), parseSearch: parseSearchWith((value) => qs.parse(value)), }) // Produces: ?page=1&sort=asc&filters=author%3Dtanner%26min_words%3D800 ``` ### JSURL2 Compresses URLs while maintaining readability: ```tsx import { parse, stringify } from 'jsurl2' const router = createRouter({ routeTree, parseSearch: parseSearchWith(parse), stringifySearch: stringifySearchWith(stringify), }) // Produces: ?page=1&sort=asc&filters=(author~tanner~min*_words~800)~ ``` ### Base64 Encoding For maximum compatibility across browsers and URL unfurlers. Use safe binary encoding utilities that handle non-UTF8 characters (not raw `atob`/`btoa`): ```tsx const router = createRouter({ routeTree, parseSearch: parseSearchWith((value) => JSON.parse(decodeFromBinary(value))), stringifySearch: stringifySearchWith((value) => encodeToBinary(JSON.stringify(value))), }) function decodeFromBinary(str: string): string { return decodeURIComponent( Array.prototype.map .call(atob(str), (c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .join(''), ) } function encodeToBinary(str: string): string { return btoa( encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)), ), ) } ``` ## Sharing Search Params Across Routes ### Global Params via Root Route Define on the root with `retainSearchParams` so params persist everywhere: ```tsx // src/routes/__root.tsx export const Route = createRootRoute({ validateSearch: zodValidator(z.object({ locale: z.enum(['en', 'es', 'fr']).optional(), debug: z.boolean().optional(), })), search: { middlewares: [retainSearchParams(['locale', 'debug'])] }, }) // Every route/component can access locale and debug; they survive navigations ``` ### Cross-Route Retention on Layout Routes Use on a layout route so child navigations preserve shared state: ```tsx // src/routes/dashboard.tsx - navigating between children preserves dateRange/teamId export const Route = createFileRoute('/dashboard')({ validateSearch: zodValidator(z.object({ dateRange: z.enum(['7d', '30d', '90d', '1y']).optional(), teamId: z.string().optional(), })), search: { middlewares: [retainSearchParams(['dateRange', 'teamId'])] }, }) ``` ### Combining Retention and Stripping ```tsx const analyticsDefaults = { metric: 'pageviews', interval: 'daily' } export const Route = createFileRoute('/dashboard/analytics')({ validateSearch: zodValidator(z.object({ metric: z.string().default(analyticsDefaults.metric), interval: z.enum(['hourly', 'daily', 'weekly']).default(analyticsDefaults.interval), compareWith: z.string().optional(), })), search: { middlewares: [ retainSearchParams(['compareWith']), // persist across navigations stripSearchParams(analyticsDefaults), // clean URL when at defaults ], }, }) ``` ## Common Patterns ### Pagination ```tsx const defaults = { page: 1, perPage: 20 } export const Route = createFileRoute('/posts')({ validateSearch: zodValidator(z.object({ page: fallback(z.number().int().positive(), defaults.page).default(defaults.page), perPage: fallback(z.number().int().positive().max(100), defaults.perPage).default(defaults.perPage), })), search: { middlewares: [stripSearchParams(defaults)] }, loaderDeps: ({ search: { page, perPage } }) => ({ page, perPage }), loader: ({ deps }) => fetchPosts(deps), component: function PostList() { const { page } = Route.useSearch() return ( <div> <Link from={Route.fullPath} search={(prev) => ({ ...prev, page: Math.max(1, prev.page - 1) })}> Previous </Link> <span>Page {page}</span> <Link from={Route.fullPath} search={(prev) => ({ ...prev, page: prev.page + 1 })}> Next </Link> </div> ) }, }) ``` ### Filters and Sorting ```tsx const defaults = { search: '', category: 'all', sortBy: 'name' as const, sortOrder: 'asc' as const } export const Route = createFileRoute('/products')({ validateSearch: zodValidator(z.object({ search: fallback(z.string(), '').default(''), category: fallback(z.string(), 'all').default('all'), sortBy: fallback(z.enum(['name', 'price', 'rating']), 'name').default('name'), sortOrder: fallback(z.enum(['asc', 'desc']), 'asc').default('asc'), })), search: { middlewares: [stripSearchParams(defaults)] }, loaderDeps: ({ search }) => search, loader: ({ deps }) => fetchProducts(deps), component: function ProductsPage() { const search = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) return ( <div> <input value={search.search} onChange={(e) => navigate({ search: (prev) => ({ ...prev, search: e.target.value }) })} /> <Link from={Route.fullPath} search={defaults}>Reset</Link> </div> ) }, }) ``` ### Modal State via Search Params ```tsx export const Route = createFileRoute('/users')({ validateSearch: zodValidator(z.object({ editUserId: fallback(z.string(), '').default(''), showCreateModal: fallback(z.boolean(), false).default(false), })), search: { middlewares: [stripSearchParams({ editUserId: '', showCreateModal: false })] }, component: function UsersPage() { const { editUserId, showCreateModal } = Route.useSearch() const navigate = useNavigate({ from: Route.fullPath }) const closeModals = () => navigate({ search: (prev) => ({ ...prev, editUserId: '', showCreateModal: false }), }) return ( <div> <Link from={Route.fullPath} search={(prev) => ({ ...prev, showCreateModal: true })}> Create User </Link> {showCreateModal && <CreateUserModal onClose={closeModals} />} {editUserId && <EditUserModal userId={editUserId} onClose={closeModals} />} </div> ) }, }) ``` ### Tab State ```tsx const tabs = ['general', 'security', 'notifications'] as const export const Route = createFileRoute('/settings')({ validateSearch: zodValidator(z.object({ tab: fallback(z.enum(tabs), 'general').default('general'), })), search: { middlewares: [stripSearchParams({ tab: 'general' as const })] }, component: function SettingsPage() { const { tab } = Route.useSearch() return ( <nav> {tabs.map((t) => ( <Link key={t} from={Route.fullPath} search={{ tab: t }}>{t}</Link> ))} </nav> ) }, }) ``` ## Best Practices 1. **Always validate search params.** Never trust raw URL input. Use `validateSearch` with `.catch()` or `fallback()` to provide sensible defaults so malformed URLs do not break the user experience. 2. **Use schema-based validation with adapters.** Prefer Zod with `zodValidator` or Valibot/ArkType via Standard Schema over manual validation. This gives you correct input/output type inference for both navigation and reading. 3. **Use `fallback()` + `.default()` with the Zod adapter.** Plain `.catch()` loses type information. The `fallback` helper from `@tanstack/zod-adapter` preserves types while handling malformed values. 4. **Strip default values from URLs.** Use `stripSearchParams` to keep URLs clean. Users should only see params that differ from defaults. 5. **Retain cross-cutting params with `retainSearchParams`.** For params like `locale`, `debug`, or `theme` that should survive navigations, define them on a parent route with `retainSearchParams`. 6. **Be selective with `loaderDeps`.** Only extract search params your loader actually uses. Returning the entire search object causes unnecessary cache invalidation when unrelated params change. 7. **Use `select` for fine-grained subscriptions.** When a component only needs one search param, use `Route.useSearch({ select })` to prevent re-renders when other params change. Enable `structuralSharing: true` when selectors return objects. 8. **Prefer functional updates for search.** Use `search={(prev) => ({ ...prev, page: prev.page + 1 })}` instead of replacing the entire object. This preserves other params and avoids dropping state. ## Related Documentation - Search Params Guide: https://tanstack.com/router/latest/docs/framework/react/guide/search-params - Custom Serialization: https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization - Render Optimizations: https://tanstack.com/router/latest/docs/framework/react/guide/render-optimizations - Data Loading (loaderDeps): https://tanstack.com/router/latest/docs/framework/react/guide/data-loading - Type Safety: https://tanstack.com/router/latest/docs/framework/react/guide/type-safety -
server-functions.md 24.7 KB
# Server Functions Server functions let you define server-only logic that can be called from anywhere in your application - route loaders, components, hooks, event handlers, or other server functions. They run on the server but can be invoked from client code seamlessly through an auto-generated RPC layer. Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/server-functions ## createServerFn Basics Create server functions with `createServerFn()`. The default HTTP method is GET; use POST for mutations. ```tsx import { createServerFn } from '@tanstack/react-start' // GET request (default) - for reading data export const getServerTime = createServerFn().handler(async () => { return new Date().toISOString() }) // POST request - for mutations export const saveData = createServerFn({ method: 'POST' }).handler( async () => { await db.items.create({ title: 'New item' }) return { success: true } }, ) ``` Server functions accept a single `data` parameter. All parameters must be passed through this object since data crosses the network boundary. ```tsx export const greetUser = createServerFn({ method: 'GET' }) .handler(async ({ data }) => { return `Hello, ${data.name}!` }) // Calling the function const greeting = await greetUser({ data: { name: 'John' } }) ``` ## Input Validation Since server functions cross the network boundary, validating input ensures type safety and runtime correctness. TanStack Start supports inline validators and schema-library adapters. > The method is `.validator()`. The older `.inputValidator()` spelling is deprecated and the compiler now emits warnings for it (TanStack/router PR #7566). ### Inline Validator The simplest approach - pass a function that validates and returns typed data: ```tsx export const getUser = createServerFn({ method: 'GET' }) .validator((data: { id: string }) => data) .handler(async ({ data }) => { // data is typed as { id: string } return db.users.findById(data.id) }) ``` ### Zod Validator (Recommended) The `@tanstack/zod-adapter` provides the `zodValidator` adapter for use with Zod schemas: ```tsx import { createServerFn } from '@tanstack/react-start' import { zodValidator } from '@tanstack/zod-adapter' import { z } from 'zod' const CreatePostSchema = z.object({ title: z.string().min(1).max(200), body: z.string().min(1), tags: z.array(z.string()).optional(), }) export const createPost = createServerFn({ method: 'POST' }) .validator(zodValidator(CreatePostSchema)) .handler(async ({ data }) => { // data is fully typed and validated: // { title: string; body: string; tags?: string[] } return db.posts.create(data) }) ``` You can also pass a Zod schema directly to `validator` without the adapter: ```tsx export const createUser = createServerFn({ method: 'POST' }) .validator( z.object({ name: z.string().min(1), age: z.number().min(0), }), ) .handler(async ({ data }) => { return `Created user: ${data.name}, age ${data.age}` }) ``` ### Other Validator Adapters Valibot and ArkType adapters follow the same pattern: ```tsx // Valibot - install @tanstack/valibot-adapter import { valibotValidator } from '@tanstack/valibot-adapter' import * as v from 'valibot' export const updateProfile = createServerFn({ method: 'POST' }) .validator(valibotValidator(v.object({ displayName: v.pipe(v.string(), v.minLength(1)), bio: v.optional(v.pipe(v.string(), v.maxLength(500))), }))) .handler(async ({ data }) => db.profiles.update(data)) // ArkType - install @tanstack/arktype-adapter import { arkTypeValidator } from '@tanstack/arktype-adapter' import { type } from 'arktype' export const searchItems = createServerFn({ method: 'GET' }) .validator(arkTypeValidator(type({ query: 'string', page: 'number > 0' }))) .handler(async ({ data }) => db.items.search(data.query, data.page)) ``` ### Serialization Checks (`strict` mode) Inputs and outputs cross the network boundary, so TypeScript checks they are serializable. This is the default `strict: true`. Opt out per function only when you know the runtime serialization layer can handle the value: ```tsx createServerFn({ strict: false }) // disable input + output checks createServerFn({ strict: { input: false } }) // disable input checks only createServerFn({ strict: { output: false } })// disable output checks only ``` `strict: false` relaxes only the type-level checks - values still need to survive runtime serialization. Prefer the default. ## Calling Patterns Server functions can be called from four main contexts. ### From Route Loaders The most common pattern for data fetching. Loaders are isomorphic (run on both server and client), so wrapping data access in a server function keeps secrets and database logic server-only. ```tsx import { createFileRoute } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' const getPosts = createServerFn().handler(async () => { return db.posts.findMany({ orderBy: { createdAt: 'desc' } }) }) export const Route = createFileRoute('/posts')({ loader: () => getPosts(), component: () => { const posts = Route.useLoaderData() return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul> }, }) ``` ### From Components with useServerFn() The `useServerFn()` hook wraps a server function for use in components, especially with mutations and event handlers: ```tsx import { useServerFn } from '@tanstack/react-start' import { useMutation } from '@tanstack/react-query' const deletePost = createServerFn({ method: 'POST' }) .validator((data: { id: string }) => data) .handler(async ({ data }) => { await db.posts.delete({ where: { id: data.id } }) return { success: true } }) function PostActions({ postId }: { postId: string }) { const deletePostFn = useServerFn(deletePost) const mutation = useMutation({ mutationFn: () => deletePostFn({ data: { id: postId } }), onSuccess: () => { // handle success }, }) return ( <button onClick={() => mutation.mutate()}> Delete Post </button> ) } ``` ### From Other Server Functions Server functions can compose by calling each other directly: ```tsx const getCurrentUser = createServerFn().handler(async () => { const session = await getSession() if (!session) return null return db.users.findById(session.userId) }) const getUserPosts = createServerFn().handler(async () => { const user = await getCurrentUser() if (!user) throw redirect({ to: '/login' }) return db.posts.findMany({ where: { authorId: user.id } }) }) ``` ### From Event Handlers Call server functions directly from click handlers, form submissions, and other events: ```tsx function LikeButton({ postId }: { postId: string }) { const handleLike = async () => { await likePost({ data: { id: postId } }) } return <button onClick={handleLike}>Like</button> } ``` ### Testing / Invoking Directly (curl) A server function's route is a real HTTP endpoint, but a bare `curl` returns an empty `200` and never runs your handler - the handler only fires when the request carries the `x-tsr-serverFn: true` header (the client fetcher sets it automatically). To exercise one directly: ```bash curl -X POST 'http://localhost:3000/_serverFn/<serverFnId>' \ -H 'x-tsr-serverFn: true' \ -H 'content-type: application/json' \ -d '{"data":{"id":"123"}}' ``` Also note a thrown error still returns HTTP **200**; the error is framed in the response body (`$TSR/Error`), not the status code - assert on the body, not the status, in tests and monitoring. Access request/response utilities inside server function handlers via `@tanstack/react-start/server`. ```tsx import { createServerFn } from '@tanstack/react-start' import { getRequest, getRequestHeader, setResponseHeader, setResponseHeaders, setResponseStatus, } from '@tanstack/react-start/server' export const getCachedData = createServerFn({ method: 'GET' }).handler( async () => { // Access the full incoming Request object const request = getRequest() // Read a specific request header const authHeader = getRequestHeader('Authorization') // Set a single response header setResponseHeader('X-Request-Id', crypto.randomUUID()) // Set multiple response headers via a Headers object setResponseHeaders( new Headers({ 'Cache-Control': 'public, max-age=300', 'CDN-Cache-Control': 'max-age=3600, stale-while-revalidate=600', }), ) // Set the HTTP status code setResponseStatus(200) return fetchExpensiveData() }, ) ``` Available utilities: | Utility | Description | |---------|-------------| | `getRequest()` | Access the full `Request` object | | `getRequestHeader(name)` | Read a specific request header | | `setResponseHeader(name, value)` | Set a single response header | | `setResponseHeaders(headers)` | Set multiple response headers via `Headers` object | | `setResponseStatus(code)` | Set the HTTP status code | ### Cache-Control Safety `Cache-Control: public` tells every CDN/proxy between you and the user that the response may be served to anyone. The example above is safe *only* because it returns non-identity data. If a handler reads a session, cookie, or auth header - or branches on identity at all - `public` will cache one user's response and replay it to the next (cross-tenant data leak). ```tsx // Authenticated data - must NOT be 'public' export const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => { const session = await requireSession() setResponseHeaders(new Headers({ 'Cache-Control': 'private, max-age=60', // only the user-agent may cache Vary: 'Cookie, Authorization', // key any cache by identity, not URL alone })) return db.orders.findMany({ where: { userId: session.userId } }) }) // Sensitive data - opt out entirely: // setResponseHeaders(new Headers({ 'Cache-Control': 'no-store' })) ``` ## Error Handling Server functions support standard error throws, redirects, and not-found responses. Errors thrown inside handlers are serialized and sent to the client. ### Error Serialization ```tsx export const riskyOperation = createServerFn({ method: 'POST' }).handler( async () => { const result = await externalApi.call() if (!result.ok) { throw new Error('External API failed') } return result.data }, ) // On the client, the error is deserialized try { await riskyOperation() } catch (error) { console.log(error.message) // "External API failed" } ``` ### Redirects Use `redirect()` from `@tanstack/react-router` to redirect the user. Redirects thrown from server functions are handled automatically when called from route loaders or via `useServerFn()`. ```tsx import { createServerFn } from '@tanstack/react-start' import { redirect } from '@tanstack/react-router' export const requireAuth = createServerFn().handler(async () => { const user = await getCurrentUser() if (!user) { throw redirect({ to: '/login' }) } return user }) // Use in a loader - redirect is handled automatically export const Route = createFileRoute('/dashboard')({ loader: () => requireAuth(), }) ``` ### Not Found Throw `notFound()` for missing resources. This triggers the nearest `notFoundComponent` in the route tree. ```tsx import { createServerFn } from '@tanstack/react-start' import { notFound } from '@tanstack/react-router' export const getPost = createServerFn() .validator((data: { id: string }) => data) .handler(async ({ data }) => { const post = await db.posts.findById(data.id) if (!post) { throw notFound() } return post }) ``` ## Streaming Responses Server functions can stream typed data to the client using `ReadableStream` or async generators. This is particularly useful for AI/chat use cases. Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/streaming-data-from-server-functions ### Typed ReadableStream Return a `ReadableStream<T>` from a server function. The type parameter flows through to the client. ```tsx import { createServerFn } from '@tanstack/react-start' type ChatChunk = { choices: Array<{ delta: { content?: string } index: number finish_reason: string | null }> } const streamChat = createServerFn({ method: 'POST' }) .validator((data: { prompt: string }) => data) .handler(async ({ data }) => { const aiResponse = await callAIProvider(data.prompt) const stream = new ReadableStream<ChatChunk>({ async start(controller) { for await (const chunk of aiResponse) { controller.enqueue(chunk) } controller.close() }, }) return stream }) ``` Consuming the stream on the client with `getReader()`: ```tsx const response = await streamChat({ data: { prompt } }) if (!response) return const reader = response.getReader() let done = false while (!done) { const { value, done: doneReading } = await reader.read() done = doneReading if (value) { // value is typed as ChatChunk const content = value.choices[0].delta.content if (content) setMessage((prev) => prev + content) } } ``` ### Async Generators A cleaner alternative to `ReadableStream`. Use `async function*` as the handler: ```tsx const streamMessages = createServerFn({ method: 'POST' }) .validator((data: { prompt: string }) => data) .handler(async function* ({ data }) { const messages = await generateAIResponse(data.prompt) for (const msg of messages) { await sleep(100) // simulate latency // Yielded values are typed and streamed to the client yield msg } }) ``` The client code is significantly simpler with `for await...of`: ```tsx const handleStream = useCallback(async () => { setMessages('') for await (const msg of await streamMessages({ data: { prompt } })) { const chunk = msg.choices[0].delta.content if (chunk) { setMessages((prev) => prev + chunk) } } }, [prompt]) ``` ## FormData Handling Server functions support native `FormData` as input. Validate and extract fields in the `validator`: ```tsx export const submitContactForm = createServerFn({ method: 'POST' }) .validator((data) => { if (!(data instanceof FormData)) throw new Error('Expected FormData') const name = data.get('name')?.toString() const email = data.get('email')?.toString() const message = data.get('message')?.toString() if (!name || !email || !message) throw new Error('All fields are required') return { name, email, message } }) .handler(async ({ data }) => { await sendEmail({ to: 'support@example.com', subject: `Contact from ${data.name}`, body: data.message }) return { success: true } }) ``` Call from a component with `useServerFn`: ```tsx function ContactForm() { const submitFn = useServerFn(submitContactForm) return ( <form onSubmit={async (e) => { e.preventDefault() await submitFn({ data: new FormData(e.currentTarget) }) }}> <input name="name" required /> <input name="email" type="email" required /> <textarea name="message" required /> <button type="submit">Send</button> </form> ) } ``` ## Execution Model Understanding how server functions execute across environments is critical for building secure TanStack Start applications. Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/execution-model ### Isomorphic by Default All code in TanStack Start is isomorphic by default - included in both server and client bundles unless explicitly constrained. This includes route loaders, which execute on server during SSR AND on client during client-side navigation. Always wrap server-only logic (database access, secrets) in `createServerFn()`. ### Build-Time RPC Replacement The build process replaces server function implementations with RPC stubs in client bundles: 1. You write a server function with `createServerFn().handler()` 2. The compiler extracts the handler to a server-only module 3. The client bundle gets a stub that makes a `fetch` call to the server 4. The server locates the handler using a generated, stable function ID (SHA256 hash by default) ```tsx // What you write: import { getUsers } from './db/queries.server' export const fetchUsers = createServerFn().handler(async () => getUsers()) // What the client build produces (conceptually): export const fetchUsers = createServerFn({ method: 'GET' }).handler( createClientRpc('sha256:abc123...') ) // The server-only import (getUsers) is removed entirely ``` Server functions can be safely statically imported anywhere, including client components. **Avoid dynamic imports** - the build process cannot replace them with RPC stubs. You can customize function ID generation via `serverFns.generateFunctionId` in the TanStack Start Vite plugin config (experimental). ## Environment Functions Environment functions control function execution based on the runtime environment. They complement server functions for cases where you need different implementations per environment rather than RPC calls. Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/environment-functions ```tsx import { createIsomorphicFn, createServerOnlyFn, createClientOnlyFn, } from '@tanstack/react-start' // Different implementations per environment const getDeviceInfo = createIsomorphicFn() .server(() => ({ type: 'server', platform: process.platform })) .client(() => ({ type: 'client', userAgent: navigator.userAgent })) // Server-only - throws on client const getDbUrl = createServerOnlyFn(() => process.env.DATABASE_URL) // Client-only - throws on server const saveToStorage = createClientOnlyFn((key: string, value: string) => { localStorage.setItem(key, value) }) ``` For components that need browser APIs, use `ClientOnly` and `useHydrated` from `@tanstack/react-router`: ```tsx import { ClientOnly, useHydrated } from '@tanstack/react-router' function Analytics() { return ( <ClientOnly fallback={null}> <GoogleAnalyticsScript /> </ClientOnly> ) } function TimeZoneDisplay() { const hydrated = useHydrated() // hydrated: false during SSR and first client render, true after hydration const timeZone = hydrated ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'UTC' return <div>Your timezone: {timeZone}</div> } ``` All environment functions are tree-shaken per bundle: `.client()` code is excluded from server bundles and vice versa. `createClientOnlyFn` and `createServerOnlyFn` are replaced with error-throwing stubs in the wrong environment. ## Import Protection (Experimental) Import protection prevents server-only code from leaking into client bundles and vice versa. It runs as a Vite plugin, enabled by default. In dev mode violations produce a mock (warning); in production builds they fail with an error. Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/import-protection **File conventions** - use `.server.*` / `.client.*` suffixes to restrict files: ``` src/utils/ auth.server.ts # Denied in client bundle analytics.client.ts # Denied in server bundle helpers.ts # Available in both bundles ``` **File markers** - alternatively, use side-effect imports for files that do not follow the naming convention: ```tsx // src/lib/secrets.ts import '@tanstack/react-start/server-only' export const API_KEY = process.env.API_KEY ``` **Custom deny rules** - block specific packages or directories: ```ts // vite.config.ts tanstackStart({ importProtection: { client: { specifiers: ['@prisma/client', 'bcrypt'], files: ['**/db/**'], }, server: { specifiers: ['localforage'], }, }, }) ``` ## Static Server Functions (Experimental) Static server functions execute at build time and cache results as static JSON files when using prerendering/static generation. Apply `staticFunctionMiddleware` (must be the final middleware) from `@tanstack/start-static-server-functions`: ```tsx import { createServerFn } from '@tanstack/react-start' import { staticFunctionMiddleware } from '@tanstack/start-static-server-functions' const getStaticConfig = createServerFn({ method: 'GET' }) .middleware([staticFunctionMiddleware]) .handler(async () => ({ siteName: 'My App', version: '1.0.0' })) ``` At build time the result is cached as a static JSON file. On initial load the data is embedded in prerendered HTML and hydrated. On subsequent client navigations the static JSON file is fetched directly. See: https://tanstack.com/start/latest/docs/framework/react/guide/static-server-functions ## File Organization For larger applications, separate server function definitions from server-only helpers and shared schemas. ``` src/utils/ users.functions.ts # Server function wrappers (createServerFn) - safe to import anywhere users.server.ts # Server-only helpers (DB queries, internal logic) - server-only schemas.ts # Shared validation schemas (Zod/Valibot) - client-safe ``` ### Example Structure ```tsx // schemas.ts - Shared validation (safe for both environments) import { z } from 'zod' export const CreateUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), }) ``` ```tsx // users.server.ts - Server-only database logic import { db } from '~/db' export async function findUserById(id: string) { return db.query.users.findFirst({ where: eq(users.id, id) }) } ``` ```tsx // users.functions.ts - Server function wrappers (safe to import anywhere) import { createServerFn } from '@tanstack/react-start' import { zodValidator } from '@tanstack/zod-adapter' import { findUserById } from './users.server' import { CreateUserSchema } from './schemas' export const getUser = createServerFn({ method: 'GET' }) .validator((data: { id: string }) => data) .handler(async ({ data }) => findUserById(data.id)) export const createUser = createServerFn({ method: 'POST' }) .validator(zodValidator(CreateUserSchema)) .handler(async ({ data }) => db.insert(users).values(data).returning()) ``` ## Middleware Integration Server functions support composable middleware for authentication, logging, and shared logic. Use `createMiddleware({ type: 'function' })` for server-function-specific middleware. ```tsx import { createMiddleware, createServerFn } from '@tanstack/react-start' const authMiddleware = createMiddleware({ type: 'function' }).server( async ({ next }) => { const user = await getCurrentUser() if (!user) throw redirect({ to: '/login' }) return next({ context: { user } }) }, ) // Server function using middleware - context.user is typed export const getUserSettings = createServerFn() .middleware([authMiddleware]) .handler(async ({ context }) => { return db.settings.findByUserId(context.user.id) }) ``` Middleware can also validate input via `validator` and run client-side logic via `.client()`. Apply middleware globally to all server functions through `src/start.ts`: ```tsx // src/start.ts import { createStart } from '@tanstack/react-start' import { loggingMiddleware } from './middleware' export const startInstance = createStart(() => ({ functionMiddleware: [loggingMiddleware], })) ``` See the dedicated Middleware guide for full details: https://tanstack.com/start/latest/docs/framework/react/guide/middleware ## Best Practices 1. **Always use server functions for sensitive operations, and authorize inside them.** Route loaders are isomorphic (run on both server and client). Never access secrets, database connections, or server-only APIs directly in loaders - wrap them in `createServerFn()`. Because a server function is an endpoint reachable independently of any route, enforce auth in the handler or its middleware - `beforeLoad` route guards are UX, not the data boundary. 2. **Validate all input with schemas.** Server functions cross a network boundary. Use Zod, Valibot, or ArkType to validate data at runtime, not just TypeScript types. 3. **Use POST for mutations, GET for reads.** GET server functions can be cached and preloaded by the router. POST is appropriate for any operation that modifies state. 4. **Separate concerns with file conventions.** Use `.functions.ts` for `createServerFn` wrappers, `.server.ts` for database queries and internal helpers, and plain `.ts` for shared types and schemas. 5. **Prefer async generators for streaming.** They produce cleaner code on both server and client compared to manual `ReadableStream` construction. Use `for await...of` on the client side. 6. **Compose with middleware for cross-cutting concerns.** Authentication, authorization, logging, and input validation are ideal candidates for middleware rather than repeated logic inside each handler. 7. **Always use static imports for server functions.** The build process cannot replace dynamic imports with RPC stubs, which leads to bundler issues. 8. **Use import protection file conventions.** Name server-only files with `.server.ts` and client-only files with `.client.ts` to get automatic build-time protection against cross-environment leaks. -
server-routes.md 17.5 KB
# Server Routes (API Routes) Server routes are server-side HTTP endpoints defined alongside your application routes using the same file-based routing conventions as TanStack Router. They handle raw HTTP requests, form submissions, webhooks, and authentication callbacks. Official docs: https://tanstack.com/start/latest/docs/framework/react/guide/server-routes ## Defining Server Routes Add a `server` property to `createFileRoute()` with a `handlers` object mapping HTTP methods to handler functions. Each handler must return a standard Web `Response`. ```ts // src/routes/api/health.ts import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/api/health')({ server: { handlers: { GET: async ({ request }) => { return Response.json({ status: 'ok', timestamp: Date.now() }) }, }, }, }) ``` Supported HTTP methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. ## Co-location with App Routes Server routes and UI routes can share the same file. The `server` property defines API handlers while `component` and other options define the page. ```tsx // src/routes/contact.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/contact')({ server: { handlers: { POST: async ({ request }) => { const body = await request.json() await sendContactEmail(body.email, body.message) return Response.json({ success: true }) }, }, }, component: () => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() const form = new FormData(e.currentTarget) await fetch('/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: form.get('email'), message: form.get('message') }), }) } return ( <form onSubmit={handleSubmit}> <input name="email" type="email" required /> <textarea name="message" required /> <button type="submit">Send</button> </form> ) }, }) ``` ## File Routing Conventions Server routes follow the same file-based routing as TanStack Router. Files with a `server` property in `createFileRoute` become API endpoints. | File path | API endpoint | |-----------|-------------| | `routes/api/users.ts` | `/api/users` | | `routes/api/users/$id.ts` | `/api/users/$id` | | `routes/api/users.$id.posts.ts` | `/api/users/$id/posts` | | `routes/api/file/$.ts` | `/api/file/$` (wildcard) | | `routes/sitemap[.]xml.ts` | `/sitemap.xml` (escaped dot) | Each route path must resolve to a single handler file. Flat file names and nested directories can be mixed freely - `routes/api/users.$id.ts` and `routes/api/users/$id.ts` produce the same endpoint. ## Handler Context Every handler receives an object with three properties: - **`request`** - Standard Web `Request` object. Use `request.json()`, `request.text()`, `request.formData()`, `request.headers`, `request.url`. - **`params`** - Typed path parameters. For `/api/users/$id/posts/$postId`, params is `{ id: string, postId: string }`. - **`context`** - Data passed from middleware via `next({ context: { ... } })`. Empty by default. ```ts export const Route = createFileRoute('/api/users/$id')({ server: { handlers: { GET: async ({ request, params, context }) => { return Response.json({ userId: params.id, url: request.url }) }, }, }, }) ``` ## Handler Definition ### Simple Handlers Object Pass handler functions directly for straightforward use cases: ```ts export const Route = createFileRoute('/api/posts')({ server: { handlers: { GET: async ({ request }) => { const url = new URL(request.url) const page = Number(url.searchParams.get('page') || '1') return Response.json(await fetchPosts(page)) }, POST: async ({ request }) => { const body = await request.json() return Response.json(await createPost(body), { status: 201 }) }, }, }, }) ``` ### createHandlers for Per-Handler Middleware Use `createHandlers` to attach middleware to individual HTTP methods: ```ts export const Route = createFileRoute('/api/admin/settings')({ server: { handlers: ({ createHandlers }) => createHandlers({ GET: { middleware: [adminMiddleware], handler: async ({ context }) => Response.json(context.settings), }, PUT: { middleware: [adminMiddleware, validationMiddleware], handler: async ({ request }) => { const body = await request.json() return Response.json(await updateSettings(body)) }, }, }), }, }) ``` ### Route-Level and Combined Middleware Apply middleware to all handlers with the top-level `middleware` property. Route-level middleware runs first, then handler-specific middleware: ```ts export const Route = createFileRoute('/api/documents')({ server: { middleware: [authMiddleware], // Runs for every handler handlers: ({ createHandlers }) => createHandlers({ GET: async ({ context }) => { return Response.json(await getDocuments(context.user.id)) }, POST: { middleware: [rateLimitMiddleware], // Runs after authMiddleware, POST only handler: async ({ request, context }) => { const body = await request.json() return Response.json(await createDocument(context.user.id, body), { status: 201 }) }, }, }), }, }) ``` ## Dynamic Path Params ### Single and Multiple Parameters ```ts // src/routes/api/users/$userId/posts/$postId.ts export const Route = createFileRoute('/api/users/$userId/posts/$postId')({ server: { handlers: { GET: async ({ params }) => { const { userId, postId } = params const post = await findPost(userId, postId) if (!post) return Response.json({ error: 'Not found' }, { status: 404 }) return Response.json(post) }, }, }, }) ``` ### Wildcard (Splat) Parameter A trailing `$` with no name captures the remaining path as `_splat`: ```ts // src/routes/api/files/$.ts export const Route = createFileRoute('/api/files/$')({ server: { handlers: { GET: async ({ params }) => { const filePath = params._splat // e.g. "documents/report.pdf" const file = await readFile(filePath) return new Response(file, { headers: { 'Content-Type': getMimeType(filePath) }, }) }, }, }, }) // GET /api/files/documents/report.pdf -> _splat = "documents/report.pdf" ``` ## Pathless Layout Routes Pathless layout routes (prefixed with `_`) group server routes under shared middleware without adding a URL segment: ``` src/routes/api/ _authenticated.ts # Middleware-only, no path segment _authenticated/ users.ts # /api/users (requires auth) posts.ts # /api/posts (requires auth) public/ health.ts # /api/public/health (no auth) ``` ```ts // src/routes/api/_authenticated.ts import { createFileRoute } from '@tanstack/react-router' import { authMiddleware } from '~/middleware/auth' export const Route = createFileRoute('/api/_authenticated')({ server: { middleware: [authMiddleware] }, }) ``` All routes inside `_authenticated/` inherit the auth middleware. Break-out routes can escape parent middleware. ## Response Handling Handlers return standard Web `Response` objects: ```ts // JSON (preferred) - sets Content-Type automatically return Response.json({ message: 'Hello' }) return Response.json(created, { status: 201 }) return Response.json({ error: 'Not found' }, { status: 404 }) // Plain text return new Response('Hello, World!', { headers: { 'Content-Type': 'text/plain' } }) // Custom headers return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' }, }) // Redirect return new Response(null, { status: 302, headers: { Location: '/new-location' } }) ``` ### Streaming Response ```ts export const Route = createFileRoute('/api/events')({ server: { handlers: { GET: async () => { const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder() for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 1000)) controller.enqueue(encoder.encode(`data: ${JSON.stringify({ count: i })}\n\n`)) } controller.close() }, }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, }) }, }, }, }) ``` ## Database Integration Server routes run entirely on the server, giving direct access to databases and environment variables. ### With Neon (Serverless PostgreSQL) ```ts import { createFileRoute } from '@tanstack/react-router' import { neon } from '@neondatabase/serverless' const sql = neon(process.env.DATABASE_URL!) export const Route = createFileRoute('/api/products')({ server: { handlers: { GET: async () => { const products = await sql`SELECT id, name, price FROM products WHERE active = true LIMIT 50` return Response.json(products) }, POST: async ({ request }) => { const { name, price } = await request.json() const [product] = await sql`INSERT INTO products (name, price) VALUES (${name}, ${price}) RETURNING *` return Response.json(product, { status: 201 }) }, }, }, }) ``` ### With Prisma / Drizzle The same pattern works with any ORM - import your client and call it from handlers: ```ts // Prisma const users = await prisma.user.findMany({ skip: (page - 1) * 20, take: 20 }) // Drizzle const allPosts = await db.select().from(posts).orderBy(desc(posts.createdAt)).limit(25) ``` ## Authentication in Server Routes ### Auth Middleware ```ts // src/middleware/auth.ts import { createMiddleware } from '@tanstack/react-start' import { useAppSession } from '~/utils/session' export const authMiddleware = createMiddleware().server(async ({ next }) => { const session = await useAppSession() if (!session.data.userId) { return Response.json({ error: 'Unauthorized' }, { status: 401 }) } const user = await getUserById(session.data.userId) if (!user) return Response.json({ error: 'User not found' }, { status: 401 }) return next({ context: { user } }) }) ``` ### Role-Based Authorization ```ts // src/middleware/roles.ts import { createMiddleware } from '@tanstack/react-start' export function requireRole(role: string) { return createMiddleware().server(async ({ next, context }) => { if (context.user?.role !== role) { return Response.json({ error: 'Forbidden' }, { status: 403 }) } return next() }) } ``` ```ts export const Route = createFileRoute('/api/admin/users')({ server: { middleware: [authMiddleware], handlers: ({ createHandlers }) => createHandlers({ GET: { middleware: [requireRole('admin')], handler: async () => Response.json(await getAllUsers()), }, }), }, }) ``` ## Complete CRUD Example ```ts // src/routes/api/tasks.ts import { createFileRoute } from '@tanstack/react-router' import { authMiddleware } from '~/middleware/auth' import { prisma } from '~/lib/prisma' export const Route = createFileRoute('/api/tasks')({ server: { middleware: [authMiddleware], handlers: { GET: async ({ request, context }) => { const url = new URL(request.url) const status = url.searchParams.get('status') const where: Record<string, unknown> = { userId: context.user.id } if (status) where.status = status const tasks = await prisma.task.findMany({ where, orderBy: { createdAt: 'desc' } }) return Response.json(tasks) }, POST: async ({ request, context }) => { const body = await request.json() if (!body.title || typeof body.title !== 'string') { return Response.json({ error: 'Title is required' }, { status: 400 }) } const task = await prisma.task.create({ data: { title: body.title, description: body.description || null, status: 'pending', userId: context.user.id }, }) return Response.json(task, { status: 201 }) }, }, }, }) ``` ```ts // src/routes/api/tasks/$taskId.ts import { createFileRoute } from '@tanstack/react-router' import { authMiddleware } from '~/middleware/auth' import { prisma } from '~/lib/prisma' export const Route = createFileRoute('/api/tasks/$taskId')({ server: { middleware: [authMiddleware], handlers: { GET: async ({ params, context }) => { const task = await prisma.task.findFirst({ where: { id: params.taskId, userId: context.user.id } }) if (!task) return Response.json({ error: 'Not found' }, { status: 404 }) return Response.json(task) }, PUT: async ({ request, params, context }) => { const existing = await prisma.task.findFirst({ where: { id: params.taskId, userId: context.user.id } }) if (!existing) return Response.json({ error: 'Not found' }, { status: 404 }) const body = await request.json() const updated = await prisma.task.update({ where: { id: params.taskId }, data: { title: body.title ?? existing.title, description: body.description ?? existing.description, status: body.status ?? existing.status }, }) return Response.json(updated) }, DELETE: async ({ params, context }) => { const existing = await prisma.task.findFirst({ where: { id: params.taskId, userId: context.user.id } }) if (!existing) return Response.json({ error: 'Not found' }, { status: 404 }) await prisma.task.delete({ where: { id: params.taskId } }) return Response.json({ deleted: true }) }, }, }, }) ``` ## Head Management Routes define a `head()` function for SEO metadata. Use `HeadContent` and `Scripts` in the root layout to render tags into the document. ### Root Layout Setup ```tsx // src/routes/__root.tsx import { HeadContent, Outlet, Scripts, createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute({ head: () => ({ meta: [{ charSet: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }], links: [{ rel: 'icon', href: '/favicon.ico' }], }), component: () => ( <html> <head><HeadContent /></head> <body><Outlet /><Scripts /></body> </html> ), }) ``` ### Dynamic Meta Tags ```tsx export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => ({ post: await fetchPost(params.postId) }), head: ({ loaderData }) => ({ meta: [ { title: loaderData.post.title }, { name: 'description', content: loaderData.post.excerpt }, { property: 'og:title', content: loaderData.post.title }, { property: 'og:image', content: loaderData.post.coverImage }, { name: 'twitter:card', content: 'summary_large_image' }, ], scripts: [{ type: 'application/ld+json', children: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: loaderData.post.title, image: loaderData.post.coverImage, datePublished: loaderData.post.publishedAt, }), }], }), component: PostPage, }) ``` ## Global Middleware via start.ts Register middleware that runs on every server request (SSR, server functions, and server routes): ```ts // src/start.ts import { createStart, createMiddleware } from '@tanstack/react-start' const loggingMiddleware = createMiddleware().server(async ({ next, request }) => { const start = Date.now() const result = await next() console.log(`${request.method} ${request.url} - ${Date.now() - start}ms`) return result }) export const startInstance = createStart(() => ({ requestMiddleware: [loggingMiddleware], })) ``` ## Best Practices 1. **Use `Response.json()` over manual serialization** - Sets Content-Type automatically and avoids forgetting to stringify. 2. **Validate request bodies before processing** - Check required fields and types before database calls. Return 400 with descriptive error messages. 3. **Scope server routes under `/api` by convention** - Placing API routes under `src/routes/api/` makes the boundary between UI and API endpoints clear. 4. **Use middleware for cross-cutting concerns** - Extract auth, logging, rate limiting into reusable middleware rather than duplicating in each handler. 5. **Return appropriate HTTP status codes** - 201 for creation, 404 for not found, 401 for unauthenticated, 403 for forbidden, 400 for bad input. 6. **Prefer pathless layout routes for auth boundaries** - Group protected endpoints under a pathless layout with auth middleware instead of repeating it per route. 7. **Keep handlers thin** - Move business logic to service modules. Handlers should parse input, call services, and format responses. ## References - Server Routes: https://tanstack.com/start/latest/docs/framework/react/guide/server-routes - Middleware: https://tanstack.com/start/latest/docs/framework/react/guide/middleware - SEO: https://tanstack.com/start/latest/docs/framework/react/guide/seo - Databases: https://tanstack.com/start/latest/docs/framework/react/guide/databases - Authentication: https://tanstack.com/start/latest/docs/framework/react/guide/authentication - File-based routing: https://tanstack.com/router/latest/docs/framework/react/routing/file-based-routing - MDN Request API: https://developer.mozilla.org/en-US/docs/Web/API/Request - MDN Response API: https://developer.mozilla.org/en-US/docs/Web/API/Response -
ssr-modes.md 32.5 KB
# SSR Modes and Rendering Strategies TanStack Start provides granular control over server-side rendering at the application, route, and even request level. This reference covers every rendering mode, deployment target, and caching strategy. ## Full-Document SSR (Default) SSR is enabled by default. On the initial request, TanStack Start renders all matched route components on the server, sends complete HTML to the client, and hydrates it into an interactive application. The default SSR flow: 1. Server receives a request and matches routes 2. `beforeLoad` and `loader` execute on the server for all matched routes 3. Components render to HTML on the server 4. HTML is sent to the client along with serialized loader data 5. Client hydrates the markup into a fully interactive React application ```tsx // src/routes/posts/$postId.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/posts/$postId')({ // ssr defaults to true - no configuration needed beforeLoad: () => { console.log('Runs on the server during initial request') console.log('Runs on the client for subsequent navigation') }, loader: async ({ params }) => { const post = await fetchPost(params.postId) return { post } }, component: PostPage, }) function PostPage() { const { post } = Route.useLoaderData() return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> </article> ) } ``` After hydration, subsequent navigations run `beforeLoad` and `loader` on the client. Route loaders are isomorphic - they execute on the server during SSR and on the client during client-side navigation. ## Streaming SSR TanStack Start uses streaming by default for SSR responses. The server streams HTML progressively to the client as components render. Combined with React Suspense boundaries, this enables progressive page loading where above-the-fold content arrives first while deferred data loads asynchronously. Streaming works with server functions that return `ReadableStream` or use async generators: ```tsx import { createServerFn } from '@tanstack/react-start' type Message = { content: string } // Using ReadableStream const streamMessages = createServerFn().handler(async () => { const messages: Message[] = await getMessages() const stream = new ReadableStream<Message>({ async start(controller) { for (const message of messages) { controller.enqueue(message) } controller.close() }, }) return stream }) // Using async generators (cleaner approach) const streamMessagesGenerator = createServerFn().handler(async function* () { const messages: Message[] = await getMessages() for (const msg of messages) { yield msg } }) ``` Client consumption with typed streaming: ```tsx function MessageFeed() { const [messages, setMessages] = useState('') const loadMessages = useCallback(async () => { for await (const msg of await streamMessagesGenerator()) { // msg is typed as Message setMessages((prev) => prev + msg.content) } }, []) return ( <div> <button onClick={loadMessages}>Load Messages</button> <pre>{messages}</pre> </div> ) } ``` The custom server handler controls streaming behavior through `src/server.ts`: ```tsx // src/server.ts import { createStartHandler, defaultStreamHandler, defineHandlerCallback, } from '@tanstack/react-start/server' import { createServerEntry } from '@tanstack/react-start/server-entry' const customHandler = defineHandlerCallback((ctx) => { // Add custom logic before streaming return defaultStreamHandler(ctx) }) const fetch = createStartHandler(customHandler) export default createServerEntry({ fetch, }) ``` ## Per-Route SSR Control (Selective SSR) The `ssr` property on a route controls server-side behavior during the initial request. There are three values and a functional form. ### ssr: true (Default) Server runs `beforeLoad` and `loader`, renders the component, and sends full HTML. ```tsx export const Route = createFileRoute('/posts/$postId')({ ssr: true, beforeLoad: () => { console.log('Executes on the server during the initial request') console.log('Executes on the client for subsequent navigation') }, loader: () => { console.log('Executes on the server during the initial request') console.log('Executes on the client for subsequent navigation') }, component: () => <div>This component is rendered on the server</div>, }) ``` ### ssr: false (Client-Only Rendering) Disables server-side execution of `beforeLoad`, `loader`, and component rendering entirely. Everything runs on the client during hydration. ```tsx export const Route = createFileRoute('/dashboard')({ ssr: false, beforeLoad: () => { // Uses browser-only API safely const token = localStorage.getItem('auth_token') if (!token) throw redirect({ to: '/login' }) }, loader: () => { console.log('Executes on the client during hydration') }, component: () => <div>This component is rendered on the client</div>, }) ``` Use `ssr: false` when: - `beforeLoad` or `loader` depends on browser-only APIs (e.g., `localStorage`, `canvas`) - The route component depends on browser-only APIs - SEO is not important for this route **Hydration mismatches from client-only providers.** Providers whose initial render differs between server and client (wallet/web3 hooks, theme from `localStorage`, anything reading `window`) throw hydration-mismatch errors under SSR. Set `ssr: false` on the routes that need them, and/or wrap the client-only subtree in `<ClientOnly fallback={null}>` (from `@tanstack/react-router`) so it renders only after hydration. ### ssr: 'data-only' (Server Data, Client Rendering) A hybrid option. The server runs `beforeLoad` and `loader` and sends the data to the client, but the component renders only on the client. ```tsx export const Route = createFileRoute('/charts')({ ssr: 'data-only', beforeLoad: () => { console.log('Executes on the server during the initial request') }, loader: async () => { // Data fetched on server, but component using canvas renders on client return await fetchChartData() }, component: () => { const data = Route.useLoaderData() // Canvas-based chart that requires browser APIs return <CanvasChart data={data} /> }, }) ``` ### Functional Form (Runtime Decisions) For dynamic SSR decisions based on route params or search params: ```tsx import { z } from 'zod' export const Route = createFileRoute('/docs/$docType/$docId')({ validateSearch: z.object({ details: z.boolean().optional() }), ssr: ({ params, search }) => { // Disable SSR for sheet-type documents if (params.status === 'success' && params.value.docType === 'sheet') { return false } // Use data-only for detail views if (search.status === 'success' && search.value.details) { return 'data-only' } // Default: full SSR (return undefined or true) }, loader: async ({ params }) => { return await fetchDoc(params.docId) }, component: DocViewer, }) ``` The `ssr` function runs only on the server during the initial request and is stripped from the client bundle. The `search` and `params` arguments are passed as discriminated unions after validation: ```tsx params: | { status: 'success'; value: ResolvedParams } | { status: 'error'; error: unknown } search: | { status: 'success'; value: ResolvedSearch } | { status: 'error'; error: unknown } ``` ### Changing the Default SSR Mode You can change the default for all routes via `createStart`: ```tsx // src/start.ts import { createStart } from '@tanstack/react-start' export const startInstance = createStart(() => ({ defaultSsr: false, // All routes default to client-only })) ``` ## Inheritance Rules Child routes inherit the SSR configuration of their parent, but children can only be MORE restrictive, never less. The restrictiveness order is: `true` > `'data-only'` > `false`. ``` Allowed transitions (parent -> child): true -> true, 'data-only', false 'data-only' -> 'data-only', false false -> false ``` Example hierarchy: ``` root { ssr: undefined } -> resolves to true (default) posts { ssr: 'data-only' } -> data-only (more restrictive than parent) $postId { ssr: true } -> stays data-only (cannot become LESS restrictive) details { ssr: false } -> false (more restrictive than data-only) ``` Another example: ``` root { ssr: undefined } -> true posts { ssr: false } -> false $postId { ssr: true } -> stays false (cannot override parent restriction) ``` ## Fallback Rendering When the server encounters the first route with `ssr: false` or `ssr: 'data-only'`, it renders a fallback instead of the route component. ### pendingComponent Fallback The route's `pendingComponent` is rendered as the fallback. If not configured, `defaultPendingComponent` is used. If neither exists, no fallback is rendered. ```tsx export const Route = createFileRoute('/dashboard')({ ssr: false, pendingComponent: () => ( <div className="flex items-center justify-center h-full"> <Spinner /> <span>Loading dashboard...</span> </div> ), loader: async () => await fetchDashboardData(), component: Dashboard, }) ``` On the client during hydration, this fallback is displayed for at least `minPendingMs` (or `defaultPendingMinMs` if not configured on the route), even if there is no `beforeLoad` or `loader`. ### shellComponent for Root Route When disabling SSR on the root route, the `<html>` shell still needs to be rendered on the server. Use `shellComponent` for this: ```tsx import * as React from 'react' import { HeadContent, Outlet, Scripts, createRootRoute, } from '@tanstack/react-router' export const Route = createRootRoute({ shellComponent: RootShell, component: RootComponent, errorComponent: () => <div>Error</div>, notFoundComponent: () => <div>Not found</div>, ssr: false, }) function RootShell({ children }: { children: React.ReactNode }) { return ( <html> <head> <HeadContent /> </head> <body> {children} <Scripts /> </body> </html> ) } function RootComponent() { return ( <div> <h1>This component will be rendered on the client</h1> <Outlet /> </div> ) } ``` The `shellComponent` is always SSR-rendered and wraps around the root `component`, `errorComponent`, or `notFoundComponent`. ## SPA Mode SPA mode completely disables server-side rendering for all routes. The build produces a static HTML shell served from a CDN, and JavaScript takes over entirely on the client. ### Enabling SPA Mode ```tsx // vite.config.ts import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' export default defineConfig({ plugins: [ tanstackStart({ spa: { enabled: true, }, }), viteReact(), ], }) ``` ### How SPA Mode Works 1. At build time, the root route is prerendered with matched routes replaced by the pending fallback component 2. The resulting HTML is stored as `/_shell.html` (configurable) 3. Default rewrites redirect all 404 requests to the SPA shell 4. On the client, JavaScript hydrates and takes over routing Server functions and server routes still work - they are served as API endpoints. SPA mode only disables SSR of the HTML document. ### Benefits and Tradeoffs Benefits: - Easier to deploy - a CDN serving static assets is all you need - Cheaper to host - CDNs are inexpensive compared to Lambda functions or persistent servers - Simpler - no hydration mismatches to debug Caveats: - Slower time to full content - all JS must download and execute before rendering - Less SEO friendly - crawlers may not execute JavaScript or may time out ### SPA Redirect Configuration Deploy targets need redirects so that all URLs serve the shell. Example for Netlify `_redirects`: ``` # Allow server functions to pass through /_serverFn/* /_serverFn/:splat 200 # Allow API server routes to pass through /api/* /api/:splat 200 # Rewrite everything else to the SPA shell /* /_shell.html 200 ``` ### SPA Shell Customization Use `router.isShell()` to detect shell rendering: ```tsx // src/routes/__root.tsx function RootComponent() { const isShell = useRouter().isShell() if (isShell) { return <AppShellSkeleton /> } return ( <div> <Navigation /> <Outlet /> </div> ) } ``` After hydration, `isShell()` returns `false` once the router navigates to the actual route. ### SPA Shell with Dynamic Data The root route's `loader` runs during prerendering, so its data is baked into the shell: ```tsx export const Route = createRootRoute({ loader: async () => { return { appName: 'My Application', version: '2.0' } }, component: Root, }) function Root() { const { appName, version } = Route.useLoaderData() return ( <html> <head> <title>{appName}</title> </head> <body> <Outlet /> </body> </html> ) } ``` ### SPA Prerender Options Configure prerendering behavior for the SPA shell: ```tsx // vite.config.ts export default defineConfig({ plugins: [ tanstackStart({ spa: { enabled: true, maskPath: '/', // Pathname used to generate the shell (default: '/') prerender: { outputPath: '/_shell.html', // Default crawlLinks: false, // Default: false for SPA retryCount: 0, // Default: 0 for SPA }, }, }), viteReact(), ], }) ``` ## Static Prerendering (SSG) Static prerendering generates HTML files at build time. Pages are served as static files without on-the-fly server rendering. ### Basic Configuration ```tsx // vite.config.ts import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' export default defineConfig({ plugins: [ tanstackStart({ prerender: { enabled: true, crawlLinks: true, }, }), viteReact(), ], }) ``` ### Full Prerender Options ```tsx // vite.config.ts export default defineConfig({ plugins: [ tanstackStart({ prerender: { // Enable prerendering enabled: true, // Generate /page/index.html instead of /page.html autoSubfolderIndex: true, // Auto-discover static routes from the route tree // When disabled, only root and explicitly listed pages are prerendered autoStaticPathsDiscovery: true, // Number of concurrent prerender jobs concurrency: 14, // Extract links from rendered HTML and prerender those pages too crawlLinks: true, // Filter which pages to prerender filter: ({ path }) => !path.startsWith('/admin'), // Retry count for failed prerender jobs retryCount: 2, // Delay between retries in milliseconds retryDelay: 1000, // Maximum redirects to follow during prerendering maxRedirects: 5, // Abort the build if any prerender fails failOnError: true, // Callback on successful render onSuccess: ({ page }) => { console.log(`Prerendered: ${page.path}`) }, }, // Explicit page configuration (merged with auto-discovered routes) pages: [ { path: '/landing', prerender: { enabled: true, outputPath: '/landing/index.html' }, }, ], }), viteReact(), ], }) ``` ### Automatic Static Route Discovery Static routes are discovered automatically when `autoStaticPathsDiscovery` is enabled (the default). Routes excluded from auto-discovery: - Routes with path parameters (e.g., `/users/$userId`) - they require specific parameter values - Layout routes (prefixed with `_`) - they do not render standalone pages - Routes without components (e.g., API routes) Dynamic routes can still be prerendered if linked from other pages when `crawlLinks` is enabled. ### Link Crawling When `crawlLinks: true`, TanStack Start extracts links from each prerendered page and prerenders those linked pages too. For example, if `/` contains a `<Link to="/posts">`, then `/posts` is also prerendered. ## Incremental Static Regeneration (ISR) TanStack Start implements ISR through standard HTTP `Cache-Control` headers rather than proprietary APIs. This works with any CDN that respects cache headers. ### How ISR Works 1. Pages are prerendered at build time (static prerendering) 2. CDN caches the HTML based on `Cache-Control` headers 3. After cache expires, the next request triggers server-side regeneration 4. `stale-while-revalidate` serves stale content while fresh content generates in the background ### Route-Level Cache Headers ```tsx // src/routes/blog/posts/$postId.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/blog/posts/$postId')({ loader: async ({ params }) => { const post = await fetchPost(params.postId) return { post } }, headers: () => ({ // Cache at CDN for 1 hour, serve stale for up to 1 day while revalidating 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400', }), component: BlogPost, }) function BlogPost() { const { post } = Route.useLoaderData() return ( <article> <h1>{post.title}</h1> <div>{post.content}</div> </article> ) } ``` ### Cache-Control Directives | Directive | Purpose | |-----------|---------| | `public` | Response can be cached by any cache (CDN, browser) | | `private` | Only the browser can cache (not CDN) | | `max-age=N` | Content is fresh for N seconds in the browser | | `s-maxage=N` | Overrides max-age for shared/CDN caches | | `stale-while-revalidate=N` | Serve stale content for up to N seconds while fetching fresh content in the background | | `immutable` | Content never changes (for hashed assets) | ### ISR with Prerendering Combine build-time prerendering with runtime ISR: ```tsx // vite.config.ts export default defineConfig({ plugins: [ tanstackStart({ prerender: { enabled: true, crawlLinks: true, }, }), viteReact(), ], }) ``` ```tsx // src/routes/products/$productId.tsx export const Route = createFileRoute('/products/$productId')({ loader: async ({ params }) => { return await fetchProduct(params.productId) }, // CDN caching (ISR) headers: () => ({ 'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600', }), // Client-side caching (TanStack Router) staleTime: 30_000, gcTime: 5 * 60_000, component: ProductPage, }) ``` This creates a multi-tier caching strategy: 1. CDN edge: 5-minute cache, stale-while-revalidate for 1 hour 2. Client memory: 30 seconds fresh, 5 minutes in cache ### On-Demand Revalidation (CDN Purge) For immediate invalidation when content changes, purge the CDN cache via API: ```tsx // src/routes/api/revalidate.ts import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/api/revalidate')({ server: { handlers: { POST: async ({ request }) => { const { path, secret } = await request.json() if (secret !== process.env.REVALIDATE_SECRET) { return Response.json({ error: 'Invalid token' }, { status: 401 }) } // Purge via Cloudflare API (adapt for your CDN) await fetch( `https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/purge_cache`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.CF_API_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ files: [`https://yoursite.com${path}`], }), }, ) return Response.json({ revalidated: true }) }, }, }, }) ``` ### CDN-Specific Cache Headers ```tsx // Cloudflare Workers export const Route = createFileRoute('/products/$id')({ headers: () => ({ 'Cache-Control': 'public, max-age=3600', 'CDN-Cache-Control': 'max-age=7200', // Cloudflare-specific override }), }) ``` Netlify supports `_headers` files: ``` # public/_headers /blog/* Cache-Control: public, max-age=3600, stale-while-revalidate=86400 /api/* Cache-Control: public, max-age=300 ``` ### Common ISR Patterns Blog posts (infrequent updates): ```tsx export const Route = createFileRoute('/blog/$slug')({ loader: async ({ params }) => fetchPost(params.slug), headers: () => ({ 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=604800', }), staleTime: 5 * 60_000, }) ``` E-commerce product pages (inventory changes): ```tsx export const Route = createFileRoute('/products/$id')({ loader: async ({ params }) => fetchProduct(params.id), headers: () => ({ 'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600', }), staleTime: 30_000, }) ``` User-specific pages (no CDN caching): ```tsx export const Route = createFileRoute('/dashboard')({ loader: async () => fetchUserData(), headers: () => ({ 'Cache-Control': 'private, max-age=60', }), staleTime: 30_000, }) ``` ## Deployment Targets ### Cloudflare Workers (Official Partner) Install dependencies: ```bash pnpm add -D @cloudflare/vite-plugin wrangler ``` Vite configuration: ```tsx // vite.config.ts import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { cloudflare } from '@cloudflare/vite-plugin' import viteReact from '@vitejs/plugin-react' export default defineConfig({ plugins: [ cloudflare({ viteEnvironment: { name: 'ssr' } }), tanstackStart(), viteReact(), ], }) ``` Wrangler config (`wrangler.jsonc`): ```json { "$schema": "node_modules/wrangler/config-schema.json", "name": "tanstack-start-app", "compatibility_date": "2025-09-02", "compatibility_flags": ["nodejs_compat"], "main": "@tanstack/react-start/server-entry" } ``` Package scripts: ```json { "scripts": { "dev": "vite dev", "build": "vite build && tsc --noEmit", "preview": "vite preview", "deploy": "npm run build && wrangler deploy" } } ``` ### Netlify (Official Partner) Install the plugin: ```bash pnpm add -D @netlify/vite-plugin-tanstack-start ``` Vite configuration: ```tsx // vite.config.ts import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import netlify from '@netlify/vite-plugin-tanstack-start' import viteReact from '@vitejs/plugin-react' export default defineConfig({ plugins: [ tanstackStart(), netlify(), viteReact(), ], }) ``` Deploy: ```bash npx netlify deploy ``` Optional `netlify.toml`: ```toml [build] command = "vite build" publish = "dist/client" [dev] command = "vite dev" port = 3000 ``` ### Railway (Official Partner) Railway provides zero-config deployment. Follow the Nitro setup, then: 1. Push code to a GitHub repository 2. Connect the repository at railway.com 3. Railway auto-detects build settings and deploys Railway provides automatic deployments on push, built-in databases, preview environments for PRs, automatic HTTPS, and custom domains. ### Nitro (Multi-Target) Nitro is an agnostic deployment layer supporting a wide range of hosting providers. Install nitro nightly in `package.json`: ```json { "dependencies": { "nitro": "npm:nitro-nightly@latest" } } ``` Vite configuration: ```tsx // vite.config.ts import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { nitro } from 'nitro/vite' import viteReact from '@vitejs/plugin-react' export default defineConfig({ plugins: [tanstackStart(), nitro(), viteReact()], }) ``` #### FastResponse for Node.js When deploying to Node.js with Nitro, replace the global `Response` with srvx's optimized `FastResponse` for approximately 5% throughput improvement: ```bash npm install srvx ``` ```tsx // src/server.ts import { FastResponse } from 'srvx' globalThis.Response = FastResponse import handler, { createServerEntry } from '@tanstack/react-start/server-entry' export default createServerEntry({ fetch(request) { return handler.fetch(request) }, }) ``` ### Vercel Follow the Nitro setup. Deploy via Vercel's one-click deployment or CLI. ### Node.js / Docker Follow the Nitro setup. Package scripts: ```json { "scripts": { "build": "vite build", "start": "node .output/server/index.mjs" } } ``` ### Bun Requires React 19+. Follow the Nitro setup with the `bun` preset: ```tsx // vite.config.ts import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { nitro } from 'nitro/vite' import viteReact from '@vitejs/plugin-react' export default defineConfig({ plugins: [tanstackStart(), nitro({ preset: 'bun' }), viteReact()], }) ``` Build and run: ```bash bun run build bun run .output/server/index.mjs ``` ## Entry Points Both entry points are optional. TanStack Start provides defaults if they are not present. ### Server Entry Point (src/server.ts) The server entry point uses the universal fetch handler format (WinterCG-compatible). Default implementation: ```tsx // src/server.ts import handler, { createServerEntry } from '@tanstack/react-start/server-entry' export default createServerEntry({ fetch(request) { return handler.fetch(request) }, }) ``` Custom server handler with additional logic: ```tsx // src/server.ts import { createStartHandler, defaultStreamHandler, defineHandlerCallback, } from '@tanstack/react-start/server' import { createServerEntry } from '@tanstack/react-start/server-entry' const customHandler = defineHandlerCallback((ctx) => { // Custom logic before rendering return defaultStreamHandler(ctx) }) const fetch = createStartHandler(customHandler) export default createServerEntry({ fetch, }) ``` #### Typed Request Context Pass typed data through the request lifecycle via module augmentation: ```tsx // src/server.ts import handler, { createServerEntry } from '@tanstack/react-start/server-entry' type MyRequestContext = { hello: string foo: number } declare module '@tanstack/react-start' { interface Register { server: { requestContext: MyRequestContext } } } export default createServerEntry({ async fetch(request) { return handler.fetch(request, { context: { hello: 'world', foo: 123 } }) }, }) ``` The registered context is available throughout global middleware, request/function middleware, server routes, server functions, and the router. ### Client Entry Point (src/client.tsx) Hydrates the server-rendered HTML into an interactive application. Default implementation: ```tsx // src/client.tsx import { StartClient } from '@tanstack/react-start/client' import { StrictMode } from 'react' import { hydrateRoot } from 'react-dom/client' hydrateRoot( document, <StrictMode> <StartClient /> </StrictMode>, ) ``` With error boundary: ```tsx // src/client.tsx import { StartClient } from '@tanstack/react-start/client' import { StrictMode } from 'react' import { hydrateRoot } from 'react-dom/client' import { ErrorBoundary } from './components/ErrorBoundary' hydrateRoot( document, <StrictMode> <ErrorBoundary> <StartClient /> </ErrorBoundary> </StrictMode>, ) ``` ## Environment Variables ### Client vs Server Access | Prefix | Accessible Where | Access Pattern | |--------|-----------------|----------------| | `VITE_` | Client + Server | `import.meta.env.VITE_*` | | No prefix | Server only | `process.env.*` | ```tsx // Server function - access any variable const getUser = createServerFn().handler(async () => { const db = await connect(process.env.DATABASE_URL) // Server-only, no prefix return db.user.findFirst() }) // Client component - only VITE_ prefixed variables function AppHeader() { return <h1>{import.meta.env.VITE_APP_NAME}</h1> } ``` ### .env File Hierarchy Files are loaded in this order (later files override earlier ones): ``` .env # Default variables (commit to git) .env.development # Development-specific .env.production # Production-specific .env.local # Local overrides (add to .gitignore) ``` ### Type Safety for Environment Variables ```tsx // src/env.d.ts /// <reference types="vite/client" /> interface ImportMetaEnv { readonly VITE_APP_NAME: string readonly VITE_API_URL: string } interface ImportMeta { readonly env: ImportMetaEnv } declare global { namespace NodeJS { interface ProcessEnv { readonly DATABASE_URL: string readonly JWT_SECRET: string } } } export {} ``` ### Runtime Client Environment Variables `VITE_` variables are replaced at build time. For runtime variables on the client, pass them from the server via a loader: ```tsx const getRuntimeVar = createServerFn({ method: 'GET' }).handler(() => { return process.env.MY_RUNTIME_VAR }) export const Route = createFileRoute('/')({ loader: async () => { const runtimeValue = await getRuntimeVar() return { runtimeValue } }, component: RouteComponent, }) function RouteComponent() { const { runtimeValue } = Route.useLoaderData() // Use the runtime variable } ``` ## Route-Level Headers The `headers` property on a route sets response headers. This is the primary mechanism for cache control, ISR, and custom headers: ```tsx export const Route = createFileRoute('/products/$id')({ loader: async ({ params }) => fetchProduct(params.id), headers: () => ({ 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', 'X-Custom-Header': 'my-value', }), component: ProductPage, }) ``` Headers can also be dynamic based on loader data or other runtime state. ## Best Practices ### 1. Use Full SSR for SEO-Critical Pages Content pages, blog posts, product pages, and landing pages should use `ssr: true` (the default) so crawlers receive complete HTML. ### 2. Use ssr: false for Authenticated Dashboards User dashboards, admin panels, and account settings rarely need SEO. Disabling SSR avoids serializing user data in the initial HTML and simplifies hydration. ```tsx export const Route = createFileRoute('/dashboard')({ ssr: false, pendingComponent: DashboardSkeleton, loader: () => fetchDashboardData(), component: Dashboard, }) ``` ### 3. Use ssr: 'data-only' for Browser-API-Dependent Components When you need server-fetched data but the component itself requires browser APIs (canvas, WebGL, etc.), use `'data-only'` to get server data performance without SSR rendering issues. ### 4. Combine Static Prerendering with ISR Prerender at build time for instant first loads, then use `Cache-Control` headers for ongoing freshness. This gives the best of both worlds - fast initial deployment and automatic content updates. ### 5. Start with Conservative Cache Times Begin with shorter cache durations and increase as you understand your content update patterns: ```tsx // Start conservative headers: () => ({ 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', }) // Increase after monitoring headers: () => ({ 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', }) ``` ### 6. Keep Secrets Server-Side Never use `VITE_` prefix for sensitive values. Access secrets via `process.env` inside server functions: ```tsx // WRONG: secret in client bundle const key = import.meta.env.VITE_SECRET_KEY // CORRECT: secret stays on server const getData = createServerFn().handler(async () => { const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SECRET_KEY}` }, }) return response.json() }) ``` ### 7. Use SPA Mode for Internal Tools Internal dashboards, admin tools, and other apps without SEO requirements benefit from SPA mode's simpler deployment and lower hosting costs. ### 8. Always Provide pendingComponent for Non-SSR Routes Routes with `ssr: false` or `ssr: 'data-only'` should define a `pendingComponent` to avoid blank screens during client-side loading. ## Official Documentation - [Selective SSR](https://tanstack.com/start/latest/docs/framework/react/guide/selective-ssr) - [SPA Mode](https://tanstack.com/start/latest/docs/framework/react/guide/spa-mode) - [Static Prerendering](https://tanstack.com/start/latest/docs/framework/react/guide/static-prerendering) - [ISR](https://tanstack.com/start/latest/docs/framework/react/guide/isr) - [Hosting](https://tanstack.com/start/latest/docs/framework/react/guide/hosting) - [Server Entry Point](https://tanstack.com/start/latest/docs/framework/react/guide/server-entry-point) - [Client Entry Point](https://tanstack.com/start/latest/docs/framework/react/guide/client-entry-point) - [Environment Variables](https://tanstack.com/start/latest/docs/framework/react/guide/environment-variables) - [Execution Model](https://tanstack.com/start/latest/docs/framework/react/guide/execution-model) -
start-guide.md 14.7 KB
# TanStack Start Full-stack React framework powered by TanStack Router and Vite. Adds SSR, streaming, server functions, middleware, server routes, and universal deployment to TanStack Router's type-safe routing. > TanStack Start is Pre-1.0. The API is stable and feature-complete, preparing for 1.0. React Server Components are available as an experimental feature - opt in with `tanstackStart({ rsc: { enabled: true } })` + `@vitejs/plugin-rsc` (requires React 19, Vite 7+). ## When to Use This Skill - Building full-stack React with SSR, SSG, or streaming - Adding server functions (type-safe RPCs) to a React app - Creating API/server routes alongside frontend routes - Implementing middleware for auth, logging, or request handling - Deploying to Cloudflare Workers, Netlify, Vercel, Node.js, Bun, or Docker - Need SPA mode with optional server functions (no SSR required) **Use TanStack Router alone** (see `router-guide.md`) when you only need client-side routing without server features. > For routing concepts (file-based routing, search params, nested layouts, loaders, preloading), see `router-guide.md`. This guide covers Start-specific full-stack features. ## Quick Start Workflow ### 1. Create Project ```bash pnpm create @tanstack/start@latest ``` ### 2. Manual Setup ```bash npm i @tanstack/react-start @tanstack/react-router react react-dom npm i -D vite @vitejs/plugin-react typescript @types/react @types/react-dom vite-tsconfig-paths ``` ### 3. Vite Configuration ```ts // vite.config.ts import { defineConfig } from 'vite' import tsConfigPaths from 'vite-tsconfig-paths' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' export default defineConfig({ server: { port: 3000 }, plugins: [ tsConfigPaths(), tanstackStart(), viteReact(), // MUST come after tanstackStart() ], }) ``` ### 4. Router and Root Route ```tsx // src/router.tsx import { createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' export function getRouter() { return createRouter({ routeTree, scrollRestoration: true }) } ``` ```tsx // src/routes/__root.tsx /// <reference types="vite/client" /> import type { ReactNode } from 'react' import { Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router' export const Route = createRootRoute({ head: () => ({ meta: [ { charSet: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { title: 'My TanStack Start App' }, ], }), component: () => ( <html> <head><HeadContent /></head> <body><Outlet /><Scripts /></body> </html> ), }) ``` ### 5. Route with Server Function ```tsx // src/routes/index.tsx import { createFileRoute } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' const getServerTime = createServerFn({ method: 'GET' }).handler(async () => { return new Date().toISOString() }) export const Route = createFileRoute('/')({ loader: () => getServerTime(), component: () => { const time = Route.useLoaderData() return <div>Server time: {time}</div> }, }) ``` ### File Structure ``` src/ ├── routes/ │ ├── __root.tsx # HTML shell, always rendered │ └── index.tsx ├── router.tsx # Router config ├── routeTree.gen.ts # Auto-generated ├── start.ts # Optional: global middleware └── server.ts # Optional: custom server entry ``` ## Execution Model All code is **isomorphic by default** - runs in both server and client bundles unless constrained. Route `loader`s run on the server during SSR AND on the client during navigation. ```tsx // WRONG - secret exposed to client bundle export const Route = createFileRoute('/users')({ loader: () => { const secret = process.env.SECRET return fetch(`/api/users?key=${secret}`) }, }) // CORRECT - server function keeps secrets server-side const getUsers = createServerFn().handler(async () => { return fetch(`/api/users?key=${process.env.SECRET}`) }) export const Route = createFileRoute('/users')({ loader: () => getUsers(), }) ``` | API | Runs On | Client Behavior | |-----|---------|-----------------| | `createServerFn()` | Server | Network request (RPC) | | `createServerOnlyFn(fn)` | Server | Throws error | | `createClientOnlyFn(fn)` | Client | Works normally | | `createIsomorphicFn()` | Both | Environment-specific impl | | `<ClientOnly>` | Client | Renders fallback on server | ## Server Functions Type-safe RPCs via `createServerFn()`. Server code is extracted from client bundles at build time; client calls become `fetch` requests. ```tsx import { createServerFn } from '@tanstack/react-start' import { z } from 'zod' import { redirect, notFound } from '@tanstack/react-router' // GET with no input export const getData = createServerFn({ method: 'GET' }).handler(async () => { return { message: 'Hello from server!' } }) // POST with Zod validation const CreatePostSchema = z.object({ title: z.string().min(1).max(200), body: z.string().min(1), }) export const createPost = createServerFn({ method: 'POST' }) .validator(CreatePostSchema) .handler(async ({ data }) => { return await db.posts.create(data) }) // Redirect and notFound export const getPost = createServerFn() .validator((data: { id: string }) => data) .handler(async ({ data }) => { const post = await db.findPost(data.id) if (!post) throw notFound() return post }) ``` ### Calling Server Functions ```tsx // From loader export const Route = createFileRoute('/posts')({ loader: () => getPosts(), }) // From component with useServerFn import { useServerFn } from '@tanstack/react-start' function CreatePostForm() { const mutation = useServerFn(createPost) return <button onClick={() => mutation({ data: { title: 'New', body: 'Content' } })}>Create</button> } // Direct call with router.invalidate() function DeleteButton({ id }: { id: string }) { const router = useRouter() return <button onClick={() => deletePost({ data: { id } }).then(() => router.invalidate())}>Delete</button> } ``` Server-function inputs/outputs are checked for serializability (`strict: true` default). Opt out per function with `createServerFn({ strict: false })` (or `{ input: false }` / `{ output: false }`) only when you know the runtime can serialize the value. ### Server Context Utilities Access request/response from `@tanstack/react-start/server`: `getRequest()`, `getRequestHeader(name)`, `setResponseHeaders(headers)`, `setResponseStatus(code)`. ## Middleware Two types: **request middleware** (all server requests including SSR) and **server function middleware** (server functions only, with client-side hooks and input validation). ### Request Middleware ```tsx import { createMiddleware } from '@tanstack/react-start' const loggingMiddleware = createMiddleware().server(async ({ next, request }) => { const start = Date.now() const result = await next() console.log(`${request.method} ${request.url} - ${Date.now() - start}ms`) return result }) ``` ### Server Function Middleware with Context ```tsx const authMiddleware = createMiddleware({ type: 'function' }) .server(async ({ next }) => { const user = await getCurrentUser() if (!user) throw redirect({ to: '/login' }) return next({ context: { user } }) }) const getProfile = createServerFn() .middleware([authMiddleware]) .handler(async ({ context }) => { return context.user // typed }) ``` ### Client + Server Middleware ```tsx const authHeaderMiddleware = createMiddleware({ type: 'function' }) .client(async ({ next }) => { return next({ headers: { Authorization: `Bearer ${getToken()}` } }) }) .server(async ({ next }) => { const user = await verifyToken(getRequestHeader('Authorization')) return next({ context: { user } }) }) ``` ### Global Middleware (src/start.ts) ```tsx import { createStart, createMiddleware } from '@tanstack/react-start' export const startInstance = createStart(() => ({ requestMiddleware: [globalLogger], // ALL requests (SSR, routes, fns) functionMiddleware: [globalAuth], // ALL server functions })) ``` ## Server Routes HTTP endpoints alongside frontend routes using file-based routing. Handlers receive `{ request, params, context }` and return `Response`. ```tsx // src/routes/api/users.ts import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/api/users')({ server: { middleware: [authMiddleware], handlers: { GET: async ({ request }) => { return Response.json(await db.users.findMany()) }, POST: async ({ request }) => { const body = await request.json() return Response.json(await db.users.create(body), { status: 201 }) }, }, }, }) ``` Per-handler middleware via `createHandlers`: ```tsx server: { handlers: ({ createHandlers }) => createHandlers({ GET: async ({ request }) => Response.json({ ok: true }), DELETE: { middleware: [adminOnlyMiddleware], handler: async ({ request }) => Response.json({ deleted: true }), }, }), } ``` Server routes and components can co-exist in the same file. Dynamic params (`$id`), wildcards (`$`), and escaped matching (`[.]json`) all work identically to Router. ## SSR Modes Per-route SSR control via the `ssr` property: | Mode | Loaders | Component | Use Case | |------|---------|-----------|----------| | `true` (default) | Server + Client | Server + Client | SEO, performance | | `false` | Client only | Client only | Browser APIs, canvas | | `'data-only'` | Server + Client | Client only | Dashboards | | `(params, search) => ...` | Dynamic | Dynamic | Conditional SSR | ```tsx export const Route = createFileRoute('/dashboard')({ ssr: 'data-only', loader: () => getDashboardData(), component: Dashboard, }) ``` ### SPA Mode Ship static HTML shells with server function support but no SSR: ```ts // vite.config.ts tanstackStart({ spa: { enabled: true } }) ``` ### Global Default ```tsx // src/start.ts export const startInstance = createStart(() => ({ defaultSsr: false })) ``` ## Head Management and SEO ```tsx export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => ({ post: await getPost({ data: { id: params.postId } }) }), head: ({ loaderData }) => ({ meta: [ { title: loaderData.post.title }, { name: 'description', content: loaderData.post.excerpt }, { property: 'og:title', content: loaderData.post.title }, { property: 'og:image', content: loaderData.post.coverImage }, { name: 'twitter:card', content: 'summary_large_image' }, ], links: [{ rel: 'canonical', href: `https://myapp.com/posts/${loaderData.post.id}` }], }), component: PostPage, }) ``` ## Authentication ### Session Management ```tsx // utils/session.ts import { useSession } from '@tanstack/react-start/server' export function useAppSession() { return useSession<{ userId?: string; email?: string }>({ name: 'app-session', password: process.env.SESSION_SECRET!, cookie: { secure: process.env.NODE_ENV === 'production', sameSite: 'lax', httpOnly: true }, }) } ``` ### Route Protection ```tsx // src/routes/_authed.tsx - layout route guard export const Route = createFileRoute('/_authed')({ beforeLoad: async ({ location }) => { const user = await getCurrentUserFn() if (!user) throw redirect({ to: '/login', search: { redirect: location.href } }) return { user } }, }) // src/routes/_authed/dashboard.tsx - automatically protected export const Route = createFileRoute('/_authed/dashboard')({ component: () => { const { user } = Route.useRouteContext() return <h1>Welcome, {user.email}!</h1> }, }) ``` ## Environment Functions ```tsx import { createIsomorphicFn, createServerOnlyFn, createClientOnlyFn } from '@tanstack/react-start' import { ClientOnly } from '@tanstack/react-router' const getDeviceInfo = createIsomorphicFn() .server(() => ({ type: 'server', platform: process.platform })) .client(() => ({ type: 'client', userAgent: navigator.userAgent })) const getDbUrl = createServerOnlyFn(() => process.env.DATABASE_URL) // throws on client const saveLocal = createClientOnlyFn((k: string, v: string) => localStorage.setItem(k, v)) // throws on server // Component-level: renders fallback during SSR, children after hydration <ClientOnly fallback={<div>Loading...</div>}><InteractiveChart /></ClientOnly> ``` ## Deployment ### Cloudflare Workers (Official Partner) Install `@cloudflare/vite-plugin` and `wrangler`, add `cloudflare({ viteEnvironment: { name: 'ssr' } })` to vite plugins (before `tanstackStart()`), and set `"main": "@tanstack/react-start/server-entry"` in `wrangler.jsonc`. ### Netlify (Official Partner) Install `@netlify/vite-plugin-tanstack-start`, add `netlify()` to vite plugins alongside `tanstackStart()`. ### Nitro (Node.js, Vercel, Bun, Docker) Install `nitro@npm:nitro-nightly@latest`, add `nitro()` to vite plugins. Build with `vite build`, run with `node .output/server/index.mjs`. ### Static Prerendering ```ts tanstackStart({ prerender: { enabled: true, crawlLinks: true } }) ``` ## Best Practices 1. **Never put secrets in loaders** - Loaders are isomorphic. Use `createServerFn()` for server-only access. 2. **Server functions are the boundary** - Primary mechanism for safe server-only execution from client code. 3. **Organize by concern** - `.functions.ts` for server fn wrappers, `.server.ts` for internal helpers, `.ts` for shared types/schemas. 4. **Compose middleware hierarchically** - Global for cross-cutting concerns, route-level for groups, function-level for specifics. 5. **Use `head()` on every content route** - Title, description, OG tags. Use loader data for dynamic pages. 6. **Choose SSR mode per route** - `true` for SEO, `false` for browser-only, `'data-only'` for dashboards. 7. **Validate all server function inputs** - Zod or custom validators via `.validator()`. ## Advanced Topics For deeper coverage, see reference files: - `server-functions.md` - Streaming, FormData, progressive enhancement, request cancellation, custom function IDs - `middleware.md` - sendContext, custom fetch, global config, environment tree shaking - `ssr-modes.md` - Selective SSR inheritance, functional form, shellComponent, fallback rendering - `server-routes.md` - Dynamic params, wildcards, escaped matching, pathless layouts ## Resources - [Official Docs](https://tanstack.com/start/latest/docs/framework/react/overview) - [GitHub](https://github.com/TanStack/router) (Start lives in the router repo) - [Examples](https://github.com/TanStack/router/tree/main/examples/react) - Basic, Auth, React Query, Cloudflare, Clerk, Supabase - [Start vs Next.js](https://tanstack.com/start/latest/docs/framework/react/comparison) - Cross-reference: `router-guide.md` for routing, `query-guide.md` for data fetching
-
-
CHANGELOG.md 5.9 KB
# Changelog All notable changes to this skill will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), and this skill adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.4.5] - 2026-09-09 ### Changed - Description condensed to fit the repo's 250-character limit. ## [0.4.4] - 2026-08-21 ### Changed - Declared ClawHub browse categories (`development`) and topics in `metadata`, so the release pipeline publishes them instead of leaving the skill in the `other` category. ### Removed - `skill-card.md`. The ClawHub CLI strips a root `skill-card.md` from every publish and the registry generates its own card, so the authored file never reached ClawHub. ## [0.4.3] - 2026-08-07 ### Changed - Trimmed the frontmatter description to what-plus-when; dropped the trailing 14-item trigger-keyword list. ## [0.4.2] - 2026-07-22 ### Added - skill-card.md release record following NVIDIA's skill-card format - metadata.openclaw block (emoji, homepage) for ClawHub display ## [0.4.1] - 2026-07-10 ### Changed - CHANGELOG preamble pinned to Keep a Changelog 2.0.0 (format unchanged; KaC 2.0.0 keeps existing changelogs valid). ## [0.4.0] - 2026-07-01 ### Fixed - infinite-queries.md: removed the v4-only `refetchPage` invalidation API (deleted in Query v5) from three examples; replaced with the `maxPages` option, which caps stored and refetched pages (TanStack Query migrating-to-v5: "Removed `refetchPage` in favor of `maxPages`"). ### Added - Query: `useIsFetching` / `useIsMutating` for app-wide loading/mutating indicators (query-guide.md). - Query: `maxPages` option to cap stored/refetched pages in infinite queries (infinite-queries.md). - Query: `usePrefetchQuery` / `usePrefetchInfiniteQuery` render-phase prefetch hooks, distinct from the imperative `queryClient.prefetchQuery` (query-performance.md). - Query: referential-stability footgun - `data ?? []` and per-render new objects/proxy getters in queryKeys or effect deps cause infinite refetch loops / "Maximum update depth exceeded"; use hoisted stable constants (query-guide.md). - Router: View Transitions - `viewTransition` on Link/navigate, `defaultViewTransition` on the router (routing-patterns.md). - Router: route lifecycle callbacks `onEnter` / `onStay` / `onLeave` and `remountDeps` remount control (routing-patterns.md). - Router: `createLink` / `useLinkProps` for custom, type-safe Link components wrapping UI-library anchors (router-guide.md). - Start: testing a server function via curl requires the `x-tsr-serverFn: true` header, else the handler never runs; thrown errors return HTTP 200 with the error framed in the body (server-functions.md). Verified against: @tanstack/react-query@5.101.2, @tanstack/react-router@1.170.16, @tanstack/react-start@1.168.26 ## [0.3.0] - 2026-06-10 ### Changed - Server functions & middleware: renamed `.inputValidator()` -> `.validator()` across SKILL.md, server-functions.md, middleware.md, start-guide.md (29 call sites). `validator()` is now the canonical method; `inputValidator()` is deprecated and the compiler emits warnings for it (TanStack/router PR #7566). - start-guide.md: dropped the stale "Release Candidate" header and the "No RSC support yet" claim, aligning it with SKILL.md (Pre-1.0; RSC experimental opt-in). ### Added - Query cross-reload persistence setup (query-performance.md): `PersistQueryClientProvider` + storage persister, `gcTime >= maxAge`, `buster` for cache-shape changes, and excluding credential-bearing queries via `shouldDehydrateQuery`; corrected the React package name to `@tanstack/react-query-persist-client`. - Network Mode note (`online` / `always` / `offlineFirst`) in query-performance.md. - `useMutationState` note for shared cross-component mutation UI in query-guide.md. Verified against: @tanstack/react-router@1.170.15, @tanstack/react-start@1.168.25, @tanstack/router-plugin@1.168.18 ## [0.2.0] - 2026-06-04 ### Changed - Start: dropped the "(RC)" label and the "No RSC yet" claim. The docs overview no longer uses RC; React Server Components are now an experimental feature (opt-in via `tanstackStart({ rsc: { enabled: true } })` + `@vitejs/plugin-rsc`, React 19 / Vite 7+). - Start scaffolding command updated from `pnpm create @tanstack/start@latest` to `npx @tanstack/cli@latest create` (or TanStack Builder). - Deployment: Cloudflare, Netlify, and Railway are now the official hosting partners. - Sharpened the auth best practice: the security boundary is the server function / server route / endpoint that touches private data, not `beforeLoad` route guards (SKILL.md best practice, middleware.md, server-functions.md). ### Added - Zod v4 note: the zod-adapter is no longer needed with Zod v4 (pass the schema directly to `validateSearch`; `.catch()` retains types). - CSRF default-middleware caveat: defining `src/start.ts` requires re-adding `createCsrfMiddleware()` explicitly for server functions. - `createServerFn({ strict })` serialization-check opt-out (server-functions.md, start-guide.md). - Native `mutationOptions()` helper (companion to `queryOptions()`) in query-guide.md. - Server-function Cache-Control safety: `public` only for non-identity data; authed responses need `private` + `Vary` or `no-store` (server-functions.md). - Real-world footguns: invalidate-then-navigate stale data, queryKey runtime dimensions, StrictMode double-submit, `staleTime` is not cross-reload persistence (Query); dot-prefixed dirs excluded from the route tree, `Link to` rejects dynamic strings, client-only-provider hydration mismatches (Router/Start). ### Fixed - Stale Start doc URLs updated to include the `/guide/` path segment. - Initial CHANGELOG; upstream tracking established. Verified against: @tanstack/react-query@5.101.0, @tanstack/react-router@1.170.11, @tanstack/react-start@1.168.19, @tanstack/zod-adapter@1.167.0, @tanstack/router-plugin@1.168.14 -
LICENSE.txt 8.9 KB
Apache License Version 2.0, January 2004 https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS -
SKILL.md 13.2 KB
--- name: tanstack description: Type-safe React with TanStack Query (fetching, caching, mutations), Router (file-based routing, search params, loaders), and Start (SSR, server functions). Use for react-query, server state, typed search params, route loaders, or SSR. metadata: version: "0.4.5" categories: "development" topics: "tanstack, react, routing, data-fetching, ssr" upstream: "@tanstack/react-query@5.101.2, @tanstack/react-router@1.170.16, @tanstack/react-start@1.168.26, @tanstack/zod-adapter@1.167.0, @tanstack/router-plugin@1.168.18" openclaw: homepage: https://github.com/tenequm/skills/tree/main/skills/tanstack emoji: "⚛️" --- # TanStack (Query + Router + Start) Type-safe libraries for React applications. **Query** manages server state (fetching, caching, mutations). **Router** provides file-based routing with validated search params and data loaders. **Start** extends Router with SSR, server functions, and middleware for full-stack apps. ## When to Use **Query** - data fetching, caching, mutations, optimistic updates, infinite scroll, streaming AI/SSE responses, tRPC v11 integration **Router** - file-based routing, type-safe navigation, validated search params, route loaders, code splitting, preloading **Start** - SSR/SSG, server functions (type-safe RPCs), middleware, API routes, deployment to Cloudflare/Vercel/Node **Decision tree:** - Client-only SPA with API calls -> Router + Query - Full-stack with SSR/server functions -> Start + Query (Start includes Router) ## TanStack Query v5 ### Setup ```tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes }, }, }) function App() { return ( <QueryClientProvider client={queryClient}> <YourApp /> </QueryClientProvider> ) } ``` ### Queries ```tsx import { useQuery, queryOptions } from '@tanstack/react-query' // Reusable query definition (recommended pattern) const todosQueryOptions = queryOptions({ queryKey: ['todos'], queryFn: async () => { const res = await fetch('/api/todos') if (!res.ok) throw new Error('Failed to fetch') return res.json() as Promise<Todo[]> }, }) // In component - full type inference from queryOptions function TodoList() { const { data, isLoading, error } = useQuery(todosQueryOptions) if (isLoading) return <Spinner /> if (error) return <div>Error: {error.message}</div> return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul> } ``` ### Mutations ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query' function CreateTodo() { const queryClient = useQueryClient() const mutation = useMutation({ mutationFn: (newTodo: { title: string }) => fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }).then(r => r.json()), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }) }, }) return ( <button onClick={() => mutation.mutate({ title: 'New' })}> {mutation.isPending ? 'Creating...' : 'Create'} </button> ) } ``` ### Key Patterns **Query keys** - hierarchical arrays for cache management: ```tsx ['todos'] // all todos ['todos', 'list', { page, sort }] // filtered list ['todo', todoId] // single item ``` **Dependent queries** - chain with `enabled`: ```tsx const { data: user } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) }) const { data: projects } = useQuery({ queryKey: ['projects', user?.id], queryFn: () => fetchProjects(user!.id), enabled: !!user?.id, }) ``` **Important defaults**: staleTime: 0, gcTime: 5min, retry: 3, refetchOnWindowFocus: true **Suspense** - use `useSuspenseQuery` with `<Suspense>` boundaries **Streamed queries** (experimental) - for AI chat/SSE: ```tsx import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query' const { data: chunks } = useQuery(queryOptions({ queryKey: ['chat', sessionId], queryFn: streamedQuery({ streamFn: () => fetchChatStream(sessionId), refetchMode: 'reset' }), })) ``` ### DevTools ```bash pnpm add @tanstack/react-query-devtools ``` ```tsx import { ReactQueryDevtools } from '@tanstack/react-query-devtools' // Add inside QueryClientProvider <ReactQueryDevtools initialIsOpen={false} /> ``` ### Query Deep Dives - `query-guide.md` - Complete Query reference with all patterns - `infinite-queries.md` - useInfiniteQuery, pagination, virtual scroll - `optimistic-updates.md` - Optimistic UI, rollback, undo - `query-performance.md` - staleTime tuning, deduplication, prefetching - `query-invalidation.md` - Cache invalidation strategies, filters, predicates - `query-typescript.md` - Type inference, generics, custom hooks --- ## TanStack Router v1 ### Setup (Vite) ```bash pnpm add @tanstack/react-router @tanstack/router-plugin ``` ```ts // vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { tanstackRouter } from '@tanstack/router-plugin/vite' export default defineConfig({ plugins: [ tanstackRouter({ autoCodeSplitting: true }), react(), ], }) ``` ```tsx // src/router.ts import { createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' export const router = createRouter({ routeTree, defaultPreload: 'intent' }) declare module '@tanstack/react-router' { interface Register { router: typeof router } } ``` ### File-Based Routing Files in `src/routes/` auto-generate route config: | Convention | Purpose | Example | |---|---|---| | `__root.tsx` | Root route (always rendered) | `src/routes/__root.tsx` | | `index.tsx` | Index route | `src/routes/index.tsx` -> `/` | | `$param` | Dynamic segment | `posts.$postId.tsx` -> `/posts/:id` | | `_prefix` | Pathless layout | `_layout.tsx` wraps children | | `(folder)` | Route group (no URL) | `(auth)/login.tsx` -> `/login` | ### Type-Safe Navigation ```tsx <Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link> // Active styling <Link to="/posts" activeProps={{ className: 'font-bold' }}>Posts</Link> // Imperative const navigate = useNavigate({ from: '/posts' }) navigate({ to: '/posts/$postId', params: { postId: post.id } }) ``` Always provide `from` on Link and hooks - narrows types and improves TS performance. ### Search Params ```tsx import { zodValidator, fallback } from '@tanstack/zod-adapter' import { z } from 'zod' const searchSchema = z.object({ page: fallback(z.number(), 1).default(1), sort: fallback(z.enum(['newest', 'oldest']), 'newest').default('newest'), }) export const Route = createFileRoute('/products')({ validateSearch: zodValidator(searchSchema), component: () => { const { page, sort } = Route.useSearch() // Writing return <Link from={Route.fullPath} search={prev => ({ ...prev, page: prev.page + 1 })}>Next</Link> }, }) ``` Use `fallback(...).default(...)` from the Zod adapter (Zod v3); plain `.catch()` causes type loss. With **Zod v4** the adapter is no longer needed - pass the schema directly to `validateSearch`, and `.catch()` retains type inference. ### Data Loading ```tsx export const Route = createFileRoute('/posts')({ // loaderDeps: only extract what loader needs (not full search) loaderDeps: ({ search: { page } }) => ({ page }), loader: ({ deps: { page } }) => fetchPosts({ page }), pendingComponent: () => <Spinner />, component: () => { const posts = Route.useLoaderData() return <PostList posts={posts} /> }, }) ``` ### Route Context (Dependency Injection) ```tsx // __root.tsx interface RouterContext { queryClient: QueryClient } export const Route = createRootRouteWithContext<RouterContext>()({ component: Root }) // router.ts const router = createRouter({ routeTree, context: { queryClient } }) // Child route - queryClient available in loader export const Route = createFileRoute('/posts')({ loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(postsQueryOptions()), }) ``` ### Router Deep Dives - `router-guide.md` - Complete Router reference with all patterns - `search-params.md` - Custom serialization, Standard Schema, sharing params - `data-loading.md` - Deferred loading, streaming SSR, shouldReload - `routing-patterns.md` - Virtual routes, route masking, navigation blocking - `code-splitting.md` - Automatic/manual splitting strategies - `router-ssr.md` - SSR setup, streaming, hydration --- ## TanStack Start Full-stack framework extending Router with SSR, server functions, middleware. Pre-1.0 (API stable, feature-complete, preparing for 1.0). React Server Components are available as an **experimental** feature - opt in with `tanstackStart({ rsc: { enabled: true } })` + `@vitejs/plugin-rsc` (requires React 19, Vite 7+). Vite is the default bundler; Rsbuild is also supported. ### Setup ```bash npx @tanstack/cli@latest create # or use TanStack Builder: https://tanstack.com/builder ``` ```ts // vite.config.ts import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' export default defineConfig({ plugins: [ tanstackStart(), viteReact(), // MUST come after tanstackStart() ], }) ``` ### Server Functions Type-safe RPCs. Server code extracted from client bundles at build time. ```tsx import { createServerFn } from '@tanstack/react-start' import { z } from 'zod' // GET - no input export const getUsers = createServerFn({ method: 'GET' }) .handler(async () => db.users.findMany()) // POST - validated input export const createUser = createServerFn({ method: 'POST' }) .validator(z.object({ name: z.string(), email: z.string().email() })) .handler(async ({ data }) => db.users.create(data)) // Call from loader export const Route = createFileRoute('/users')({ loader: () => getUsers(), component: () => { const users = Route.useLoaderData() return <UserList users={users} /> }, }) ``` **Critical**: Loaders are isomorphic (run on server AND client). Never put secrets in loaders - use `createServerFn()` instead. ### Middleware ```tsx import { createMiddleware } from '@tanstack/react-start' const authMiddleware = createMiddleware({ type: 'function' }) .server(async ({ next }) => { const user = await getCurrentUser() if (!user) throw redirect({ to: '/login' }) return next({ context: { user } }) }) const getProfile = createServerFn() .middleware([authMiddleware]) .handler(async ({ context }) => context.user) // typed ``` Global middleware via `src/start.ts`: ```tsx export const startInstance = createStart(() => ({ requestMiddleware: [logger], // all requests functionMiddleware: [auth], // all server functions })) ``` **CSRF**: Start auto-installs `createCsrfMiddleware()` for server functions *only when there is no `src/start.ts`*. Once you create `src/start.ts`, add it back explicitly, or non-GET server functions lose same-origin protection: ```tsx requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }), logger] ``` ### SSR Modes | Mode | Use Case | |------|----------| | `true` (default) | SEO, performance | | `false` | Browser-only features | | `'data-only'` | Dashboards (data on server, render on client) | SPA mode: `tanstackStart({ spa: { enabled: true } })` in vite.config.ts ### Deployment - **Official partners**: Cloudflare Workers (`@cloudflare/vite-plugin`), Netlify (`@netlify/vite-plugin-tanstack-start`), Railway - **Node/Vercel/Bun/Docker**: via Nitro - **Static**: `tanstackStart({ prerender: { enabled: true, crawlLinks: true } })` ### Start Deep Dives - `start-guide.md` - Complete Start reference with all patterns - `server-functions.md` - Streaming, FormData, progressive enhancement - `middleware.md` - sendContext, custom fetch, global config - `ssr-modes.md` - Selective SSR, shellComponent, fallback rendering - `server-routes.md` - Dynamic params, wildcards, pathless layouts --- ## Best Practices 1. **Use `queryOptions()` factory** for reusable, type-safe query definitions 2. **Structure query keys hierarchically** - `['entity', 'action', { filters }]` 3. **Set staleTime per data type** - static: `Infinity`, dynamic: `0`, moderate: `5min` 4. **Always validate search params** with Zod via `zodValidator` + `fallback().default()` 5. **Provide `from` on navigation** - narrows types, catches route mismatches 6. **Use route context for DI** - pass QueryClient, auth via `createRootRouteWithContext` 7. **Set `defaultPreload: 'intent'`** globally for perceived performance 8. **Enforce auth at the data boundary** - authorize inside the server function, server route, or API endpoint that reads/writes private data; `beforeLoad`/route guards are UX, not the security boundary. Never put secrets in isomorphic loaders - use `createServerFn()` 9. **Compose middleware hierarchically** - global -> route -> function 10. **Use `head()` on every content route** for SEO (title, description, OG tags) ## Resources - **Query Docs**: https://tanstack.com/query/latest/docs/framework/react/overview - **Router Docs**: https://tanstack.com/router/latest/docs/framework/react/overview - **Start Docs**: https://tanstack.com/start/latest/docs/framework/react/overview - **GitHub**: https://github.com/TanStack/query | https://github.com/TanStack/router - **Discord**: https://discord.gg/tanstack
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.