測驗:Router + Query 整合:loader 模式與預取策略
共 5 題,點選答案後會立即顯示結果
1. 在元件內抓資料時,巢狀元件會導致「Loading 瀑布流」問題。這個問題的核心原因是什麼?
2. 要讓 TanStack Router 的 loader 能使用 queryClient,需要透過什麼機制?
3. 以下兩段 loader 程式碼的差異是什麼?
4. 為什麼文章建議把 query options 抽成共用函式,讓 loader 和元件都使用同一個函式?
5. 一個列表頁使用 search params 驅動查詢(分頁、排序),以下哪個做法是正確的?
**系列**: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.2 秒
Code 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 整合的核心概念:
- Loading 瀑布流問題——元件內抓資料會導致串聯等待,在路由的 loader 層處理可以並行抓取
- routerContext 設定——把 queryClient 放進 Router 的 context,讓所有 loader 都能使用
- Ensure 模式——
await ensureQueryData(),快取有就用、沒有就等抓完再進頁面 - Prefetch 模式——
prefetchQuery()(不 await),開始抓但不等,元件用useSuspenseQuery接手 - 共用 Query Options——抽成函式確保 loader 和元件的 queryKey 一致
- Search Params 驅動查詢——URL 是 single source of truth,分享連結就能還原狀態
- Pending UI 和 Error 處理——
pendingComponent、errorComponent給使用者適當的回饋
下一篇是系列最後一篇,我們會用 Supabase 作為 BaaS,把前面學到的所有概念串起來,打造一個完整的 CRUD 應用。
進階測驗:Router + Query 整合:loader 模式與預取策略
共 5 題,包含情境題與錯誤診斷題。