Skip to main content
Loaders allow you to fetch data before a route renders, ensuring all required data is available when components mount.

Defining Loaders

Add a loader function to your route:
const postRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts/$postId',
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    return { post }
  },
  component: PostComponent,
})

Accessing Loader Data

Use the useLoaderData hook in components:
import { useLoaderData } from '@tanstack/react-router'

function PostComponent() {
  const { post } = useLoaderData({ from: '/posts/$postId' })
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

Type Safety

Specify the route for full type inference:
// Type-safe: post is inferred from loader return type
const data = useLoaderData({ from: '/posts/$postId' })

// Loose: Union of all loader data from all routes
const data = useLoaderData({ strict: false })

Loader Context

Loaders receive a context object with useful properties:
interface LoaderContext {
  params: RouteParams          // Path parameters
  deps: LoaderDeps             // Loader dependencies
  context: RouteContext        // Route context
  location: ParsedLocation     // Current location
  abortController: AbortController  // For cancellation
  preload: boolean             // True if preloading
  cause: 'enter' | 'stay' | 'preload'  // Why loader is running
  route: AnyRoute              // Route definition
  parentMatchPromise: Promise  // Parent loader promise
}

Using Context

const postRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts/$postId',
  loader: async ({ params, context, abortController }) => {
    // Use auth from context
    const userId = context.auth.userId
    
    // Fetch with abort signal
    const post = await fetchPost(params.postId, {
      signal: abortController.signal,
    })
    
    // Check permissions
    if (post.authorId !== userId) {
      throw new Error('Unauthorized')
    }
    
    return { post }
  },
})

Loader Dependencies

Declare dependencies for cache invalidation:
const postsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts',
  validateSearch: (search) => ({
    page: Number(search.page) || 1,
    filter: search.filter as string,
  }),
  loaderDeps: ({ search }) => ({
    page: search.page,
    filter: search.filter,
  }),
  loader: async ({ deps }) => {
    // Loader reruns when deps change
    const posts = await fetchPosts({
      page: deps.page,
      filter: deps.filter,
    })
    return { posts }
  },
})

Why Loader Dependencies?

Without loaderDeps, loaders only rerun when path params change. With loaderDeps:
  • Loader reruns when dependencies change
  • Each unique dependency combination is cached separately
  • Provides fine-grained cache invalidation

Parallel Loading

Multiple route loaders run in parallel:
// Layout loader
const layoutRoute = createRoute({
  id: 'layout',
  loader: async () => {
    const user = await fetchUser()
    return { user }
  },
})

// Posts loader (runs in parallel with layout)
const postsRoute = createRoute({
  getParentRoute: () => layoutRoute,
  path: '/posts',
  loader: async () => {
    const posts = await fetchPosts()
    return { posts }
  },
})
Both loaders run simultaneously, reducing total load time.

Sequential Loading

Wait for parent loader data:
const postRoute = createRoute({
  getParentRoute: () => layoutRoute,
  path: '/posts/$postId',
  loader: async ({ params, parentMatchPromise }) => {
    // Wait for parent loader
    const parentMatch = await parentMatchPromise
    const user = parentMatch.loaderData.user
    
    // Use parent data
    const post = await fetchPost(params.postId, user.id)
    return { post }
  },
})

Caching

Cache Configuration

Control how long data stays fresh:
const postsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts',
  staleTime: 5000,        // Fresh for 5 seconds
  gcTime: 30 * 60 * 1000, // Garbage collect after 30 minutes
  loader: async () => {
    const posts = await fetchPosts()
    return { posts }
  },
})
staleTime
number
default:"0"
Time in milliseconds before cached data is considered stale. Stale data triggers a background refetch but is still used immediately.
gcTime
number
default:"1800000"
Time in milliseconds before unused cached data is garbage collected (deleted from memory).

Global Defaults

Set default caching behavior:
const router = createRouter({
  routeTree,
  defaultStaleTime: 5000,
  defaultGcTime: 30 * 60 * 1000,
})

Preloading

Preload route data before navigation:
const router = createRouter({
  routeTree,
  defaultPreload: 'intent', // Preload on hover
})

Preload Options

  • false - No preloading
  • 'intent' - Preload on hover/touch
  • 'viewport' - Preload when link visible
  • 'render' - Preload when link renders

Per-Route Preloading

const postRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts/$postId',
  preload: 'intent',
  preloadStaleTime: 10000, // Keep preloaded data fresh for 10s
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    return { post }
  },
})

Manual Preloading

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

function Component() {
  const router = useRouter()
  
  const preloadPost = async (postId: string) => {
    await router.preloadRoute({
      to: '/posts/$postId',
      params: { postId },
    })
  }
  
  return (
    <button onMouseEnter={() => preloadPost('123')}>
      Hover to Preload
    </button>
  )
}

Before Load

Run code before the loader:
const protectedRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/dashboard',
  beforeLoad: async ({ context, location }) => {
    // Check authentication
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
    
    // Return additional context
    return {
      permissions: await fetchPermissions(context.auth.userId),
    }
  },
  loader: async ({ context }) => {
    // Access context from beforeLoad
    const data = await fetchData(context.permissions)
    return { data }
  },
})

beforeLoad vs loader

  • beforeLoad: Runs first, sequential, can add context
  • loader: Runs after, parallel across routes, for data fetching
Use beforeLoad for:
  • Authentication checks
  • Authorization logic
  • Redirects
  • Adding context for loaders
Use loader for:
  • Fetching data
  • Data transformations
  • API calls

Error Handling

Handle loader errors gracefully:
const postRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts/$postId',
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    
    if (!post) {
      throw notFound()
    }
    
    return { post }
  },
  errorComponent: ({ error, reset }) => (
    <div>
      <h1>Error Loading Post</h1>
      <p>{error.message}</p>
      <button onClick={reset}>Try Again</button>
    </div>
  ),
})

Catching Errors

const route = createRoute({
  getParentRoute: () => rootRoute,
  path: '/data',
  loader: async () => {
    const data = await fetchData()
    return { data }
  },
  onError: (error) => {
    console.error('Loader failed:', error)
    logErrorToService(error)
  },
})

Invalidation

Force loaders to rerun:
import { useRouter } from '@tanstack/react-router'

function Component() {
  const router = useRouter()
  
  const refreshData = async () => {
    // Invalidate all routes
    await router.invalidate()
    
    // Invalidate specific routes
    await router.invalidate({
      filter: (match) => match.routeId === '/posts',
    })
  }
  
  return <button onClick={refreshData}>Refresh</button>
}

Deferred Data

Start rendering before all data loads:
import { defer } from '@tanstack/react-router'

const pageRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/page',
  loader: () => {
    const criticalData = fetchCritical()  // Wait for this
    const slowData = fetchSlow()          // Don't wait
    
    return {
      critical: await criticalData,
      slow: defer(slowData), // Deferred
    }
  },
})
In component:
import { Await } from '@tanstack/react-router'

function PageComponent() {
  const { critical, slow } = useLoaderData({ from: '/page' })
  
  return (
    <div>
      {/* Renders immediately */}
      <h1>{critical.title}</h1>
      
      {/* Suspends until loaded */}
      <Suspense fallback={<div>Loading...</div>}>
        <Await promise={slow}>
          {(data) => <div>{data.content}</div>}
        </Await>
      </Suspense>
    </div>
  )
}

Should Reload

Control when loaders rerun:
const route = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts/$postId',
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    return { post }
  },
  shouldReload: (match) => {
    // Only reload if data is older than 5 minutes
    const fiveMinutes = 5 * 60 * 1000
    return Date.now() - match.updatedAt > fiveMinutes
  },
})

Loader Patterns

Global Data

const rootRoute = createRootRoute({
  loader: async () => {
    const user = await fetchCurrentUser()
    return { user }
  },
})

Dependent Requests

const route = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts/$postId',
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    const author = await fetchAuthor(post.authorId)
    const comments = await fetchComments(params.postId)
    
    return { post, author, comments }
  },
})

Parallel Requests

const route = createRoute({
  getParentRoute: () => rootRoute,
  path: '/dashboard',
  loader: async () => {
    const [stats, activity, notifications] = await Promise.all([
      fetchStats(),
      fetchActivity(),
      fetchNotifications(),
    ])
    
    return { stats, activity, notifications }
  },
})

Next Steps

Type Safety

Configure end-to-end type safety for loaders

Search Params

Use search params in loader dependencies

Path Params

Access path parameters in loaders

Navigation

Navigate with preloading

Build docs developers (and LLMs) love