首页 / TanStack 生态入门教程 / Router 与 Query 集成

TanStack 生态入门教程

Router 与 Query 集成

本教程共 38 篇 · 第 21 篇 · 更新于 2026-07-27 · 约 14 分钟阅读

TanStackTanStack 生态入门教程TanStack RouterTanStack QueryensureQueryDataprefetchQuerySSRhydration

21. Router 与 Query 集成

本节目标:搞懂 TanStack Router 和 TanStack Query 怎么配合。学会在 loader 里用 ensureQueryData 预热缓存、组件里用 useSuspenseQuery 读取、通过路由上下文注入 QueryClient、用 SSR 集成包自动处理脱水/注水。学完你能让两个库各司其职,数据加载又快又可控。

21.1 为什么要集成

上一章我们学了 Router 自带的 loader 和缓存。Router 缓存够用,但有几个短板:

  • 没有持久化存储(刷新页面缓存就没了)
  • 路由之间不能共享缓存(A 路由加载的数据 B 路由用不了)
  • 没有变更(mutation)API
  • 没有乐观更新支持

TanStack Query 恰好擅长这些。但 Query 自己不知道用户要去哪个页面,没法提前加载数据。

Router 知道用户要去哪,Query 擅长管数据。把它们结合起来:

  • Router 负责”什么时候加载”:在路由匹配时触发 loader
  • Query 负责”怎么缓存和更新”:管理缓存、去重、后台刷新
Note

Router 变成”协调者”(coordinator),不直接存数据,而是把加载任务委托给 Query。Router 的内置缓存在集成 Query 后基本就不用了。

21.2 集成的基本模式

核心思路:在 loader 里用 ensureQueryData 把数据塞进 Query 缓存,组件里用 useSuspenseQuery 读取。

定义查询选项

先用 queryOptions 定义查询,这样查询键和查询函数能复用:

// src/api/posts.ts
import { queryOptions } from '@tanstack/react-query'

export const postsQueryOptions = queryOptions({
  queryKey: ['posts'],
  queryFn: async () => {
    const res = await fetch('/api/posts')
    if (!res.ok) throw new Error('获取文章失败')
    return res.json()
  },
})

export const postQueryOptions = (postId: string) =>
  queryOptions({
    queryKey: ['posts', postId],
    queryFn: async () => {
      const res = await fetch(`/api/posts/${postId}`)
      if (!res.ok) throw new Error('获取文章详情失败')
      return res.json()
    },
  })
Tip

queryOptions 是个工具函数,返回一个包含 queryKeyqueryFn 的对象。好处是类型安全、可复用,loader 和组件用同一份定义。

在 loader 里预热缓存

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

export const Route = createFileRoute('/posts')({
  // 确保数据在缓存里,没有就加载
  loader: ({ context: { queryClient } }) =>
    queryClient.ensureQueryData(postsQueryOptions),
  component: PostsComponent,
})

function PostsComponent() {
  // 从缓存读取,一定有数据(loader 保证过了)
  const { data: posts } = useSuspenseQuery(postsQueryOptions)

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

执行流程:

  1. 用户导航到 /posts
  2. Router 执行 loader -> ensureQueryData 检查缓存
  3. 缓存没有 -> 发起请求 -> 等待完成 -> 写入缓存
  4. loader 返回 -> 组件渲染
  5. useSuspenseQuery 从缓存读到数据 -> 直接显示

整个过程没有 loading 闪烁,因为组件渲染时数据已经就绪。

ensureQueryData vs prefetchQuery

这两个方法都能预热缓存,区别在于等不等待:

方法行为适用场景
ensureQueryData等待请求完成才返回关键数据,必须先加载
prefetchQuery发出请求但不等待非关键数据,可以延迟
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
import { postQueryOptions, commentsQueryOptions } from '../api/posts'

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ context: { queryClient }, params: { postId } }) => {
    // 关键数据:等它完成
    await queryClient.ensureQueryData(postQueryOptions(postId))

    // 非关键数据:发起但不等
    queryClient.prefetchQuery(commentsQueryOptions(postId))
  },
  component: PostDetail,
})

function PostDetail() {
  const { postId } = Route.useParams()
  const { data: post } = useSuspenseQuery(
    postQueryOptions(postId)
  )

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {/* 评论区用 Suspense 包裹,评论没好就显示 loading */}
      <Suspense fallback={<div>加载评论中...</div>}>
        <Comments postId={post.id} />
      </Suspense>
    </article>
  )
}
Tip

ensureQueryData 保证组件渲染时关键数据一定在缓存里,useSuspenseQuery 就不会挂起。prefetchQuery 提前发请求但不阻塞渲染,配合 Suspense 实现延迟加载。

21.3 通过路由上下文注入 QueryClient

组件和 loader 都需要访问 queryClient。最干净的方式是放进路由上下文。

创建带上下文的根路由

// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'

export const Route = createRootRouteWithContext<{
  queryClient: QueryClient
}>()({
  component: RootComponent,
})

创建路由器时提供 QueryClient

// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'
import { routeTree } from './routeTree.gen'

// 客户端:全局共用一个 QueryClient
const queryClient = new QueryClient()

export const router = createRouter({
  routeTree,
  context: {
    queryClient,
  },
  // 关闭 Router 内置预取缓存(交给 Query 管)
  defaultPreloadStaleTime: 0,
  defaultPreload: 'intent',
})
Warning

集成 Query 时一定要设 defaultPreloadStaleTime: 0。不然 Router 的内置缓存会和 Query 的缓存打架,导致预取数据不生效。设成 0 就是告诉 Router:预取的数据立即过期,每次都走 Query 的缓存逻辑。

在 loader 里使用

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

类型自动推导,context.queryClient 有完整的 QueryClient 类型。

21.4 完整示例:文章列表与详情

把前面的知识点串起来,做一个完整的文章浏览功能。

查询选项定义

// src/api/posts.ts
import { queryOptions } from '@tanstack/react-query'

type Post = {
  id: string
  title: string
  content: string
}

type Comment = {
  id: string
  postId: string
  text: string
}

// 文章列表
export const postsQueryOptions = queryOptions({
  queryKey: ['posts'],
  queryFn: async (): Promise<Post[]> => {
    const res = await fetch('/api/posts')
    if (!res.ok) throw new Error('获取文章列表失败')
    return res.json()
  },
})

// 文章详情
export const postQueryOptions = (postId: string) =>
  queryOptions({
    queryKey: ['posts', postId],
    queryFn: async (): Promise<Post> => {
      const res = await fetch(`/api/posts/${postId}`)
      if (!res.ok) throw new Error('获取文章详情失败')
      return res.json()
    },
  })

// 评论
export const commentsQueryOptions = (postId: string) =>
  queryOptions({
    queryKey: ['posts', postId, 'comments'],
    queryFn: async (): Promise<Comment[]> => {
      const res = await fetch(`/api/posts/${postId}/comments`)
      if (!res.ok) throw new Error('获取评论失败')
      return res.json()
    },
  })

列表页

// src/routes/posts.tsx
import { createFileRoute, Link } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
import { postsQueryOptions } from '../api/posts'

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

function PostsPage() {
  const { data: posts } = useSuspenseQuery(postsQueryOptions)

  return (
    <div>
      <h1>文章列表</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to="/posts/$postId" params={{ postId: post.id }}>
              {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  )
}

详情页

// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
import { Suspense } from 'react'
import { postQueryOptions, commentsQueryOptions } from '../api/posts'

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ context: { queryClient }, params: { postId } }) => {
    // 关键数据:等待
    await queryClient.ensureQueryData(postQueryOptions(postId))
    // 非关键数据:不等待
    queryClient.prefetchQuery(commentsQueryOptions(postId))
  },
  component: PostDetailPage,
})

function PostDetailPage() {
  const { postId } = Route.useParams()
  const { data: post } = useSuspenseQuery(postQueryOptions(postId))

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <Suspense fallback={<div>加载评论中...</div>}>
        <CommentsSection postId={postId} />
      </Suspense>
    </article>
  )
}

function CommentsSection({ postId }: { postId: string }) {
  const { data: comments } = useSuspenseQuery(
    commentsQueryOptions(postId)
  )

  return (
    <section>
      <h2>评论</h2>
      <ul>
        {comments.map((comment) => (
          <li key={comment.id}>{comment.text}</li>
        ))}
      </ul>
    </section>
  )
}

变更后刷新

用户发表评论后,用 queryClient.invalidateQueries 刷新:

import { useMutation, useQueryClient } from '@tanstack/react-query'

function CommentForm({ postId }: { postId: string }) {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: (text: string) =>
      fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        body: JSON.stringify({ text }),
      }).then((r) => r.json()),
    onSuccess: () => {
      // 让评论缓存失效,自动重新加载
      queryClient.invalidateQueries({
        queryKey: ['posts', postId, 'comments'],
      })
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const formData = new FormData(e.currentTarget)
        mutation.mutate(formData.get('text') as string)
      }}
    >
      <input name="text" placeholder="写评论..." />
      <button type="submit" disabled={mutation.isPending}>
        发表
      </button>
    </form>
  )
}
Note

这是 Router + Query 集成最大的好处:变更后刷新数据特别简单。Router 自带缓存做这个要自己写一套 mutation 逻辑,Query 天然支持。

21.5 错误处理

useSuspenseQuery 时,查询出错会抛出错误,被 Router 的错误边界捕获。

// src/routes/posts.tsx
import { useRouter } from '@tanstack/react-router'
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
import { useEffect } from 'react'

export const Route = createFileRoute('/posts')({
  loader: ({ context: { queryClient } }) =>
    queryClient.ensureQueryData(postsQueryOptions),
  errorComponent: ({ error }) => {
    const router = useRouter()
    const resetBoundary = useQueryErrorResetBoundary()

    useEffect(() => {
      // 重置 Query 的错误状态,下次渲染会重新请求
      resetBoundary.reset()
    }, [resetBoundary])

    return (
      <div>
        <p>加载失败:{error.message}</p>
        <button onClick={() => router.invalidate()}>重试</button>
      </div>
    )
  },
  component: PostsPage,
})

关键点:

  1. useQueryErrorResetBoundary 重置 Query 的错误状态
  2. router.invalidate() 重新执行 loader,同时重置 Router 的错误边界
  3. 两个都要重置,否则只重试了一半
Tip

如果用户不是点重试,而是直接导航走了再回来,useEffect 里的 resetBoundary.reset() 会确保回来时重新请求。

21.6 SSR 集成:自动脱水与注水

做服务端渲染(SSR)时,服务端加载的数据要传给客户端。这个过程叫脱水(dehydrate)和注水(hydrate)。

手动做很麻烦:服务端序列化 QueryClient 状态、嵌到 HTML 里、客户端读出来恢复。TanStack 提供了集成包自动处理。

安装

npm install @tanstack/react-router-ssr-query

配置

// src/router.tsx
import { QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { routeTree } from './routeTree.gen'

export function getRouter() {
  // 每次请求创建新的 QueryClient(SSR 必须)
  const queryClient = new QueryClient()

  const router = createRouter({
    routeTree,
    context: { queryClient },
    defaultPreload: 'intent',
  })

  // 一行代码搞定 SSR 集成
  setupRouterSsrQueryIntegration({
    router,
    queryClient,
  })

  return router
}

setupRouterSsrQueryIntegration 自动做了这些事:

  • 服务端渲染时,序列化 QueryClient 状态嵌到 HTML
  • 客户端启动时,读取状态恢复 QueryClient
  • 渲染期间 resolve 的查询会流式传输给客户端
  • 自动包裹 QueryClientProvider
Note

SSR 环境下必须在 getRouter 函数里创建 QueryClient,不能全局共享。因为不同用户的请求不能共享缓存。每次调用 getRouter() 都创建新的实例。

自定义脱水选项

如果有些查询不想序列化到 HTML(比如包含敏感数据),可以过滤:

setupRouterSsrQueryIntegration({
  router,
  queryClient,
  dehydrateOptions: {
    // 标记了 meta.ssr === false 的查询不脱水
    shouldDehydrateQuery: (query) => query.meta?.ssr !== false,
  },
  hydrateOptions: {
    defaultOptions: {
      queries: {
        // 注水后的查询 5 分钟后回收
        gcTime: 5 * 60 * 1000,
      },
    },
  },
})

给查询打标记:

const secretQueryOptions = queryOptions({
  queryKey: ['secret'],
  queryFn: fetchSecret,
  meta: { ssr: false }, // SSR 时不脱水
})

useSuspenseQuery vs useQuery 在 SSR 中的区别

// 参与服务端渲染,数据会流式传输
const { data } = useSuspenseQuery(postsQueryOptions)

// 不参与服务端渲染,客户端注水后才发请求
const { data, isLoading } = useQuery(postsQueryOptions)
HookSSR 行为适用场景
useSuspenseQuery服务端执行,流式传输关键数据,首屏要显示
useQuery服务端不执行,客户端才发非关键数据,后台加载
Tip

简单记:首屏要看到的数据用 useSuspenseQuery,不着急的用 useQuery

21.7 流式传输:服务端 prefetchQuery

在 SSR 场景下,loader 里的 prefetchQuery 有特殊行为:服务端发出请求但不等待,渲染期间数据 resolve 了就流式传给客户端。

// src/routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
  loader: ({ context: { queryClient } }) => {
    // 不 await 也不 return,服务端发出请求后继续渲染
    // 数据 resolve 后流式传给客户端
    queryClient.prefetchQuery(slowStatsQueryOptions)
  },
  component: Dashboard,
})

function Dashboard() {
  const { data: stats } = useSuspenseQuery(slowStatsQueryOptions)
  return <div>{/* 显示统计 */}</div>
}

执行流程:

  1. 服务端执行 loader -> prefetchQuery 发出请求
  2. loader 立即返回(没等请求)-> 开始渲染
  3. 组件用 useSuspenseQuery -> 数据还没好 -> 挂起
  4. 服务端把已渲染的 HTML 流式传给客户端
  5. 请求 resolve -> 服务端继续渲染剩余部分 -> 流式传给客户端
  6. 客户端逐步显示内容
Warning

流式传输要求服务器支持(比如 Node.js 的流式响应)。具体配置在后面 SSR 章节详细讲。

21.8 小结

这一章把 Router 和 Query 串起来了:

  • 集成模式:loader 用 ensureQueryData 预热缓存,组件用 useSuspenseQuery 读取
  • 路由上下文:通过 createRootRouteWithContext 注入 QueryClient
  • defaultPreloadStaleTime: 0:集成 Query 时必须设,避免 Router 缓存干扰
  • 延迟加载prefetchQuery 发请求不等待,配合 Suspense 实现流式加载
  • 错误处理useQueryErrorResetBoundary + router.invalidate() 双重重置
  • SSR 集成@tanstack/react-router-ssr-query 一行代码搞定脱水注水
  • 流式传输:SSR 中 prefetchQuery 不 await,数据 resolve 后流式传给客户端

下一章开始进入 TanStack Start,这是基于 Router + Vite 的全栈框架。Router 和 Query 的集成知识在 Start 里同样适用。