Loaders & Data

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.

What a Loader Is

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.

A Basic Loader

// 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>
}
⚡ No 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.

Reading the Result

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.

Loader Parameters

The loader receives a single object with everything you need:

ParamWhat it gives you
paramsPath params from $ segments — typed
searchValidated search params (Lesson 6) — typed
locationThe current location object
contextMerged parent context + this route's beforeLoad output
depsWhat loaderDeps returned (see below)
cause'enter' | 'preload' | 'stay'
abortControllerSignal 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 In

By 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()
  },
})
🧠 Why 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.

Stale & GC Defaults

TanStack caches loader results. Know the defaults:

DefaultMeaning
staleTime: 0Data is immediately stale. Re-fetched in background on re-entry.
Preloaded fresh: 30sIf a route is preloaded, then preloaded again within 30s, the second is skipped.
gcTime: 30 minRoute 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.

Error Handling

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.

Next.js → TanStack Quick Map

ConcernNext.js (App Router)TanStack
Fetch locationpage.tsx default export (server)Route loader option
Read in componentasync component awaitRoute.useLoaderData()
Paramsfunction params proploader { params }
Search depsmanual searchParams proploaderDeps
Error UIerror.tsx fileerrorComponent option
Runs whereserver (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.

Practice Exercise

The Feed list screen needs paginated posts driven by URL search params. Build:

  1. A validateSearch schema with offset (number, fallback 0) and limit (number, fallback 10)
  2. A loaderDeps extracting both
  3. A loader that fetches with signal and returns the posts
  4. A component reading both the posts and the search params

Sketch mentally, then reveal.

Show solution
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:

Quick Quiz

1. Where do you declare a route's data fetch?

In useEffect
In the loader
In the __root
The route's loader option runs before the component mounts. Its return value is cached and read via useLoaderData().

2. How do you read the loader's result in the component?

props.data
Route.useLoaderData()
useFetch hook
Route.useLoaderData() returns the typed, cached result. For deep components in code-split trees, getRouteApi works too.

3. Where do path params come from in the loader?

props argument
The params object
window.location
The loader receives { params }, typed from the route's $ segments. params.postId is string.

4. What is loaderDeps for?

Caching the result
Declaring search deps
Cancelling requests
loaderDeps extracts a slice of search params the loader depends on. Only those changing triggers a refetch — unrelated search changes don't.

5. What is the default staleTime?

30 minutes
Zero seconds
Infinite (never)
Default staleTime: 0 — data is immediately stale, re-fetched in background on re-entry. Override per-route if data changes rarely.

6. How does the loader handle request cancellation?

It cannot cancel
Via abortController
Via setTimeout
The loader receives abortController; its signal is cancelled when the route unloads. Pass { signal } to fetch.

7. A thrown error in the loader is caught by?

The component try/catch
errorComponent option
window.onerror
Errors bubble to the route's errorComponent. Provide one per fallible route; fall back to <ErrorComponent/> for safety.

8. Default gcTime (garbage collection)?

Zero (immediate)
Thirty minutes
Infinite (never)
Default gcTime: 30 min — route data not accessed in 30 min is GC'd. Keeps memory bounded while allowing back-button to hit cache.

9. Next.js App Router's loader equivalent is?

getServerSideProps
The async component
useRouter hook
In Next.js App Router, the async server component is the loader — you await data in it. TanStack separates loader (runs first) from component (reads result).

10. Which signal is cancelled on route unload?

The router signal
abortController signal
The location signal
abortController.signal from the loader's args. Pass it to fetch so in-flight requests cancel cleanly on navigation.
← Lesson 6: Search Params Lesson 8: Migration →
🎓 Stuck on something?
Ask your teacher — when to override 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.