【React 純前端實戰】#04 Router + Query 整合:loader 模式與預取策略

測驗:Router + Query 整合:loader 模式與預取策略

共 5 題,點選答案後會立即顯示結果

1. 在元件內抓資料時,巢狀元件會導致「Loading 瀑布流」問題。這個問題的核心原因是什麼?

  • A. 每個元件都使用不同的 queryKey,導致快取無法共用
  • B. 子元件要等父元件渲染完才開始抓資料,形成串聯等待
  • C. TanStack Query 預設不允許並行請求
  • D. 瀏覽器限制同時只能發送一個 HTTP 請求

2. 要讓 TanStack Router 的 loader 能使用 queryClient,需要透過什麼機制?

  • A. 在每個路由檔案中單獨 import queryClient
  • B. 把 queryClient 存在全域變數 window 上
  • C. 用 createRootRouteWithContext 定義 context,並在 createRouter 時傳入 queryClient
  • D. 在 QueryClientProvider 的 props 中設定 routerEnabled

3. 以下兩段 loader 程式碼的差異是什麼?

// 寫法 A loader: async ({ params, context }) => { await context.queryClient.ensureQueryData(options) } // 寫法 B loader: ({ params, context }) => { context.queryClient.prefetchQuery(options) }
  • A. 寫法 A 不使用快取,寫法 B 會使用快取
  • B. 寫法 A 會等資料抓完才進入頁面,寫法 B 觸發請求後立刻進入頁面
  • C. 寫法 A 用於讀取資料,寫法 B 用於寫入資料
  • D. 兩者行為完全相同,只是語法不同

4. 為什麼文章建議把 query options 抽成共用函式,讓 loader 和元件都使用同一個函式?

const postQueryOptions = (postId: string) => ({ queryKey: [‘posts’, postId], queryFn: () => fetch(`/api/posts/${postId}`).then(r => r.json()), })
  • A. 為了減少程式碼行數,讓檔案更短
  • B. TanStack Query 強制要求 queryOptions 必須用函式定義
  • C. 為了讓 TypeScript 能自動推導 loader 的回傳型別
  • D. 確保 loader 存進快取的 queryKey 和元件取出時的 queryKey 完全一致,避免快取失效

5. 一個列表頁使用 search params 驅動查詢(分頁、排序),以下哪個做法是正確的?

  • A. 用 useState 儲存分頁狀態,在 useEffect 裡同步到 URL
  • B. 在 loader 中用 window.location.search 手動解析 search params
  • C. 用 validateSearch 驗證 search params,在 loader 中透過 search 參數存取,並將 search 放進 queryKey
  • D. 把分頁和排序資訊存在 localStorage,每次進入頁面時讀取

**系列**:React 純前端實戰(第 4 篇,共 5 篇)
**難度**:L1-起步
**前置知識**:讀完本系列第 2 篇(TanStack Router)和第 3 篇(TanStack Query)

一句話說明

在路由層就開始抓資料,讓使用者切換頁面時不用看到空白畫面。


為什麼要在路由層抓資料?

Loading 瀑布流問題

上一篇我們學了 useQuery——在元件裡面抓資料。但這有一個問題:元件要先渲染,才會開始抓資料。如果有巢狀元件,就會形成「瀑布流」。

傳統做法(元件內抓資料):

  路由匹配 /posts/5
    ↓
  PostLayout 元件渲染
    ↓ 開始抓作者資料...
    ↓ 等待中...
    ↓ 拿到作者資料了!
    ↓
  PostDetail 元件渲染(子元件)
    ↓ 開始抓文章資料...
    ↓ 等待中...
    ↓ 拿到文章資料了!
    ↓
  Comments 元件渲染(更深的子元件)
    ↓ 開始抓留言資料...
    ↓ 等待中...
    ↓ 拿到了!終於全部顯示!

總等待時間 = 作者 + 文章 + 留言(串聯等待)

問題在哪? 這三筆資料之間沒有依賴關係,它們可以同時抓。但因為元件是一層一層渲染的,變成了排隊串聯等待。

loader 的解法

loader 做法(路由層抓資料):

  路由匹配 /posts/5
    ↓
  loader 執行(元件還沒渲染!)
    → 同時開始抓作者資料
    → 同時開始抓文章資料
    → 同時開始抓留言資料
    ↓ 全部抓完(或至少開始抓了)
    ↓
  元件渲染 → 資料已經準備好!

總等待時間 = max(作者, 文章, 留言)(並行等待)

翻譯:loader 讓你在「進入頁面之前」就開始抓資料,而不是等元件一層一層渲染後才開始。這就是 TanStack Router 和 TanStack Query 整合的核心價值。


把 QueryClient 傳進 Router(routerContext)

要在 loader 裡面用 TanStack Query,首先要讓 Router 能存取到 queryClient。這是透過 routerContext 實現的。

第一步:定義 Router Context 型別

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

// 定義 Router 的 context 型別
interface RouterContext {
  queryClient: QueryClient        // 所有路由都能存取到 queryClient
}

export const Route = createRootRouteWithContext<RouterContext>()({
  component: () => <Outlet />,
})
Code language: JavaScript (javascript)

逐行翻譯

程式碼 翻譯
interface RouterContext 定義「路由的共享背包」裡面有什麼東西
queryClient: QueryClient 背包裡放了快取管理器
createRootRouteWithContext<RouterContext>() 建立根路由,並告訴它「背包的型別長這樣」

第二步:建立 Router 時傳入 context

// src/main.tsx(或 src/app.tsx)
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { routeTree } from './routeTree.gen'

// 建立快取管理器
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,    // 5 分鐘內算新鮮
    },
  },
})

// 建立 Router,把 queryClient 放進 context
const router = createRouter({
  routeTree,
  context: { queryClient },         // 這裡!把 queryClient 傳進去
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  )
}
Code language: JavaScript (javascript)

逐行翻譯

程式碼 翻譯
context: { queryClient } 把 queryClient 放進路由的「共享背包」
<QueryClientProvider><RouterProvider> Query 的 Provider 要在最外層

設定完成後,**所有路由的 loader 都能透過 context.queryClient 存取到快取管理器**。這是一次性的設定,做完就不用再管了。


Loader 基礎:在路由層抓資料

loader 函式的基本結構

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

export const Route = createFileRoute('/posts/$postId')({
  loader: ({ params, context }) => {
    // 在這裡抓資料,元件渲染之前執行
    console.log(params.postId)           // 網址參數
    console.log(context.queryClient)     // 快取管理器
  },
  component: PostDetail,
})
Code language: JavaScript (javascript)

loader 能存取什麼?

參數 意思 範例
params 網址中的動態參數 { postId: '5' }
context 路由的共享背包 { queryClient }
search 驗證過的 search params { page: 1, sort: 'date' }
abortController 取消信號(路由離開時自動取消) 傳給 fetch 用

loader 的執行時機

使用者點了一個連結(/posts/5)
  ↓
Router 匹配到 /posts/$postId 路由
  ↓
執行 beforeLoad(如果有的話,例如認證檢查)
  ↓
執行 loader ← 在這裡抓資料
  ↓
loader 完成(或開始抓了)
  ↓
渲染 component
Code language: PHP (php)

重點:loader 在 component 渲染之前執行。這就是為什麼它能消除 loading 瀑布流——資料在畫面出現前就準備好了。


兩種整合模式:Ensure vs Prefetch

這是本篇最核心的概念。TanStack Router 的 loader 搭配 TanStack Query,有兩種主要模式。

模式一:Ensure 模式(等到有資料才進頁面)

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

// 定義 query 選項(可以在 loader 和元件之間共用)
const postQueryOptions = (postId: string) => ({
  queryKey: ['posts', postId],
  queryFn: () =>
    fetch(`/api/posts/${postId}`).then(res => {
      if (!res.ok) throw new Error('Failed to fetch')
      return res.json()
    }),
})

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params, context }) => {
    // ensureQueryData:快取有就直接用,沒有就等抓完
    await context.queryClient.ensureQueryData(
      postQueryOptions(params.postId)
    )
  },
  component: PostDetail,
})

function PostDetail() {
  const { postId } = Route.useParams()
  // 資料一定已經在快取裡了(loader 保證的)
  const { data: post } = useQuery(postQueryOptions(postId))

  return <h1>{post.title}</h1>    // 不需要處理 loading 狀態!
}
Code language: JavaScript (javascript)

逐行翻譯

程式碼 翻譯
postQueryOptions(postId) 把 query 的設定抽成函式,loader 和元件共用同一份
ensureQueryData(...) 「確保快取裡有這份資料」——有就直接回傳,沒有就去抓,抓完才繼續
await 等到資料確實在快取裡了,才讓頁面進入
元件裡的 useQuery 因為 loader 已經確保有資料了,useQuery 會直接從快取拿

Ensure 模式的行為

快取裡有資料(且未過期):
  loader → 直接回傳 → 瞬間進入頁面

快取裡沒資料:
  loader → 開始抓 → 等待中... → 抓到了 → 進入頁面
  (使用者在舊頁面等待,或看到 pending UI)

模式二:Prefetch 模式(先開始抓,不等結果)

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

const postQueryOptions = (postId: string) => ({
  queryKey: ['posts', postId],
  queryFn: () =>
    fetch(`/api/posts/${postId}`).then(res => {
      if (!res.ok) throw new Error('Failed to fetch')
      return res.json()
    }),
})

export const Route = createFileRoute('/posts/$postId')({
  loader: ({ params, context }) => {
    // prefetchQuery:開始抓,但不等結果
    context.queryClient.prefetchQuery(
      postQueryOptions(params.postId)
    )
    // 注意:沒有 await!
  },
  component: PostDetail,
})

function PostDetail() {
  const { postId } = Route.useParams()
  // useSuspenseQuery 接手:如果資料還沒回來,會觸發 Suspense
  const { data: post } = useSuspenseQuery(postQueryOptions(postId))

  return <h1>{post.title}</h1>
}
Code language: JavaScript (javascript)

逐行翻譯

程式碼 翻譯
prefetchQuery(...) 「開始抓資料,但我不等你」——觸發請求就馬上繼續
沒有 await 不等結果,loader 立刻完成
useSuspenseQuery(...) 在元件裡接手——如果資料還沒回來,「暫停」元件渲染,顯示 Suspense fallback

Prefetch 模式的行為

loader → 觸發請求(不等待)→ 立刻進入頁面
  ↓
元件渲染 → useSuspenseQuery 發現資料還在路上
  ↓
Suspense fallback 顯示(Loading...)
  ↓
資料回來 → 元件完成渲染

兩種模式怎麼選?

Ensure 模式 Prefetch 模式
loader 的寫法 await ensureQueryData() prefetchQuery()(不 await)
元件用什麼 hook useQuery useSuspenseQuery
頁面切換行為 等資料抓完才進新頁面 立刻進新頁面,在新頁面顯示 loading
適合場景 資料量小、必須有資料才有意義的頁面 頁面有多區塊,部分區塊可以先顯示
使用者體驗 停在舊頁面等待,但進入新頁面時完整呈現 立刻看到新頁面的框架

**經驗法則**:不確定的話先用 **Ensure 模式**,因為它比較簡單——不用處理 Suspense boundary。等你發現某個頁面的等待時間太長再考慮 Prefetch 模式。


共用 Query Options:避免 loader 和元件的 key 不一致

上面兩個範例都有一個關鍵做法:把 query options 抽成共用函式

// 把 query options 抽出來
const postQueryOptions = (postId: string) => ({
  queryKey: ['posts', postId],
  queryFn: () => fetch(`/api/posts/${postId}`).then(r => r.json()),
})

// loader 用它
loader: async ({ params, context }) => {
  await context.queryClient.ensureQueryData(postQueryOptions(params.postId))
}

// 元件也用它
const { data } = useQuery(postQueryOptions(postId))
Code language: JavaScript (javascript)

為什麼這很重要? 如果 loader 裡面的 queryKey['posts', postId],但元件裡不小心寫成 ['post', postId](少了 s),loader 抓好的資料就存在錯的 key 底下,元件找不到快取,又會重新抓一次——loader 等於白做了。

共用函式確保 loader 存進去的 key元件取出來的 key 一定相同。


Search Params 驅動查詢

第 2 篇我們學了 validateSearch 做 search params 的型別驗證。現在把它和 loader + Query 整合起來。

完整範例:帶分頁和篩選的列表頁

// src/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'

// 第一步:定義 search params schema
const postsSearchSchema = z.object({
  page: z.number().default(1),                            // 分頁
  sort: z.enum(['date', 'title']).default('date'),        // 排序
  status: z.enum(['all', 'draft', 'published']).optional(), // 篩選(可選)
})

// 第二步:把 search params 整合進 query options
const postsQueryOptions = (search: z.infer<typeof postsSearchSchema>) => ({
  queryKey: ['posts', 'list', search],   // search params 是 queryKey 的一部分!
  queryFn: () =>
    fetch(`/api/posts?page=${search.page}&sort=${search.sort}`)
      .then(res => res.json()),
})

// 第三步:路由定義
export const Route = createFileRoute('/posts/')({
  validateSearch: postsSearchSchema,        // 驗證 search params
  loader: async ({ search, context }) => {
    // search 已經是驗證過的、有型別的物件
    await context.queryClient.ensureQueryData(
      postsQueryOptions(search)
    )
  },
  component: PostList,
})

// 第四步:元件使用
function PostList() {
  const search = Route.useSearch()          // 取得驗證過的 search params
  const { data: posts } = useQuery(postsQueryOptions(search))

  return (
    <div>
      <h1>文章列表(第 {search.page} 頁)</h1>
      <ul>
        {posts.map(p => <li key={p.id}>{p.title}</li>)}
      </ul>
    </div>
  )
}
Code language: JavaScript (javascript)

逐行翻譯

程式碼 翻譯
queryKey: ['posts', 'list', search] 不同的 search params 會產生不同的快取
loader: ({ search, context }) loader 能直接拿到驗證過的 search params
Route.useSearch() 元件中取得和 loader 相同的 search params

URL 就是 Single Source of Truth

使用者在第 2 頁,按日期排序:
  URL: /posts?page=2&sort=date

使用者把這個 URL 分享給同事:
  同事打開 → validateSearch 解析 → page=2, sort='date'
  → loader 用這些參數抓資料 → 看到一模一樣的畫面
Code language: PHP (php)

翻譯:因為狀態都在 URL 裡,所以分享連結就能還原畫面。不用額外存什麼「目前是第幾頁」的狀態——URL 就是答案。

更新 Search Params

import { Link } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'

function PostListControls() {
  const navigate = useNavigate()
  const { page, sort } = Route.useSearch()

  return (
    <div>
      {/* 方法 1:用 Link(像換頁按鈕) */}
      <Link
        to="/posts"
        search={{ page: page + 1, sort }}     // 下一頁
      >
        下一頁
      </Link>

      {/* 方法 2:用 navigate(像下拉選單改排序) */}
      <select
        value={sort}
        onChange={(e) =>
          navigate({
            to: '/posts',
            search: { page: 1, sort: e.target.value },  // 改排序,回到第 1 頁
          })
        }
      >
        <option value="date">按日期</option>
        <option value="title">按標題</option>
      </select>
    </div>
  )
}
Code language: JavaScript (javascript)

翻譯:改 search params 就是改 URL。URL 一變 → validateSearch 重新驗證 → loader 用新參數抓資料 → 元件自動更新。整個流程自動串起來。


Pending UI:路由切換中的 Loading 處理

用 Ensure 模式時,loader 會等資料抓完才切換頁面。如果抓資料要花幾秒鐘,使用者不會看到任何反應——這體驗不好。TanStack Router 提供了 Pending UI 來解決。

pendingComponent:路由級 Loading

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params, context }) => {
    await context.queryClient.ensureQueryData(
      postQueryOptions(params.postId)
    )
  },
  pendingComponent: () => <div>文章載入中...</div>,   // loader 執行中時顯示
  pendingMinMs: 200,   // 至少等 200ms 才顯示 pending(避免閃爍)
  pendingMs: 1000,     // loader 超過 1 秒才顯示 pending
  component: PostDetail,
})
Code language: JavaScript (javascript)

逐行翻譯

程式碼 翻譯
pendingComponent loader 在等待時要顯示什麼畫面
pendingMs: 1000 loader 跑超過 1 秒才會出現 pending 畫面(避免太快閃一下)
pendingMinMs: 200 pending 畫面至少顯示 200ms(避免才出現就消失)

Pending 的時間線

loader 開始執行
  │
  │ 0ms ─────── 500ms ─────── 1000ms ─────── 1200ms ───── 1500ms
  │                              │               │            │
  │                              │  顯示 pending  │            │
  │                          pendingMs           pendingMinMs  │
  │                         (超過1秒才顯示)    (至少顯示200ms)   │
  │                                                           │
  │                                                    loader 完成
  │                                                    顯示頁面

  如果 loader 在 500ms 內完成 → 不會顯示 pending(太快了沒必要)
  如果 loader 花了 1.1 秒 → 在 1 秒時顯示 pending,至少顯示到 1.2Code language: CSS (css)

全域 Pending Indicator

你也可以在根路由設定全域的 loading 指示器:

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

export const Route = createRootRouteWithContext<RouterContext>()({
  component: () => (
    <div>
      <nav>導覽列</nav>
      <Outlet />
    </div>
  ),
  pendingComponent: () => (
    <div style={{ textAlign: 'center', padding: '2rem' }}>
      頁面切換中...
    </div>
  ),
})
Code language: JavaScript (javascript)

錯誤處理:errorComponent

當 loader 抓資料失敗時,你需要告訴使用者發生了什麼事。

基本用法

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params, context }) => {
    await context.queryClient.ensureQueryData(
      postQueryOptions(params.postId)
    )
  },
  errorComponent: ({ error }) => (
    <div>
      <h2>載入失敗</h2>
      <p>{error.message}</p>
      <button onClick={() => window.location.reload()}>重試</button>
    </div>
  ),
  component: PostDetail,
})
Code language: JavaScript (javascript)

翻譯:如果 loader 裡面的 ensureQueryData 抓資料失敗(網路錯誤、404 等),TanStack Router 不會渲染 component,而是改渲染 errorComponent

loader 拋錯的流程

loader 執行
  ↓
ensureQueryData 發出請求
  ↓
API 回傳 404
  ↓
queryFn 裡面的 throw new Error('Failed to fetch') 被觸發
  ↓
ensureQueryData 將錯誤向上拋出
  ↓
TanStack Router 接住錯誤
  ↓
渲染 errorComponent(而不是 component)
Code language: JavaScript (javascript)

全域 vs 路由級錯誤處理

// 全域錯誤處理(根路由)
// src/routes/__root.tsx
export const Route = createRootRouteWithContext<RouterContext>()({
  errorComponent: ({ error }) => (
    <div>出了點問題:{error.message}</div>    // 所有子路由的兜底
  ),
  component: () => <Outlet />,
})

// 路由級錯誤處理(特定頁面)
// src/routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
  errorComponent: ({ error }) => (
    <div>找不到這篇文章</div>                   // 只有這個路由用
  ),
  // ...
})
Code language: JavaScript (javascript)

翻譯:錯誤會先看當前路由有沒有 errorComponent,有就用。沒有的話往上層路由找,最後到根路由的全域 errorComponent。就像 try-catch 的冒泡機制。


完整範例:把所有概念串起來

以下是一個完整的、把 Router 和 Query 整合在一起的範例。這大概是 AI 幫你生成的程式碼會長的樣子。

檔案結構

src/
├── main.tsx                 ← Router + Query 初始化
├── routes/
│   ├── __root.tsx           ← 根路由(context 定義、全域 layout)
│   ├── index.tsx            ← 首頁
│   └── posts/
│       ├── index.tsx        ← /posts(帶分頁、篩選)
│       └── $postId.tsx      ← /posts/123(文章詳情)
└── api/
    └── posts.ts             ← query options 集中管理
Code language: PHP (php)

api/posts.ts——集中管理 Query Options

// src/api/posts.ts
import { z } from 'zod'

// search params schema
export const postsSearchSchema = z.object({
  page: z.number().default(1),
  sort: z.enum(['date', 'title']).default('date'),
})

export type PostsSearch = z.infer<typeof postsSearchSchema>

// Query Options
export const postsQueryOptions = (search: PostsSearch) => ({
  queryKey: ['posts', 'list', search] as const,
  queryFn: async () => {
    const res = await fetch(
      `/api/posts?page=${search.page}&sort=${search.sort}`
    )
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return res.json() as Promise<{ posts: Post[]; totalPages: number }>
  },
})

export const postDetailQueryOptions = (postId: string) => ({
  queryKey: ['posts', 'detail', postId] as const,
  queryFn: async () => {
    const res = await fetch(`/api/posts/${postId}`)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return res.json() as Promise<Post>
  },
})
Code language: JavaScript (javascript)

routes/posts/index.tsx——列表頁(Ensure 模式 + Search Params)

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

export const Route = createFileRoute('/posts/')({
  // 驗證 search params
  validateSearch: postsSearchSchema,

  // loader:路由匹配後、元件渲染前執行
  loader: async ({ search, context }) => {
    await context.queryClient.ensureQueryData(
      postsQueryOptions(search)
    )
  },

  // Pending UI
  pendingComponent: () => <div>載入文章列表中...</div>,
  pendingMs: 500,

  // 錯誤畫面
  errorComponent: ({ error }) => (
    <div>無法載入文章列表:{error.message}</div>
  ),

  component: PostList,
})

function PostList() {
  const search = Route.useSearch()
  const { data } = useQuery(postsQueryOptions(search))

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

      {/* 分頁 */}
      <div>
        {search.page > 1 && (
          <Link to="/posts" search={{ ...search, page: search.page - 1 }}>
            上一頁
          </Link>
        )}
        <span> 第 {search.page} 頁 </span>
        {search.page < data.totalPages && (
          <Link to="/posts" search={{ ...search, page: search.page + 1 }}>
            下一頁
          </Link>
        )}
      </div>
    </div>
  )
}
Code language: JavaScript (javascript)

routes/posts/$postId.tsx——詳情頁

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

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params, context }) => {
    await context.queryClient.ensureQueryData(
      postDetailQueryOptions(params.postId)
    )
  },
  pendingComponent: () => <div>載入文章中...</div>,
  errorComponent: ({ error }) => (
    <div>
      <h2>找不到文章</h2>
      <p>{error.message}</p>
    </div>
  ),
  component: PostDetail,
})

function PostDetail() {
  const { postId } = Route.useParams()
  const { data: post } = useQuery(postDetailQueryOptions(postId))

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  )
}
Code language: JavaScript (javascript)

核心概念翻譯

你會看到 意思
createRootRouteWithContext<{queryClient}>() 建立根路由,讓所有子路由能存取 queryClient
context: { queryClient } 在建立 Router 時把 queryClient 放進共享背包
loader: ({ params, search, context }) 路由匹配後、元件渲染前執行的資料抓取函式
ensureQueryData(options) 確保快取有資料——有就用,沒有就等抓完
prefetchQuery(options) 開始抓資料但不等結果——搭配 useSuspenseQuery 使用
useSuspenseQuery(options) 資料沒到就暫停渲染,搭配 Suspense boundary 使用
pendingComponent loader 等待時顯示的畫面
pendingMs / pendingMinMs 控制 pending 畫面的出現和最短顯示時間
errorComponent loader 出錯時顯示的畫面
validateSearch + search in loader 用驗證過的 search params 組合 queryKey

Vibe Coder 檢查點

看到 AI 生成了 Router + Query 整合的程式碼時,確認這些事:

  • [ ] 根路由有用 createRootRouteWithContext 嗎? 如果 loader 裡要用 queryClient,根路由必須用這個 API 定義 context
  • [ ] createRouter 有傳入 context: { queryClient } 嗎? 沒傳的話 loader 裡面拿不到 queryClient
  • [ ] loader 和元件用的 queryKey 一致嗎? 最好的做法是抽成共用的 queryOptions 函式
  • [ ] ensureQueryData 和 prefetchQuery 有搞清楚差別嗎? 前者 await(等資料),後者不 await(不等)
  • [ ] 用 prefetchQuery 時,元件有用 useSuspenseQuery 嗎? 用普通 useQuery 的話資料可能還沒到
  • [ ] 有分頁/篩選的頁面有 validateSearch 嗎? search params 應該是驗證過的,不是自己 parse 的
  • [ ] search params 有放進 queryKey 嗎? 不同的分頁/篩選條件應該是不同的快取
  • [ ] 有 pendingComponent 或 errorComponent 嗎? 使用者需要知道發生了什麼事

必看懂 vs 知道就好

必看懂(本系列會一直出現)

概念 為什麼重要
ensureQueryData in loader 最常見的整合模式,每個有資料的頁面都會用
共用 query options 確保 loader 和元件的 queryKey 一致
context.queryClient 在 loader 裡面操作快取的唯一方式
validateSearch + loader 的 search 分頁、篩選、排序頁面的標準做法
pendingComponent / errorComponent 基本的 UX 處理

知道就好(遇到再查)

  • prefetchQuery + useSuspenseQuery:進階的 Prefetch 模式,需要理解 Suspense boundary
  • Deferred Data:loader 回傳 Promise,元件用 Await 元件等待(部分資料先顯示)
  • Route Preloading:滑鼠 hover 到 Link 上就開始 prefetch(進一步優化體驗)
  • Streaming:搭配 Server Functions 做資料串流(需要 SSR 環境)
  • Critical vs Non-Critical Data:在 loader 中區分哪些資料必須等、哪些可以延遲

本篇小結

這篇幫你看懂了 TanStack Router 和 TanStack Query 整合的核心概念:

  1. Loading 瀑布流問題——元件內抓資料會導致串聯等待,在路由的 loader 層處理可以並行抓取
  2. routerContext 設定——把 queryClient 放進 Router 的 context,讓所有 loader 都能使用
  3. Ensure 模式——await ensureQueryData(),快取有就用、沒有就等抓完再進頁面
  4. Prefetch 模式——prefetchQuery()(不 await),開始抓但不等,元件用 useSuspenseQuery 接手
  5. 共用 Query Options——抽成函式確保 loader 和元件的 queryKey 一致
  6. Search Params 驅動查詢——URL 是 single source of truth,分享連結就能還原狀態
  7. Pending UI 和 Error 處理——pendingComponenterrorComponent 給使用者適當的回饋

下一篇是系列最後一篇,我們會用 Supabase 作為 BaaS,把前面學到的所有概念串起來,打造一個完整的 CRUD 應用。

進階測驗:Router + Query 整合:loader 模式與預取策略

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你正在開發一個文章詳情頁,API 回應通常需要 2-3 秒。PM 反映「點連結後畫面完全沒反應,使用者以為按鈕壞了」。你該怎麼處理? 情境題

export const Route = createFileRoute(‘/posts/$postId’)({ loader: async ({ params, context }) => { await context.queryClient.ensureQueryData( postDetailQueryOptions(params.postId) ) }, component: PostDetail, })
  • A. 把 ensureQueryData 換成 prefetchQuery,不等資料就進頁面
  • B. 在 component 裡面加一個 useState 控制 loading 狀態
  • C. 加上 pendingComponentpendingMs,讓 loader 等待超過一定時間後顯示 loading 畫面
  • D. 把 loader 裡的 await 拿掉,讓頁面先切換

2. 你的列表頁需要支援分頁和排序功能,PM 要求「分享網址時對方要能看到一模一樣的分頁和排序狀態」。以下哪個架構最符合需求? 情境題

  • A. 用 useState 管理 page 和 sort,在 useEffect 裡抓資料
  • B. 用 validateSearch 定義 search params schema,loader 中用 search 參數抓資料,search params 放進 queryKey
  • C. 用 React Context 儲存分頁狀態,透過 Provider 傳遞給所有元件
  • D. 用 localStorage 儲存分頁和排序,進頁面時自動讀取

3. 你的頁面有三個區塊:標題列(不需要 API)、文章內容(API 較快,約 200ms)、推薦文章(API 較慢,約 3 秒)。你希望使用者能盡快看到標題和文章內容,推薦文章可以晚點顯示。最適合的做法是? 情境題

  • A. 在 loader 中用 await ensureQueryData 同時等兩個 API 完成
  • B. 不使用 loader,全部在元件裡用 useQuery 抓資料
  • C. 在 loader 中只抓文章內容,推薦文章完全不預抓
  • D. 在 loader 中用 await ensureQueryData 抓文章內容,用 prefetchQuery(不 await)開始抓推薦文章,元件中用 useSuspenseQuery 搭配 Suspense boundary 接手推薦文章

4. 同事設定了 Router + Query 的整合,但 loader 執行時報錯:Cannot read properties of undefined (reading 'ensureQueryData')。以下是他的程式碼,問題出在哪裡? 錯誤診斷

// __root.tsx import { createRootRoute, Outlet } from ‘@tanstack/react-router’ export const Route = createRootRoute({ component: () => <Outlet />, }) // main.tsx const router = createRouter({ routeTree, }) // posts/$postId.tsx export const Route = createFileRoute(‘/posts/$postId’)({ loader: async ({ params, context }) => { await context.queryClient.ensureQueryData(…) }, })
  • A. ensureQueryData 應該改成 prefetchQuery
  • B. 根路由用了 createRootRoute 而非 createRootRouteWithContext,且 createRouter 沒有傳入 context: { queryClient }
  • C. QueryClientProvider 應該放在 RouterProvider 裡面而不是外面
  • D. loader 函式不支援 async/await,需要改用 callback 寫法

5. 你在 loader 中用 ensureQueryData 預先抓取了文章資料,但頁面渲染時元件裡的 useQuery 又發了一次相同的 API 請求。以下是程式碼,最可能的問題是什麼? 錯誤診斷

// loader loader: async ({ params, context }) => { await context.queryClient.ensureQueryData({ queryKey: [‘posts’, params.postId], queryFn: () => fetchPost(params.postId), }) } // component function PostDetail() { const { postId } = Route.useParams() const { data } = useQuery({ queryKey: [‘post’, postId], // <– 注意這行 queryFn: () => fetchPost(postId), }) return <h1>{data?.title}</h1> }
  • A. ensureQueryData 不會把資料存進快取,需要改用 fetchQuery
  • B. staleTime 設定為 0,導致資料立刻過期被重新抓取
  • C. loader 用的 queryKey 是 ['posts', postId](有 s),元件用的是 ['post', postId](沒有 s),key 不一致導致快取沒命中
  • D. useQuery 不支援從 loader 的快取讀取資料,應該改用 useSuspenseQuery

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *