測驗:TanStack Query 資料抓取與快取管理
共 5 題,點選答案後會立即顯示結果
1. 為什麼從 API 抓回來的資料(Server State)不適合只用 useState 管理?
2. 在 useQuery 中,queryKey 的作用是什麼?
3. 使用者第一次進入頁面時 isLoading = true,離開後再回來時 isLoading = false 但 isFetching = true。這代表什麼?
4. 在 useMutation 的 onSuccess 中呼叫 invalidateQueries({ queryKey: ['posts'] }),會發生什麼事?
5. Optimistic Updates(樂觀更新)的核心三步驟,正確順序是什麼?
**系列**:React 純前端實戰(第 3 篇,共 5 篇)
**難度**:L1-起步
**前置知識**:讀完本系列第 1-2 篇、基本的 async/await 和 fetch API 概念
一句話說明
TanStack Query 幫你管理「從 API 抓回來的資料」——自動快取、自動重新抓取、自動處理載入和錯誤狀態。
為什麼需要 TanStack Query?
Server State vs Client State
當你用 AI 生成 React 程式碼時,你會看到兩種完全不同的「狀態」:
| Client State(客戶端狀態) | Server State(伺服器狀態) | |
|---|---|---|
| 資料來源 | 使用者操作產生 | 從 API / 資料庫抓回來 |
| 舉例 | 表單輸入、側邊欄開關、深色模式 | 使用者清單、文章列表、訂單資料 |
| 特性 | 你完全控制,改了就改了 | 別人也可能改,隨時可能過期 |
| 適合的工具 | useState、Zustand |
TanStack Query |
翻譯:表單打了什麼字、選單開或關——這些你自己管就好(useState)。但是「後端那邊的文章列表」可能隨時有人新增刪除,你不能只存一份就不管了——這就是 TanStack Query 要解決的問題。
不用 TanStack Query 時,你可能會看到這種寫法
function PostList() {
const [posts, setPosts] = useState([]); // 存資料
const [loading, setLoading] = useState(true); // 存載入狀態
const [error, setError] = useState(null); // 存錯誤
useEffect(() => {
setLoading(true);
fetch('/api/posts')
.then(res => res.json())
.then(data => setPosts(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error!</p>;
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
Code language: JavaScript (javascript)問題在哪?
- 每個元件都要自己寫 loading / error 狀態
- 沒有快取:切換頁面再回來,又要重新抓
- 沒有自動重新抓取:資料過期了也不知道
- 多個元件用同一份資料時,各抓各的
用 TanStack Query 後的同樣功能
function PostList() {
const { data: posts, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json()),
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error!</p>;
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
Code language: JavaScript (javascript)翻譯:「去抓 /api/posts 的資料,用 'posts' 這個名字存起來(快取)。載入中、出錯、成功的狀態我都幫你管好了。」
QueryClientProvider:初始化設定
在你的 App 最外層,你會看到這段設定。這是 TanStack Query 的起點。
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 分鐘內算「新鮮」
gcTime: 1000 * 60 * 30, // 快取保留 30 分鐘
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterOrYourApp />
</QueryClientProvider>
);
}
Code language: JavaScript (javascript)逐行翻譯
const queryClient = new QueryClient({ // 建立一個快取管理器
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 資料抓回來後 5 分鐘內不會重新抓
gcTime: 1000 * 60 * 30, // 沒人用的快取 30 分鐘後才清掉
},
},
});
Code language: JavaScript (javascript)<QueryClientProvider client={queryClient}> {/* 把快取管理器提供給整個 App */}
<RouterOrYourApp />
</QueryClientProvider>
Code language: HTML, XML (xml)知道就好:staleTime 和 gcTime 這裡設的是「全域預設值」。每個 useQuery 可以自己覆蓋。
useQuery:讀取資料的核心
最小範例
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json()),
});
Code language: JavaScript (javascript)核心概念翻譯
| 你會看到 | 意思 |
|---|---|
queryKey: ['posts'] |
這份資料的名字(用來識別和快取) |
queryFn: () => ... |
怎麼抓資料(回傳 Promise 的函式) |
data |
抓回來的資料 |
isLoading |
第一次載入中(還沒有任何資料) |
isFetching |
正在抓資料(包含背景重新抓取) |
error |
如果出錯,這裡有錯誤資訊 |
isLoading vs isFetching——一定要看懂的差異
第一次進頁面:
isLoading = true ← 完全沒資料,顯示 Loading 畫面
isFetching = true
抓到資料後切換到別的頁面,再切回來:
isLoading = false ← 有舊資料可以先顯示
isFetching = true ← 但背景正在重新抓取
背景抓取完成:
isLoading = false
isFetching = false ← 資料已是最新
Code language: JavaScript (javascript)翻譯:isLoading 是「完全空白,什麼都沒有」。isFetching 是「正在更新,但可能有舊資料可以先看」。大部分情況下你用 isLoading 來決定要不要顯示 Loading 畫面。
staleTime 和 gcTime
這兩個設定控制了快取的行為,是 TanStack Query 最重要的概念之一。
staleTime(新鮮時間):
「資料抓回來之後,多久之內不需要重新抓?」
staleTime: 0 → 一回來就算過期(預設值)
staleTime: 1000 * 60 → 1 分鐘內算新鮮
staleTime: Infinity → 永遠不過期(除非手動刷新)
Code language: JavaScript (javascript)gcTime(垃圾回收時間):
「沒有元件在用這份快取資料時,保留多久才清掉?」
gcTime: 1000 * 60 * 5 → 5 分鐘後清掉(預設值)
gcTime: Infinity → 永遠不清
Code language: JavaScript (javascript)翻譯:staleTime 決定「多快要重新抓」,gcTime 決定「沒人看的時候多久才丟掉」。
queryKey 的設計——為什麼 key 結構很重要
queryKey 不只是個名字,它是一個陣列,TanStack Query 用它來決定「這是不是同一份資料」。
// 所有文章
useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
// 特定文章(id = 5)
useQuery({ queryKey: ['posts', 5], queryFn: () => fetchPost(5) });
// 帶篩選條件的文章
useQuery({ queryKey: ['posts', { status: 'draft' }], queryFn: () => fetchPosts({ status: 'draft' }) });
Code language: JavaScript (javascript)翻譯:
['posts']— 「所有文章」這份資料['posts', 5]— 「第 5 篇文章」這份資料['posts', { status: 'draft' }]— 「草稿狀態的文章」這份資料
它們是三份不同的快取。queryKey 不同,資料就分開存。
queryKey 變化時的行為
function PostDetail({ postId }: { postId: number }) {
const { data } = useQuery({
queryKey: ['posts', postId], // postId 變了 → queryKey 變了
queryFn: () => fetchPost(postId),
});
// ...
}
Code language: JavaScript (javascript)翻譯:當 postId 從 5 變成 10 時,TanStack Query 知道這是不同的資料,會自動去抓 fetchPost(10)。如果之前抓過 10 號文章的快取還在,就先顯示舊的,背景再確認是否需要更新。
快取行為:stale-while-revalidate
這是 TanStack Query 的核心策略,名字聽起來很複雜,但概念很簡單:
「先給你舊的看,背景偷偷去抓新的。」
完整流程圖
使用者第一次訪問「文章列表」頁面
1. queryKey ['posts'] 沒有快取 → isLoading = true
2. 呼叫 queryFn 抓資料
3. 抓到了 → 存進快取,畫面顯示資料
使用者切到「關於」頁面(離開文章列表)
4. 沒有元件在用 ['posts'] 了
5. 但快取還在(要等 gcTime 到了才清)
使用者切回「文章列表」頁面
6. 快取裡有資料 → 立刻顯示(不用等!)
7. 但如果 staleTime 已過 → 背景重新抓取
8. 新資料回來 → 自動更新畫面
Code language: JavaScript (javascript)翻譯:使用者看到的是「瞬間顯示」(因為有快取),但資料是最新的(因為背景偷偷更新了)。
自動 refetch 的觸發時機
TanStack Query 會在這些時候自動重新抓取(前提是資料已經 stale):
| 時機 | 設定 | 預設 |
|---|---|---|
| 元件掛載時 | refetchOnMount |
true |
| 視窗重新聚焦 | refetchOnWindowFocus |
true |
| 網路重新連線 | refetchOnReconnect |
true |
| 固定間隔 | refetchInterval |
false(停用) |
// 範例:即時資料每 30 秒抓一次
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 1000 * 30, // 每 30 秒自動抓
});
Code language: JavaScript (javascript)翻譯:預設行為是「你切回瀏覽器視窗時,自動更新過期的資料」。這就是為什麼用了 TanStack Query 後,使用者 alt-tab 回來就能看到最新資料。
useMutation:寫入資料
useQuery 負責「讀」,useMutation 負責「寫」(新增、修改、刪除)。
最小範例
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePostButton() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
return (
<button
onClick={() => mutation.mutate({ title: 'New Post', body: 'Hello' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Posting...' : 'Create Post'}
</button>
);
}
Code language: JavaScript (javascript)逐行翻譯
const queryClient = useQueryClient(); // 取得快取管理器
const mutation = useMutation({
mutationFn: (newPost) => // 怎麼送資料到後端
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(res => res.json()),
onSuccess: () => { // 成功之後做什麼
queryClient.invalidateQueries({
queryKey: ['posts'] // 告訴快取:['posts'] 的資料過期了,重新抓
});
},
});
mutation.mutate({ title: 'New Post' }); // 觸發這個 mutation
Code language: JavaScript (javascript)mutation 的狀態
| 你會看到 | 意思 |
|---|---|
mutation.mutate(data) |
執行這個操作 |
mutation.isPending |
正在執行中 |
mutation.isError |
執行失敗了 |
mutation.isSuccess |
執行成功了 |
mutation.error |
錯誤資訊 |
mutation.reset() |
重設狀態 |
完整的錯誤處理
AI 常會生成包含 onError 和 onSettled 的寫法:
const mutation = useMutation({
mutationFn: updatePost,
onSuccess: (data) => {
console.log('成功!回傳的資料:', data);
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
onError: (error) => {
console.error('失敗了:', error.message);
},
onSettled: () => {
console.log('不管成功或失敗都會執行');
},
});
Code language: JavaScript (javascript)翻譯:
onSuccess:成功了,通常在這裡刷新快取onError:失敗了,通常在這裡顯示錯誤提示onSettled:不管成功失敗,通常在這裡關閉 loading 動畫
invalidateQueries——刷新快取的關鍵
// 刷新所有以 'posts' 開頭的快取
queryClient.invalidateQueries({ queryKey: ['posts'] });
// ↑ 這會讓 ['posts']、['posts', 5]、['posts', { status: 'draft' }] 全部重新抓取
// 只刷新特定的
queryClient.invalidateQueries({ queryKey: ['posts', 5] });
// ↑ 只有 ['posts', 5] 會重新抓取
Code language: JavaScript (javascript)翻譯:invalidateQueries 的意思是「告訴快取:這些資料過期了,下次有人要用就重新抓」。注意它是前綴匹配:['posts'] 會匹配所有以 'posts' 開頭的 queryKey。
Optimistic Updates(樂觀更新)
概念
一般流程:按下「按讚」→ 等 API 回應 → 更新畫面 樂觀更新:按下「按讚」→ 立刻更新畫面 → 背景送 API → 如果失敗再回滾
翻譯:「先假設會成功,讓使用者立刻看到結果。如果後端說失敗,再復原。」
最小範例
const likeMutation = useMutation({
mutationFn: (postId) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
onMutate: async (postId) => {
// 1. 取消正在進行的查詢(避免覆蓋我們的樂觀更新)
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
// 2. 保存舊資料(等等失敗時要用來回滾)
const previousPost = queryClient.getQueryData(['posts', postId]);
// 3. 樂觀更新:先在快取裡改掉
queryClient.setQueryData(['posts', postId], (old) => ({
...old,
likes: old.likes + 1,
}));
// 4. 把舊資料傳給 onError
return { previousPost };
},
onError: (err, postId, context) => {
// 失敗了!用舊資料回滾
queryClient.setQueryData(['posts', postId], context.previousPost);
},
onSettled: (data, error, postId) => {
// 不管成功失敗,都重新抓一次確保同步
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
Code language: JavaScript (javascript)流程翻譯
使用者按下「按讚」
↓
onMutate 執行:
1. 取消正在進行的查詢
2. 把目前的資料存起來(備份)
3. 直接改快取裡的資料(likes + 1)→ 畫面立刻更新!
↓
mutationFn 執行:送 API 請求到後端
↓
如果成功 → onSuccess + onSettled → 重新抓取確保同步
如果失敗 → onError(用備份回滾)+ onSettled → 重新抓取
必看懂:Optimistic Updates 的核心就是三步——「備份、先改、失敗回滾」。
queryKey 設計模式:Query Key Factory
當專案變大,queryKey 到處散落會變得難以管理。你會看到 AI 生成類似這樣的「工廠函式」:
最小範例
// queryKeys.ts
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: object) => [...postKeys.lists(), filters] as const,
details:() => [...postKeys.all, 'detail'] as const,
detail: (id: number) => [...postKeys.details(), id] as const,
};
Code language: JavaScript (javascript)使用方式
// 取得所有文章列表
useQuery({
queryKey: postKeys.lists(), // ['posts', 'list']
queryFn: fetchPosts,
});
// 取得篩選後的文章
useQuery({
queryKey: postKeys.list({ status: 'draft' }), // ['posts', 'list', { status: 'draft' }]
queryFn: () => fetchPosts({ status: 'draft' }),
});
// 取得單篇文章
useQuery({
queryKey: postKeys.detail(5), // ['posts', 'detail', 5]
queryFn: () => fetchPost(5),
});
// 刷新所有文章相關的快取
queryClient.invalidateQueries({ queryKey: postKeys.all });
// ↑ 所有以 ['posts'] 開頭的都會被刷新
Code language: JavaScript (javascript)為什麼這樣設計
postKeys.all → ['posts'] → 刷新「所有文章相關」
postKeys.lists() → ['posts', 'list'] → 刷新「所有列表」
postKeys.list({...}) → ['posts', 'list', ...] → 刷新「特定篩選的列表」
postKeys.details() → ['posts', 'detail'] → 刷新「所有文章詳情」
postKeys.detail(5) → ['posts', 'detail', 5] → 刷新「第 5 篇文章」
Code language: PHP (php)翻譯:這是一個「從大到小」的樹狀結構。用 invalidateQueries 時,你可以選擇刷新整棵樹、某個分支、或一個節點。統一管理 key 的好處是不會打錯字,也容易搜尋「誰在用這個 key」。
Vibe Coder 檢查點
看到 AI 用 TanStack Query 寫的程式碼時,確認以下幾點:
useQuery 相關
- [ ]
queryKey有包含所有影響查詢結果的變數嗎?(例如 id、篩選條件) - [ ]
queryFn有正確處理 HTTP 錯誤嗎?(fetch 不會自動 throw 4xx/5xx 錯誤) - [ ]
staleTime設定合理嗎?(太短 = 一直抓,太長 = 資料很舊) - [ ] 有區分
isLoading和isFetching嗎?(初次載入 vs 背景更新)
useMutation 相關
- [ ]
onSuccess有用invalidateQueries刷新相關的快取嗎? - [ ] 有處理
onError嗎?(至少顯示錯誤訊息給使用者) - [ ] 按鈕有在
isPending時 disabled 嗎?(避免重複送出)
Optimistic Updates 相關
- [ ]
onMutate有備份舊資料嗎? - [ ]
onError有用備份回滾嗎? - [ ]
onSettled有重新抓取確保同步嗎?
queryKey 相關
- [ ] 有用 Query Key Factory 統一管理嗎?(專案大的時候)
- [ ]
invalidateQueries的範圍正確嗎?(太大 = 重抓太多,太小 = 漏更新)
常見問題速查
fetch 不會自動 throw 錯誤
// 有陷阱的寫法(404 不會進 error)
queryFn: () => fetch('/api/posts').then(res => res.json())
// 正確的寫法
queryFn: async () => {
const res = await fetch('/api/posts');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Code language: JavaScript (javascript)翻譯:fetch 在收到 404 或 500 時不會自己報錯。你要自己檢查 res.ok,不 OK 就手動丟出錯誤,TanStack Query 才能正確進入 error 狀態。
enabled 控制什麼時候才抓
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // userId 存在時才抓
});
Code language: JavaScript (javascript)翻譯:enabled: false 時這個 query 完全不會執行。常用在「要等另一個資料回來才能抓」的場景。
select 只取部分資料
const { data: postTitles } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
select: (posts) => posts.map(p => p.title), // 只取標題
});
Code language: JavaScript (javascript)翻譯:select 讓你在快取的資料上做轉換。快取裡存的是完整資料,但元件只收到標題。好處是多個元件可以共用同一份快取但各取所需。
必看懂 vs 知道就好
必看懂(天天會遇到)
useQuery的queryKey、queryFn、data、isLoadinguseMutation的mutationFn、mutate、onSuccessinvalidateQueries刷新快取staleTime和gcTime的意義QueryClientProvider初始化
知道就好(遇到再查)
useInfiniteQuery:無限捲動載入useSuspense/ Suspense 模式:搭配 React Suspense 使用placeholderData:在資料載入前先顯示占位資料useQueries:同時執行多個 query- Devtools:
@tanstack/react-query-devtools除錯工具
本篇小結
TanStack Query 的核心概念就是:
- useQuery 負責讀取——給一個 key 和抓資料的函式,它幫你管載入、錯誤、快取
- stale-while-revalidate 是快取策略——先顯示舊的,背景去抓新的
- useMutation 負責寫入——操作成功後用
invalidateQueries刷新快取 - Optimistic Updates 讓 UX 更快——先改 UI 再送請求,失敗就回滾
- queryKey 是快取的身份證——結構設計好,管理和刷新都方便
下一篇我們會介紹如何把 TanStack Router 和 TanStack Query 整合在一起——用 loader 模式在路由層就開始抓資料,讓頁面切換更快。
進階測驗:TanStack Query 資料抓取與快取管理
共 5 題,包含情境題與錯誤診斷題。