Lesson 7 — type-safe data fetching tied to your route tree
Routes need data. A post detail screen needs the post; a feed list needs the posts. TanStack's loader co-locates data fetching with the route, runs it before the component mounts, and exposes the result through a typed hook. Combined with params and search params from earlier lessons, you get end-to-end type safety from URL to fetch to JSX.
A function declared on the route that runs before the component renders. Its return value is cached and handed to the component via a typed hook. Loaders run in parallel for matched routes — the parent and child fetch concurrently, not serially.
// routes/_app/(tabs)/feed/$postId/index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_app/(tabs)/feed/$postId')({
// 1. Declare the loader — async function, returns anything
loader: async ({ params }) => {
const res = await fetch(`/api/posts/${params.postId}`)
return res.json() // typed as Post via inference or generic
},
component: PostScreen,
})
function PostScreen() {
// 2. Read the result — typed, no loading state to manage
const post = Route.useLoaderData()
// ^? Post
return <article>{post.title}</article>
}
useEffect, no useState
The old Next.js Pages-Router pattern (useEffect + fetch + useState) is gone. The loader runs early, the result is cached, the component reads it synchronously via useLoaderData(). Loading states are handled by Suspense / pendingComponent, not your component.
Two ways:
// 1. On the route itself (preferred inside this route's tree)
const post = Route.useLoaderData()
// 2. Anywhere else — getRouteApi (avoids circular deps in code-split trees)
import { getRouteApi } from '@tanstack/react-router'
const routeApi = getRouteApi('/_app/(tabs)/feed/$postId')
const post = routeApi.useLoaderData()
For type inference, either annotate the loader's return type explicitly or let TypeScript infer it from your fetch wrapper.
The loader receives a single object with everything you need:
| Param | What it gives you |
|---|---|
params | Path params from $ segments — typed |
search | Validated search params (Lesson 6) — typed |
location | The current location object |
context | Merged parent context + this route's beforeLoad output |
deps | What loaderDeps returned (see below) |
cause | 'enter' | 'preload' | 'stay' |
abortController | Signal cancelled if route unloads — use for fetch cancellation |
// params comes from the route path — typed as { postId: string }
loader: async ({ params, signal }) => {
const res = await fetch(`/api/posts/${params.postId}`, { signal })
return res.json()
}
loaderDeps — Pipe Search Params InBy default, a loader re-runs only on param changes. If it depends on search params (like pagination offset), declare those deps explicitly. loaderDeps extracts a stable subset of search that triggers re-fetches:
import { z } from 'zod'
export const Route = createFileRoute('/_app/(tabs)/feed')({
// 1. Validate search params
validateSearch: z.object({
offset: z.number().int().nonnegative().catch(0),
}),
// 2. Extract what the loader needs
loaderDeps: ({ search: { offset } }) => ({ offset }),
// 3. Use deps, not search directly
loader: async ({ deps: { offset } }) => {
const res = await fetch(`/api/posts?offset=${offset}`)
return res.json()
},
})
loaderDeps exists
The loader would re-fetch on every search change without it — even unrelated params like modalOpen. loaderDeps declares the minimal slice the loader cares about. Only that slice changing triggers a refetch.
TanStack caches loader results. Know the defaults:
| Default | Meaning |
|---|---|
staleTime: 0 | Data is immediately stale. Re-fetched in background on re-entry. |
| Preloaded fresh: 30s | If a route is preloaded, then preloaded again within 30s, the second is skipped. |
gcTime: 30 min | Route data not accessed in 30 min is garbage-collected. |
staleReloadMode: 'background' | Stale successful data keeps rendering while refetch happens in background. |
Override per-route via staleTime and gcTime options. router.invalidate() force-reloads all active loaders.
A thrown error in the loader bubbles to the route's errorComponent. Provide one per route that can fail, and fall back to the default for safety:
export const Route = createFileRoute('/_app/(tabs)/feed/$postId')({
loader: () => fetchPost(), // may throw
errorComponent: ({ error }) => {
if (error instanceof NotFoundError) {
return <div>Post not found</div>
}
return <ErrorComponent error={error} /> // default fallback
},
})
Errors render at the route boundary they're thrown from — a child loader failure won't unmount the parent's shell.
| Concern | Next.js (App Router) | TanStack |
|---|---|---|
| Fetch location | page.tsx default export (server) | Route loader option |
| Read in component | async component await | Route.useLoaderData() |
| Params | function params prop | loader { params } |
| Search deps | manual searchParams prop | loaderDeps |
| Error UI | error.tsx file | errorComponent option |
| Runs where | server (RSC) | client (or server in TanStack Start — out of scope) |
The mental shift: in Next.js App Router, the component is the loader (async server component). In TanStack, loader and component are separate — the loader is a side-channel that runs first, the component reads its result synchronously.
The Feed list screen needs paginated posts driven by URL search params. Build:
validateSearch schema with offset (number, fallback 0) and limit (number, fallback 10)loaderDeps extracting bothloader that fetches with signal and returns the postsSketch mentally, then reveal.
import { z } from 'zod'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_app/(tabs)/feed')({
validateSearch: z.object({
offset: z.number().int().nonnegative().catch(0),
limit: z.number().int().positive().catch(10),
}),
loaderDeps: ({ search: { offset, limit } }) => ({ offset, limit }),
loader: async ({ deps: { offset, limit }, signal }) => {
const res = await fetch(
`/api/posts?offset=${offset}&limit=${limit}`,
{ signal }
)
return res.json() as Promise<Post[]>
},
component: FeedList,
})
function FeedList() {
const posts = Route.useLoaderData() // typed as Post[]
const { offset, limit } = Route.useSearch()
return (
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
<li>Showing {offset}–{offset + posts.length} (limit {limit})</li>
</ul>
)
}
Why this works:
?offset=20&limit=5 fetches exactly that.loaderDeps ensures the fetch re-runs only when offset/limit change, not on unrelated search changes.signal cancels the fetch if the user navigates away mid-request — no race conditions.loader option runs before the component mounts. Its return value is cached and read via useLoaderData().Route.useLoaderData() returns the typed, cached result. For deep components in code-split trees, getRouteApi works too.{ params }, typed from the route's $ segments. params.postId is string.loaderDeps for?loaderDeps extracts a slice of search params the loader depends on. Only those changing triggers a refetch — unrelated search changes don't.staleTime?staleTime: 0 — data is immediately stale, re-fetched in background on re-entry. Override per-route if data changes rarely.abortController; its signal is cancelled when the route unloads. Pass { signal } to fetch.errorComponent. Provide one per fallible route; fall back to <ErrorComponent/> for safety.gcTime (garbage collection)?gcTime: 30 min — route data not accessed in 30 min is GC'd. Keeps memory bounded while allowing back-button to hit cache.await data in it. TanStack separates loader (runs first) from component (reads result).abortController.signal from the loader's args. Pass it to fetch so in-flight requests cancel cleanly on navigation.staleTime, how to pass auth context into loaders (beforeLoad), or how to combine loaderDeps with complex search schemas.
Primary source: TanStack Router — Data Loading (Defining Loaders, loaderDeps, and Consuming Data sections). Read after the quiz.