| | |

TanStack Router 09 ⚛️ Data Loaders — Route loader functions

⏱️ Estimated read: 10 min | |

The previous chapters covered the path and search parameters: the URL data that the route reads. This chapter covers the data loaders: the functions that fetch the data before the route component renders. In the React Router Data mode, the loader is defined in the route object with the loader property. In TanStack Router, the loader is defined in the file route with the loader property, and the component accesses the data with the Route.useLoaderData() hook. The critical detail is the execution timing. The loader runs before the component renders, and the router waits for the loader to resolve. The component receives the data as a resolved value, not a promise. This is the difference from the useEffect + useState pattern, where the component renders first with an empty state and then re-renders with the data. The loader is the data-first pattern: the component renders only when the data is ready.

Key point: The loader property in the route object is the data fetching function. The Route.useLoaderData() hook returns the typed loader result. The loader receives a context object with params, deps, context, and request. The loaderDeps option extracts the search or path params that the loader depends on, so that the router re-runs the loader when those params change. The pendingComponent shows the loading state, and the errorComponent handles the loader errors. The loader is the correct choice for the route data; the useQuery or useEffect is the correct choice for the component-specific data.


Why data loaders require a specific mental model

The execution timing problem. The loader runs before the component. The router calls the loader, awaits the result, and only then renders the component. The component’s render function assumes the data is available. The Route.useLoaderData() returns the resolved data, not a promise. The component that calls data.map(...) does not need the optional chaining or the loading check. The loading state is handled by the pendingComponent.

The loaderDeps problem. The loader runs when the route matches. If the route’s search params change (e.g., the ?page=2), the loader must re-run to fetch the new page. The loaderDeps function extracts the search params that the loader depends on. The router compares the deps before and after the navigation, and re-runs the loader if they differ. Without the loaderDeps, the loader runs only when the path params change, and the search changes do not trigger the refetch.

The caching problem. The loader result is cached. The navigation back to the same route uses the cached result, not a new fetch. The router.invalidate() command clears the cache and re-runs the loaders. The staleTime option in the defaultPreloadStaleTime controls the cache duration. The aggressive caching is the feature: the navigation is instant, and the data is re-fetched only when the deps change or the cache is invalidated.

The error problem. The loader can throw. The notFound() and the redirect() are the special throws that the router handles. The other errors are caught by the nearest errorComponent. The loader that throws without the error component crashes the route. The errorComponent is the mandatory safety net for the data loading.

The parallel loading problem. The parent route’s loader and the child route’s loader run in parallel by default. The child does not wait for the parent. The await in the parent’s loader is not necessary for the child. The parallel loading is faster, but the child that depends on the parent’s data must access it via the useRouteContext or the Route.useRouteContext().

The trade-off. The loader is the data-first pattern. The useQuery is the client-side data fetching pattern with the polling and the caching. The loader is the route-level data; the useQuery is the component-level data. The correct choice depends on whether the data is required for the route to render or whether it is the supplementary data that can load after.


a. The basic loader and the Route.useLoaderData

The loader property in the route object is the function. The Route.useLoaderData() reads the result.

// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts')({
  loader: async () => {
    const res = await fetch('/api/posts')
    return res.json()
  },
  component: PostsList,
})

function PostsList() {
  const posts = Route.useLoaderData()
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

The loader fetches the posts and returns the array. The Route.useLoaderData() returns the typed array. The component renders the list. The component does not have a loading state, because the router renders the component only after the loader resolves.

The loader receives a context object with the route info.

loader: async ({ params, deps, context, request, abortController }) => {
  const res = await fetch(`/api/posts/${params.postId}`, {
    signal: abortController.signal,
  })
  return res.json()
}

The params is the route params. The deps is the loaderDeps result. The context is the router context. The request is the fetch request. The abortController is the cancellation signal, which is triggered when the navigation is cancelled.


b. The loaderDeps and the re-fetching

The loaderDeps extracts the search params that the loader depends on.

import { z } from 'zod'

const searchSchema = z.object({
  page: z.number().default(1),
})

export const Route = createFileRoute('/posts')({
  validateSearch: searchSchema,
  loaderDeps: ({ search }) => ({ page: search.page }),
  loader: async ({ deps }) => {
    const res = await fetch(`/api/posts?page=${deps.page}`)
    return res.json()
  },
  component: PostsList,
})

The loaderDeps receives the full search object and returns the subset that the loader depends on. The router compares the loaderDeps results before and after the navigation. If the page changes, the loader re-runs with the new page. If the sort changes but is not in the deps, the loader does not re-run.

The loaderDeps is the mechanism for the search-based pagination, filtering, and sorting. Without it, the loader runs only when the path params change.


c. The pending and error components

The pendingComponent renders while the loader is in progress. The errorComponent renders if the loader throws.

export const Route = createFileRoute('/posts')({
  loader: async () => {
    const res = await fetch('/api/posts')
    if (!res.ok) throw new Error('Failed to load')
    return res.json()
  },
  component: PostsList,
  pendingComponent: PostsPending,
  errorComponent: PostsError,
})

function PostsPending() {
  return <p>Loading posts...</p>
}

function PostsError({ error }: { error: Error }) {
  return (
    <div>
      <p>Error: {error.message}</p>
      <button onClick={() => router.invalidate()}>Retry</button>
    </div>
  )
}

The pendingComponent is the loading UI. The errorComponent receives the error prop. The router.invalidate() re-runs the loader, which is the retry mechanism.

The pendingMs and pendingMinMs options control the timing. The pendingMs is the delay before showing the pending component. The pendingMinMs is the minimum time the pending component is shown. These are the anti-flicker measures.

{
  loader: ...,
  pendingMs: 1000,
  pendingMinMs: 500,
}

The pending component appears after 1 second and stays for at least 500 milliseconds. This prevents the flicker when the loader is fast.


Complete Example Session

# ============================================
# PART 1: BASIC LOADER
# ============================================

export const Route = createFileRoute('/posts')({
  loader: async () => {
    const res = await fetch('/api/posts')
    return res.json()
  },
  component: PostsList,
})
# ============================================
# PART 2: READ DATA
# ============================================

function PostsList() {
  const posts = Route.useLoaderData()
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}
# ============================================
# PART 3: LOADER CONTEXT
# ============================================

loader: async ({ params }) => {
  return fetch(`/api/posts/${params.postId}`).then((r) => r.json())
}
# ============================================
# PART 4: LOADER DEPS
# ============================================

loaderDeps: ({ search }) => ({ page: search.page }),
loader: async ({ deps }) => fetch(`/api/posts?page=${deps.page}`).then((r) => r.json())
# ============================================
# PART 5: PENDING COMPONENT
# ============================================

export const Route = createFileRoute('/posts')({
  loader: ...,
  pendingComponent: () => <p>Loading...</p>,
})
# ============================================
# PART 6: ERROR COMPONENT
# ============================================

errorComponent: ({ error }) => <p>Error: {error.message}</p>,
# ============================================
# PART 7: PENDING TIMING
# ============================================

pendingMs: 1000,
pendingMinMs: 500,
# ============================================
# PART 8: INVALIDATE AND RETRY
# ============================================

const router = useRouter()
router.invalidate()
# ============================================
# PART 9: VERIFY
# ============================================

# /posts → loader fetches → posts render
# /posts?page=2 → loaderDeps triggers refetch
# Loader throws → errorComponent renders

The nine parts trace the loader: basic, data read, context, deps, pending, error, timing, invalidate, and verification.


Quick Reference

Loader Context

PropertyPurpose
paramsRoute params
depsloaderDeps result
contextRouter context
requestFetch request
abortControllerCancellation signal

Route Options

OptionPurpose
loaderData fetching function
loaderDepsParams that trigger re-run
pendingComponentLoading UI
errorComponentError UI
pendingMsDelay before pending
pendingMinMsMin pending duration

Hooks

HookReturns
Route.useLoaderData()Typed loader result
useRouter()Router instance
Route.useRouteContext()Context data

Cache Control

CommandEffect
router.invalidate()Re-run loaders
defaultPreloadStaleTimeCache duration

Best Practices

✅ Do This:

loader: async () => fetch(...).then(r => r.json())
<!-- Use loader for route data -->                   # ✅
const posts = Route.useLoaderData()
<!-- Use the typed hook -->                          # ✅
loaderDeps: ({ search }) => ({ page: search.page })
<!-- Add deps for search-based refetch -->           # ✅

❌ Don’t Do This:

# Use useEffect + useState for route data
<!-- The loader is the correct tool -->              # ❌
# Forget the errorComponent
<!-- Loader errors crash the route -->               # ❌
# Assume the loader runs on every navigation
<!-- The cache prevents the re-run -->               # ❌

Common Pitfalls

PitfallWhy It HappensFix
Data undefinedUsed useState instead of loaderUse Route.useLoaderData
Search change not refetchingNo loaderDepsAdd the deps
Loader error crashes routeNo errorComponentAdd the boundary
Pending flickerNo pendingMsAdd the timing
Stale dataCache not invalidatedrouter.invalidate()

Real-World Examples

1. Basic Loader

loader: async () => fetch('/api/posts').then(r => r.json())

2. Read Data

const posts = Route.useLoaderData()

3. Loader Context

loader: async ({ params }) => fetch(`/api/posts/${params.id}`).then(r => r.json())

4. Loader Deps

loaderDeps: ({ search }) => ({ page: search.page })

5. Pending Component

pendingComponent: () => <p>Loading...</p>

6. Error Component

errorComponent: ({ error }) => <p>{error.message}</p>

7. Pending Timing

pendingMs: 1000, pendingMinMs: 500

8. Invalidate

router.invalidate()

9. Abort Signal

loader: async ({ abortController }) => fetch(url, { signal: abortController.signal })

10. Not Found

if (!post) throw notFound()

Visual

Loader Flow

┌──────────────────────────────────────────────┐
│  Navigation to /posts                        │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Loader runs                                 │
│  → fetch data                                │
└──────────────────┬───────────────────────────┘
                   │
        ┌──────────┴──────────┐
        │                     │
        ▼                     ▼
┌───────────────┐     ┌───────────────┐
│  Success      │     │  Error        │
│  → render     │     │  → errorComp  │
└───────────────┘     └───────────────┘

Loader Deps

┌──────────────────────────────────────────────┐
│  URL: /posts?page=1                          │
│  deps: { page: 1 }                           │
└──────────────────┬───────────────────────────┘
                   │
                   │ navigate to /posts?page=2
                   ▼
┌──────────────────────────────────────────────┐
│  URL: /posts?page=2                          │
│  deps: { page: 2 }                           │
│  → deps changed → loader re-runs            │
└──────────────────────────────────────────────┘

Summary

ItemValue
Loader propertyloader in route
Read hookRoute.useLoaderData()
Deps functionloaderDeps
Pending UIpendingComponent
Error UIerrorComponent
Pending timingpendingMs, pendingMinMs
Invalidaterouter.invalidate()
Not foundthrow notFound()

Key takeaways:

  • The loader runs before the component. The router awaits the loader and renders the component with the data. The component does not need the loading state or the optional chaining.
  • The Route.useLoaderData() returns the typed result. The data is the resolved value, not a promise. The component uses it directly.
  • The loaderDeps triggers the re-fetch. The search or path params that the loader depends on are extracted. The router compares them and re-runs the loader if they change. Without the deps, the search changes do not trigger the refetch.
  • The pendingComponent and errorComponent are the UI states. The pending component shows while the loader is in progress. The error component shows if the loader throws. The pendingMs and pendingMinMs control the timing.
  • The cache is the feature. The loader result is cached, and the navigation back to the same route uses the cached data. The router.invalidate() clears the cache and re-runs the loaders.

Remember: The loader is the data-first pattern. The Route.useLoaderData is the read. The loaderDeps is the re-fetch trigger. The pendingComponent is the loading. The errorComponent is the failure. The router.invalidate is the refresh. The exam tests the loader property and the useLoaderData hook.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!