Skip to content
数据加载与 Loader

数据加载与 Loader

TanStack Router 的 Loader 机制在路由渲染前预加载数据,支持 SWR(Stale-While-Revalidate)缓存、依赖追踪和错误处理。


Loader 基础

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

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params, context, location }) => {
    // params: { postId: string } — 路径参数
    // context: 路由上下文(auth 等)
    const post = await fetchPost(params.postId)
    return { post }
  },
  component: PostDetail,
})

读取 Loader 数据

function PostDetail() {
  // loader 的返回值类型自动推断
  const { post } = Route.useLoaderData()
  // post 类型完全安全 ✅

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  )
}

// 从其他组件访问
import { useLoaderData, getRouteApi } from '@tanstack/react-router'

const routeApi = getRouteApi('/posts/$postId')
function PostTitle() {
  const { post } = routeApi.useLoaderData()
  return <h1>{post.title}</h1>
}

SWR 缓存与 staleTime

Loader 的数据默认被缓存。staleTime 决定缓存何时过期:

export const Route = createFileRoute('/posts/$postId')({
  staleTime: 30_000, // 30 秒内重复访问使用缓存,不重新请求
  loader: async ({ params }) => {
    return fetchPost(params.postId)
  },
})

SWR 行为:

场景 行为
首次访问 /posts/1 执行 loader,缓存结果
15 秒后再次访问 /posts/1 使用缓存(staleTime 内),同时后台重新获取
45 秒后再次访问 /posts/1 缓存已过期,重新执行 loader
访问 /posts/2 不同参数,始终执行 loader

💡 最佳实践:对频繁变化的数据设置较短的 staleTime(5-15 秒),对相对稳定的数据设置较长的 staleTime(30 秒到几分钟)。也可以设置为 Infinity 表示永远不自动过期。

全局默认 staleTime

// router.tsx
const router = createRouter({
  routeTree,
  defaultStaleTime: 10_000, // 全局默认 10 秒
})

预加载缓存

预加载后的数据有独立的过期时间:

const router = createRouter({
  routeTree,
  defaultPreloadStaleTime: 60_000, // 预加载的数据缓存 1 分钟
})

loaderDeps — 精准依赖追踪

默认任何参数变化都会重新执行 loader。用 loaderDeps 限制触发条件:

export const Route = createFileRoute('/posts/')({
  validateSearch: z.object({
    page: fallback(z.number(), 1).default(1),
    sort: z.enum(['newest', 'oldest']).default('newest'),
  }),
  // 只有 page 变化时才重新加载,sort 变化不触发
  loaderDeps: ({ search }) => ({
    page: search.page,
  }),
  loader: async ({ deps }) => {
    // deps: { page: number }
    return fetchPosts({ page: deps.page })
  },
})

性能提示loaderDeps 是重要的性能优化手段。如果一个搜索参数只影响客户端排序(如 sort),就不应该触发 loader 重新请求。


beforeLoad — 守卫与数据预检查

beforeLoad 在 loader 之前执行,适合做认证检查、重定向和 context 注入:

export const Route = createFileRoute('/dashboard')({
  beforeLoad: async ({ context, location }) => {
    // 1. 认证守卫
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }

    // 2. 预加载权限数据到 context
    const permissions = await fetchPermissions(context.auth.userId)
    return { permissions } // 返回的数据会合并到 context
  },
  loader: async ({ context }) => {
    // context 中已有 permissions
    return fetchDashboardData(context.permissions)
  },
})

beforeLoad 的返回值

beforeLoad 返回的对象会合并到 context 中,供当前路由及所有子路由的 loaderbeforeLoad 使用:

// 父路由
beforeLoad: async () => {
  const org = await fetchOrganization()
  return { org } // context.org 对子路由可用
}

// 子路由
loader: async ({ context }) => {
  // context.org ✅ 可用
  return fetchProjects(context.org.id)
}

加载状态与错误处理

pendingComponent

Loader 执行期间可显示加载状态:

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    // 模拟慢速加载
    await new Promise((r) => setTimeout(r, 2000))
    return fetchPost(params.postId)
  },
  component: PostDetail,
  pendingComponent: PostSkeleton, // loader 执行中显示
})

errorComponent

Loader 失败时的错误 UI:

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const res = await fetch(`/api/posts/${params.postId}`)
    if (res.status === 404) throw notFound()
    if (!res.ok) throw new Error('加载失败')
    return res.json()
  },
  component: PostDetail,
  errorComponent: ({ error, reset }) => (
    <div className="error-state">
      <h2>加载失败</h2>
      <p>{error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  ),
})

🚨 陷阱:loader 中的错误不会被 React Error Boundary 捕获。必须通过路由的 errorComponent 来处理 loader 错误。组件渲染时的错误仍然走 Error Boundary。


notFoundComponent

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

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    if (!post) throw notFound() // 触发 notFoundComponent
    return { post }
  },
  component: PostDetail,
  notFoundComponent: () => (
    <div>
      <h2>文章不存在</h2>
      <Link to="/posts">返回列表</Link>
    </div>
  ),
})

💡 最佳实践notFound() 是 TanStack Router 提供的特殊抛出,与 redirect() 类似。它触发的是路由级 notFoundComponent,而非根路由的全局 404。


Loader 中访问其他路由数据

// 子路由访问父路由的 loader 数据
export const Route = createFileRoute('/posts/$postId/comments')({
  loader: async ({ params, context }) => {
    // 不能直接访问父 loader 数据!
    // 需要通过 context 或独立请求
    return fetchComments(params.postId)
  },
})

🚨 陷阱:子路由的 loader 不能直接访问父路由 loader 的返回值。如果子路由需要父路由的数据,有以下选择:

  1. 在父路由的 beforeLoad 中获取并放入 context
  2. 子路由的 loader 中独立请求(配合 HTTP 缓存层)
  3. 使用全局状态管理(Zustand、Jotai 等)

手动刷新 Loader

function PostDetail() {
  const { post } = Route.useLoaderData()
  const navigate = useNavigate()

  const refresh = () => {
    // 方式一:重新导航到当前路由(触发 loader 重新执行)
    navigate({ to: '.', replace: true })
  }

  return (
    <div>
      <h1>{post.title}</h1>
      <button onClick={refresh}>刷新</button>
    </div>
  )
}

// 方式二:使用 useRouter
import { useRouter } from '@tanstack/react-router'

function RefreshButton() {
  const router = useRouter()

  return (
    <button onClick={() => router.invalidate()}>
      清除所有缓存并刷新
    </button>
  )
}

Loader 完整类型参考

type LoaderFn<TContext, TParams, TSearch, TDeps> = (opts: {
  params: TParams          // 路径参数(解析后)
  context: TContext        // 路由上下文
  location: ParsedLocation // 当前 URL 信息
  search: TSearch          // 验证后的搜索参数
  deps: TDeps              // loaderDeps 返回值
  cause: 'enter' | 'stay'  // 触发原因
  preload: boolean         // 是否为预加载
  navigate: NavigateFn     // 导航函数
  abortController: AbortController // 导航取消时的信号
}) => MaybePromise<unknown>

💡 最佳实践:使用 abortController.signal 取消 fetch 请求,避免组件卸载后的无效请求:

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