Skip to main content

Loaders

Loaders are functions that fetch data for a route before it renders. They’re the backbone of TanStack Router’s data loading strategy, providing type-safe, cacheable, and parallel data fetching.

Why Loaders Matter

Loaders solve critical data loading challenges:
  • Fetch before render - Data loads before components mount
  • Type-safe data flow - Return types automatically inferred
  • Automatic caching - Prevents redundant fetches
  • Parallel loading - Multiple loaders run concurrently
  • Cancellation - Automatic cleanup on navigation away
This eliminates loading states scattered throughout components and provides a better user experience.

Defining Loaders

Loaders are defined using the loader option on routes.

Basic Loader

import { createFileRoute } from '@tanstack/react-router'

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

function PostsComponent() {
  const posts = Route.useLoaderData()
  // posts is fully typed based on loader return value!
  
  return (
    <div>
      {posts.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  )
}

Loader with Parameters

import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const response = await fetch(`/api/posts/${params.postId}`)
    return response.json()
  },
})
Path parameters are available in the loader context.

Loader with Context

Access router context in loaders:
export const Route = createFileRoute('/posts')({
  loader: async ({ context }) => {
    // Access queryClient, auth, etc. from context
    return context.queryClient.fetchQuery({
      queryKey: ['posts'],
      queryFn: fetchPosts,
    })
  },
})

Loader Context

Loaders receive a rich context object defined in packages/router-core/src/route.ts:1418-1453:
export interface LoaderFnContext {
  abortController: AbortController
  preload: boolean
  params: AllParams
  deps: LoaderDeps
  context: RouteContext
  location: ParsedLocation
  cause: 'preload' | 'enter' | 'stay'
  route: AnyRoute
  parentMatchPromise?: Promise<ParentMatch>
}

Available Context Properties

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ 
    params,           // Path parameters
    context,          // Router context + parent context
    deps,             // Loader dependencies
    abortController,  // For cancellation
    location,         // Current location (no search to discourage skipping loaderDeps)
    preload,          // true if preloading
    cause,            // 'enter' | 'stay' | 'preload'
    parentMatchPromise, // Await parent loader
  }) => {
    const post = await fetch(
      `/api/posts/${params.postId}`,
      { signal: abortController.signal }
    )
    return post.json()
  },
})

Loader Dependencies

Loader dependencies control when a loader re-runs.

Defining Dependencies

import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

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

export const Route = createFileRoute('/posts')({
  validateSearch: searchSchema,
  
  // Define which search params trigger loader
  loaderDeps: ({ search }) => ({
    page: search.page,
    filter: search.filter,
  }),
  
  loader: async ({ deps }) => {
    // Loader re-runs when page or filter changes
    const response = await fetch(
      `/api/posts?page=${deps.page}&filter=${deps.filter || ''}`
    )
    return response.json()
  },
})
Without loaderDeps, the loader only runs once per route match. Use loaderDeps to re-run loaders when specific values change.

Why Use loaderDeps?

The location object in loader context doesn’t include search params. This is intentional - it encourages you to explicitly declare dependencies:
// ❌ Bad - bypasses dependency tracking
loader: ({ location }) => {
  const search = parseSearch(location.search)
  return fetch(`/api?page=${search.page}`)
}

// ✅ Good - explicit dependencies
loaderDeps: ({ search }) => ({ page: search.page }),
loader: ({ deps }) => {
  return fetch(`/api?page=${deps.page}`)
}
This ensures caching works correctly.

Accessing Loader Data

Use the useLoaderData hook to access loader results.

In Route Component

function PostsComponent() {
  const data = Route.useLoaderData()
  // data is fully typed from loader return type
  
  return (
    <div>
      {data.posts.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  )
}

In Nested Components

import { useLoaderData } from '@tanstack/react-router'

function NestedComponent() {
  const data = useLoaderData({ from: '/posts' })
  // Specify which route's data to access
  
  return <div>{data.posts.length} posts</div>
}

Type-Safe Access

Loader data types are automatically inferred:
export const Route = createFileRoute('/posts')({
  loader: async () => {
    return {
      posts: await fetchPosts(),
      metadata: { total: 100, page: 1 },
    }
  },
})

function PostsComponent() {
  const data = Route.useLoaderData()
  // TypeScript knows:
  // data.posts is Post[]
  // data.metadata.total is number
}

Parallel Loaders

Multiple route loaders run in parallel automatically.
// Parent route loader
export const ParentRoute = createFileRoute('/dashboard')({
  loader: async () => {
    return { user: await fetchUser() }
  },
})

// Child route loader runs in parallel
export const ChildRoute = createFileRoute('/dashboard/stats')({
  loader: async () => {
    return { stats: await fetchStats() }
  },
})
Both loaders start simultaneously. The route doesn’t render until all loaders complete.

Awaiting Parent Loader

Sometimes child loaders need parent data:
export const ChildRoute = createFileRoute('/dashboard/settings')({
  loader: async ({ parentMatchPromise }) => {
    // Wait for parent loader to complete
    const parentMatch = await parentMatchPromise
    const userId = parentMatch.loaderData.user.id
    
    return { settings: await fetchUserSettings(userId) }
  },
})
This creates a sequential dependency when needed.

Loader Cancellation

Loaders are automatically cancelled when navigating away.
export const Route = createFileRoute('/posts')({
  loader: async ({ abortController }) => {
    const response = await fetch('/api/posts', {
      signal: abortController.signal, // Cancelled on navigation
    })
    return response.json()
  },
})
Always pass signal to fetch requests for proper cleanup.

Error Handling

Handle loader errors gracefully:
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const response = await fetch(`/api/posts/${params.postId}`)
    
    if (!response.ok) {
      throw new Error('Failed to load post')
    }
    
    return response.json()
  },
  
  errorComponent: ({ error, reset }) => (
    <div>
      <h2>Error Loading Post</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Retry</button>
    </div>
  ),
})
Errors thrown in loaders are caught by the route’s error boundary.

Preloading

Loaders can run before navigation starts:
import { useRouter } from '@tanstack/react-router'

function PostLink({ postId }: { postId: string }) {
  const router = useRouter()
  
  return (
    <Link
      to="/posts/$postId"
      params={{ postId }}
      preload="intent" // Preload on hover/touchstart
    >
      View Post
    </Link>
  )
}
When preloading, context.preload is true in the loader. See the Prefetching guide for more details.

Conditional Loading

Control whether a loader runs:
export const Route = createFileRoute('/posts')({
  loader: async ({ context, preload }) => {
    // Skip during preload
    if (preload) return null
    
    // Skip if data already in cache
    if (context.cache.has('posts')) {
      return context.cache.get('posts')
    }
    
    const posts = await fetchPosts()
    context.cache.set('posts', posts)
    return posts
  },
})

shouldReload

Control when cached data should reload:
export const Route = createFileRoute('/posts')({
  loader: async () => fetchPosts(),
  
  // Reload if navigating from a different route
  shouldReload: ({ match }) => {
    return match.cause !== 'stay'
  },
})

Integration with React Query

TanStack Router works great with React Query:
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions } from '@tanstack/react-query'

const postsQueryOptions = queryOptions({
  queryKey: ['posts'],
  queryFn: fetchPosts,
})

export const Route = createFileRoute('/posts')({
  loader: ({ context: { queryClient } }) => {
    return queryClient.ensureQueryData(postsQueryOptions)
  },
  component: PostsComponent,
})

function PostsComponent() {
  // Access via React Query for real-time updates
  const { data } = useSuspenseQuery(postsQueryOptions)
  
  return (
    <div>
      {data.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  )
}
This pattern:
  • Ensures data is loaded before render
  • Provides React Query’s caching and refetching
  • Enables mutations and optimistic updates

Loader Best Practices

Pass the abort signal to fetch requests. This prevents memory leaks and unnecessary network requests when users navigate away.
Explicitly declare which search params trigger loader re-runs. This ensures correct caching behavior.
Every route with a loader should have an errorComponent to handle failures gracefully.
Design loaders to be independent. Only use parentMatchPromise when child data truly depends on parent data.
For heavy data loads, split loaders across parent/child routes to show content progressively.

Loader vs beforeLoad

Choose the right lifecycle hook:
Use CaseHookWhy
Fetch dataloaderDesigned for async data loading
Authentication checksbeforeLoadRuns first, can redirect before data loads
Setting contextbeforeLoadContext available to loader
Dependent dataloaderUse parentMatchPromise
Side effectsloaderHas access to all route data
// ✅ Good separation
export const Route = createFileRoute('/_auth/dashboard')({
  beforeLoad: ({ context }) => {
    // Auth check first
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: '/login' })
    }
    // Add user to context
    return { user: context.auth.getUser() }
  },
  loader: ({ context }) => {
    // Fetch user data (we know user exists)
    return fetchDashboard(context.user.id)
  },
})

Next Steps

Caching

Learn about loader data caching strategies

Prefetching

Optimize performance with loader prefetching

Routes

Explore all route lifecycle options

Search Params

Use search params in loader dependencies

Build docs developers (and LLMs) love