2026/07/09

在使用 TanStack Query 開發 React / Next.js 專案時,我們常常會遇到這些問題:
| 問題 | 常見原因 |
|---|---|
| 為什麼 API 又重新打了? | staleTime、視窗重新聚焦、query key 變動 |
| 為什麼畫面還是舊資料? | cache 尚未失效、mutation 後沒有 invalidate |
| mutation 成功了,但列表沒更新? | invalidateQueries 的 query key 沒對上 |
| loading 狀態怪怪的? | 分不清 isPending 與 isFetching |
| 明明是不同資料,卻共用同一份 cache? | query key 設計錯誤 |
| mutation 太多,看不出是哪個操作在跑 | 沒有設定 mutationKey |
這時候 TanStack Query Devtools 就很有用。
它可以讓你在瀏覽器中直接看到目前所有 query、mutation、cache data、stale 狀態、fetch 狀態與錯誤資訊。
官方文件: TanStack Query Devtools 官方文件
TanStack Query Devtools 是 TanStack Query 官方提供的除錯工具,用來觀察 TanStack Query 內部狀態。
它可以幫你看到:
| 功能 | 說明 |
|---|---|
| Query Key | 檢查 query key 是否符合預期 |
| Query 狀態 | fresh、stale、fetching、paused、inactive |
| Cached Data | 查看目前 cache 裡的資料 |
| Refetch 行為 | 觀察資料什麼時候重新請求 |
| Mutation 狀態 | 查看 mutation 是否 pending、success、error |
| Mutation Key | 區分不同 mutation 操作 |
| Error | 快速查看 API 錯誤 |
簡單說,它就像是 TanStack Query 的透明駕駛艙。 原本藏在 cache 裡的狀態,全都攤開給你看。
官方安裝方式請參考: TanStack Query Devtools - Install and Import
1npm i @tanstack/react-query-devtools1pnpm add @tanstack/react-query-devtools1yarn add @tanstack/react-query-devtools1bun add @tanstack/react-query-devtools如果你是 Next.js App Router,官方文件有特別提到,Next 13+ App Dir 需要將 Devtools 安裝為 dev dependency 才能正常運作。
1pnpm add -D @tanstack/react-query-devtools通常你的專案會有一個 QueryClientProvider。
1'use client'
2
3import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
5import { useState } from 'react'
6
7export function QueryProvider({ children }: { children: React.ReactNode }) {
8 const [queryClient] = useState(() => new QueryClient())
9
10 return (
11 <QueryClientProvider client={queryClient}>
12 {children}
13
14 <ReactQueryDevtools initialIsOpen={false} />
15 </QueryClientProvider>
16 )
17}ReactQueryDevtools 必須放在 QueryClientProvider 裡面,因為它需要讀取同一個 QueryClient 的狀態。
如果你使用 Next.js App Router,可以建立一個 client component provider。
1// app/providers.tsx
2'use client'
3
4import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
6import { useState } from 'react'
7
8export function Providers({ children }: { children: React.ReactNode }) {
9 const [queryClient] = useState(() => new QueryClient())
10
11 return (
12 <QueryClientProvider client={queryClient}>
13 {children}
14
15 {process.env.NODE_ENV === 'development' && (
16 <ReactQueryDevtools initialIsOpen={false} />
17 )}
18 </QueryClientProvider>
19 )
20}接著在 layout.tsx 使用:
1// app/layout.tsx
2import { Providers } from './providers'
3
4export default function RootLayout({
5 children,
6}: {
7 children: React.ReactNode
8}) {
9 return (
10 <html lang="zh-TW">
11 <body>
12 <Providers>{children}</Providers>
13 </body>
14 </html>
15 )
16}建議只在 development 環境開啟 Devtools:
1{process.env.NODE_ENV === 'development' && (
2 <ReactQueryDevtools initialIsOpen={false} />
3)}Devtools 會顯示 query key、mutation key、cached data、API 回傳資料。
這些資訊在 production 不一定適合暴露。
| 內容 | 可能風險 |
|---|---|
| query key | 可能透露 API 資料結構 |
| cached data | 可能包含使用者資料 |
| mutation variables | 可能包含表單輸入內容 |
| error message | 可能透露後端錯誤細節 |
所以通常建議:
| 環境 | 建議 |
|---|---|
| local development | 開啟 |
| staging | 視團隊需求 |
| production | 不建議開啟 |
打開 Devtools 後,你會看到每個 query 的狀態。
| 狀態 | 意思 |
|---|---|
fresh | 資料還在新鮮時間內 |
stale | 資料已過期,下次符合條件時可能 refetch |
fetching | 正在請求資料 |
paused | 請求暫停,常見於離線狀態 |
inactive | 目前沒有 component 使用這個 query |
error | query 發生錯誤 |
最常看的其實是這三個:
| 你想查什麼 | 看哪裡 |
|---|---|
| API 有沒有重新打 | fetching 狀態 |
| cache 有沒有資料 | cached data |
| query key 有沒有寫錯 | query key 列表 |
useQuery 的 cache假設你有一個文章列表:
1import { useQuery } from '@tanstack/react-query'
2
3type Post = {
4 id: string
5 title: string
6}
7
8async function fetchPosts(): Promise<Post[]> {
9 const res = await fetch('/api/posts')
10
11 if (!res.ok) {
12 throw new Error('Failed to fetch posts')
13 }
14
15 return res.json()
16}
17
18export function PostList() {
19 const { data, isPending, isError, error, isFetching } = useQuery({
20 queryKey: ['posts'],
21 queryFn: fetchPosts,
22 })
23
24 if (isPending) {
25 return <p>Loading...</p>
26 }
27
28 if (isError) {
29 return <p>{error.message}</p>
30 }
31
32 return (
33 <div>
34 {isFetching && <p>資料更新中...</p>}
35
36 <ul>
37 {data.map((post) => (
38 <li key={post.id}>{post.title}</li>
39 ))}
40 </ul>
41 </div>
42 )
43}打開 Devtools 後,你應該會看到:
1['posts']你可以檢查:
| 檢查項目 | 說明 |
|---|---|
| query key | 是否為 ['posts'] |
| data | API 回傳的文章列表 |
| status | success / pending / error |
| fetch status | idle / fetching / paused |
| stale 狀態 | fresh 或 stale |
這比一直 console.log(data) 乾淨很多。
TanStack Query 是用 queryKey 來判斷 cache 是否為同一份資料。
假設文章詳情頁這樣寫:
1function usePost(postId: string) {
2 return useQuery({
3 queryKey: ['post', postId],
4 queryFn: () => fetchPost(postId),
5 })
6}Devtools 會看到:
1['post', 'abc-123']
2['post', 'def-456']這是合理的,因為不同文章應該有不同 cache。
但如果你寫成這樣:
1function usePost(postId: string) {
2 return useQuery({
3 queryKey: ['post'],
4 queryFn: () => fetchPost(postId),
5 })
6}所有文章都會共用同一個 cache key。
可能造成:
| 問題 | 原因 |
|---|---|
| A 文章顯示 B 文章資料 | query key 沒有包含 postId |
| 切換文章時資料怪怪的 | cache 被錯誤共用 |
| invalidate 不精準 | 無法針對單一文章更新 |
這種問題用 Devtools 很容易看出來。
isPending 與 isFetching 差在哪?這是 TanStack Query 很常搞混的地方。
| 狀態 | 意思 | 適合用途 |
|---|---|---|
isPending | 第一次還沒有資料 | 初始 loading |
isFetching | 正在請求資料 | 背景更新提示 |
isError | 請求失敗 | 錯誤畫面 |
data | cache 裡的資料 | 畫面內容 |
常見寫法:
1if (isPending) {
2 return <p>Loading...</p>
3}
4
5return (
6 <>
7 {isFetching && <p>資料更新中...</p>}
8 <PostList data={data} />
9 </>
10)這樣第一次進頁面會看到 loading。 之後背景重新抓資料時,不會整頁閃成 loading,而是顯示「資料更新中」。
Devtools 可以幫你確認現在到底是第一次載入,還是背景 refetch。
staleTime 代表資料多久後會從 fresh 變成 stale。
1useQuery({
2 queryKey: ['posts'],
3 queryFn: fetchPosts,
4 staleTime: 1000 * 60 * 5,
5})這代表資料在 5 分鐘內都會被視為 fresh。
| 設定 | 行為 |
|---|---|
staleTime: 0 | 很快變 stale |
staleTime: 5 分鐘 | 5 分鐘內維持 fresh |
staleTime: Infinity | 永遠 fresh,除非手動 invalidate |
在 Devtools 裡,你可以直接看到 query 是 fresh 還是 stale。
這對判斷「為什麼 API 又重新打了」很有幫助。
gcTime 代表 query 變成 inactive 後,cache 會保留多久。
1useQuery({
2 queryKey: ['posts'],
3 queryFn: fetchPosts,
4 gcTime: 1000 * 60 * 10,
5})這代表 component 不再使用這個 query 後,cache 會保留 10 分鐘。
| 設定 | 控制內容 |
|---|---|
staleTime | 資料多久變舊 |
gcTime | inactive cache 留多久 |
簡單記:
1staleTime = 新鮮期限
2gcTime = 垃圾回收時間一個管資料新不新,一個管 cache 留不留。
TanStack Query v5 的 Devtools 支援觀察 mutations。 也就是說,你不只能看 query,也可以看新增、更新、刪除這類操作。
常見 mutation 範例:
1import { useMutation, useQueryClient } from '@tanstack/react-query'
2
3async function createPost(input: { title: string }) {
4 const res = await fetch('/api/posts', {
5 method: 'POST',
6 body: JSON.stringify(input),
7 })
8
9 if (!res.ok) {
10 throw new Error('Failed to create post')
11 }
12
13 return res.json()
14}
15
16export function CreatePostButton() {
17 const queryClient = useQueryClient()
18
19 const mutation = useMutation({
20 mutationFn: createPost,
21 onSuccess: () => {
22 queryClient.invalidateQueries({
23 queryKey: ['posts'],
24 })
25 },
26 })
27
28 return (
29 <button
30 onClick={() => {
31 mutation.mutate({ title: 'TanStack Query Devtools 教學' })
32 }}
33 >
34 新增文章
35 </button>
36 )
37}按下新增後,可以在 Devtools 觀察 mutation 的狀態。
| 狀態 | 意思 |
|---|---|
pending | mutation 執行中 |
success | mutation 成功 |
error | mutation 失敗 |
| variables | 這次 mutation 傳入的參數 |
| data | mutation 成功後回傳的資料 |
| error | mutation 失敗時的錯誤 |
mutationKey 是用來標記 mutation 的 key。
它不像 queryKey 那樣主要用來管理 cache data。 因為 mutation 通常是「寫入、更新、刪除」這類副作用,不是拿來保存 server state 的主要資料來源。
但 mutationKey 很適合用在這些情境:
| 使用情境 | 說明 |
|---|---|
| Devtools 分辨 mutation | 看出現在執行的是哪個操作 |
useMutationState 過濾 mutation | 只取特定 mutation 的狀態 |
| 設定 mutation defaults | 對某類 mutation 套用預設設定 |
| 大型專案管理 mutation | 避免所有 mutation 混在一起 |
官方文件: useMutation 官方文件, useMutationState 官方文件
可以這樣替新增文章設定 mutationKey:
1const createPostMutation = useMutation({
2 mutationKey: ['posts', 'create'],
3 mutationFn: createPost,
4 onSuccess: () => {
5 queryClient.invalidateQueries({
6 queryKey: ['posts'],
7 })
8 },
9})更新文章可以這樣寫:
1const updatePostMutation = useMutation({
2 mutationKey: ['posts', 'update'],
3 mutationFn: updatePost,
4 onSuccess: (_, variables) => {
5 queryClient.invalidateQueries({
6 queryKey: ['post', variables.postId],
7 })
8
9 queryClient.invalidateQueries({
10 queryKey: ['posts'],
11 })
12 },
13})刪除文章可以這樣寫:
1const deletePostMutation = useMutation({
2 mutationKey: ['posts', 'delete'],
3 mutationFn: deletePost,
4 onSuccess: () => {
5 queryClient.invalidateQueries({
6 queryKey: ['posts'],
7 })
8 },
9})這樣在 Devtools 裡就比較容易分辨:
1['posts', 'create']
2['posts', 'update']
3['posts', 'delete']如果沒有 mutationKey,mutation 多起來時會像一鍋沒有標籤的火鍋料,很難看出誰是誰。
useMutationState 搭配 Mutation KeymutationKey 也可以搭配 useMutationState,用來取得特定 mutation 的狀態。
例如只取得正在新增文章的 mutation:
1import { useMutationState } from '@tanstack/react-query'
2
3const creatingPosts = useMutationState({
4 filters: {
5 mutationKey: ['posts', 'create'],
6 status: 'pending',
7 },
8})可以拿來做:
| 場景 | 用途 |
|---|---|
| 顯示全域 loading | 有特定 mutation pending 時顯示 |
| 禁用按鈕 | 避免重複送出 |
| 顯示 optimistic UI | 根據 variables 預先更新畫面 |
| Debug mutation flow | 確認 mutation 是否真的正在執行 |
例如:
1const isCreatingPost = creatingPosts.length > 0
2
3return (
4 <button disabled={isCreatingPost}>
5 {isCreatingPost ? '新增中...' : '新增文章'}
6 </button>
7)| 項目 | Query Key | Mutation Key |
|---|---|---|
| 主要用途 | 識別 cache data | 識別 mutation 操作 |
| 常見情境 | 查詢列表、詳情、篩選資料 | 新增、更新、刪除資料 |
| 是否影響快取命中 | 會 | 不像 query key 那樣用來讀取 cache |
| Devtools 可見性 | 可觀察 query 狀態 | 可觀察 mutation 狀態 |
| 常搭配 API | useQuery、invalidateQueries | useMutation、useMutationState |
簡單記:
1queryKey = 這份資料是誰
2mutationKey = 這個操作是誰mutation 本身只是執行寫入操作。 例如新增文章成功後,TanStack Query 不會自動知道你的文章列表要重新抓。
所以通常會在 onSuccess 裡面 invalidate 相關 query:
1const mutation = useMutation({
2 mutationKey: ['posts', 'create'],
3 mutationFn: createPost,
4 onSuccess: () => {
5 queryClient.invalidateQueries({
6 queryKey: ['posts'],
7 })
8 },
9})Devtools 可以幫你確認:
| 檢查項目 | 正常情況 |
|---|---|
| mutation | 從 pending 變成 success |
['posts'] query | 被標記為 stale |
| fetch status | 重新 fetching |
| cached data | 更新成最新資料 |
如果 mutation 成功但畫面沒更新,通常要查這幾個:
| 問題 | 範例 |
|---|---|
| invalidate 錯 query key | ['post'] vs ['posts'] |
| 列表 query key 有 filter | ['posts', filters] |
| API 回傳舊資料 | 後端 cache 或資料庫尚未更新 |
| 畫面讀的不是同一個 query | hooks 分散導致 key 不一致 |
| query 目前 inactive | 沒有 component 正在使用它 |
可以檢查:
| 檢查項目 | 可能問題 |
|---|---|
| query key 是否每次都變 | key 裡面放了不穩定資料 |
| staleTime 是否太短 | 資料很快變 stale |
| component 是否一直 remount | layout 或 key 設計問題 |
| refetchOnWindowFocus | 切回視窗時觸發 refetch |
例如這種寫法要小心:
1useQuery({
2 queryKey: ['posts', { keyword, timestamp: Date.now() }],
3 queryFn: fetchPosts,
4})Date.now() 每次 render 都不同,query key 也會一直變。 結果 cache 會被切成碎紙片。
可以檢查:
| 檢查項目 | 可能問題 |
|---|---|
| cached data 是否更新 | 沒有 invalidate |
| query key 是否一致 | invalidate 錯 key |
| API 是否真的回新資料 | 後端沒有更新成功 |
| component 是否讀錯 query | query key 不一致 |
可能原因:
| 原因 | 說明 |
|---|---|
| query key 不同 | cache 沒命中 |
| gcTime 到期 | cache 被清掉 |
| enabled 條件改變 | query 重新進入 pending |
| SSR hydration 沒接好 | server/client cache 不一致 |
如果是 Next.js App Router 搭配 hydration,Devtools 可以幫你確認 client 端是否真的拿到預期 cache。
如果你有很多 mutation:
1useMutation({
2 mutationFn: createPost,
3})
4
5useMutation({
6 mutationFn: updatePost,
7})
8
9useMutation({
10 mutationFn: deletePost,
11})Devtools 裡可能不容易辨識。
建議補上 mutationKey:
1useMutation({
2 mutationKey: ['posts', 'create'],
3 mutationFn: createPost,
4})
5
6useMutation({
7 mutationKey: ['posts', 'update'],
8 mutationFn: updatePost,
9})
10
11useMutation({
12 mutationKey: ['posts', 'delete'],
13 mutationFn: deletePost,
14})這樣 debug 時會清楚很多。
除了右下角浮動按鈕,TanStack Query Devtools 也支援 panel 形式。
官方範例: TanStack Query Devtools Panel Example
簡化範例:
1'use client'
2
3import { useState } from 'react'
4import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
5
6export function QueryDebugPanel() {
7 const [isOpen, setIsOpen] = useState(false)
8
9 return (
10 <div>
11 <button onClick={() => setIsOpen((prev) => !prev)}>
12 {isOpen ? '關閉 Devtools' : '開啟 Devtools'}
13 </button>
14
15 {isOpen && (
16 <ReactQueryDevtoolsPanel onClose={() => setIsOpen(false)} />
17 )}
18 </div>
19 )
20}這種方式適合內部後台、debug mode,或開發環境專用工具列。
實務上,我通常會用 Devtools 檢查這幾件事:
| 情境 | 我會看什麼 |
|---|---|
| 串新 API | data shape 是否符合畫面需要 |
| 做列表篩選 | query key 是否包含 filter |
| 做詳情頁 | query key 是否包含 id |
| 做 mutation | mutation key 是否清楚 |
| mutation 後更新列表 | invalidate 是否命中正確 query |
| 調整 staleTime | 是否真的減少 refetch |
| Debug loading | 是 pending 還是 background fetching |
| 重構 hooks | cache 是否被切碎 |
特別是 query key 和 mutation key。 很多 TanStack Query 的問題,不是出在 API,而是出在 key 的設計。
TanStack Query Devtools 不只是拿來看 API 有沒有成功。
它更像是 server state 的控制台,可以讓你看懂 query、cache、mutation 之間的關係。
| 它能幫你解決什麼 | 價值 |
|---|---|
| 看 query key | 避免 cache 設計錯誤 |
| 看 mutation key | 分辨目前執行中的操作 |
| 看 cached data | 減少 console.log |
| 看 stale / fresh | 理解 refetch 行為 |
| 看 mutation 狀態 | 確認新增、更新、刪除流程 |
| 看 invalidate 結果 | 確認資料是否重新抓取 |
| 看 inactive query | 理解 cache 生命週期 |
如果你的專案有列表、篩選、詳情頁、表單送出、資料更新,TanStack Query Devtools 幾乎是必裝工具。
它不會幫你設計好 cache 架構。 但它會把 cache 裡那些看不見的小怪獸照出來。