2026/07/09

💡本文文章範例 Tanstack Query 相關程式碼皆以V5為主。
在使用 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
npm i @tanstack/react-query-devtoolspnpm add @tanstack/react-query-devtoolsyarn add @tanstack/react-query-devtoolsbun add @tanstack/react-query-devtools如果你是 Next.js App Router,官方文件有特別提到,Next 13+ App Dir 需要將 Devtools 安裝為 dev dependency 才能正常運作。
pnpm add -D @tanstack/react-query-devtools通常你的專案會有一個 QueryClientProvider。
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export default function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}ReactQueryDevtools 必須放在 QueryClientProvider 裡面,因為它需要讀取同一個 QueryClient 的狀態。
如果你使用 Next.js App Router,可以建立一個 client component provider。
// In Next.js, this file would be called: app/providers.tsx
'use client'
// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
import {
environmentManager,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
})
}
let browserQueryClient: QueryClient | undefined = undefined
function getQueryClient() {
if (environmentManager.isServer()) {
// Server: always make a new query client
return makeQueryClient()
} else {
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}
export default function QueryProvider({ children }: { children: React.ReactNode }) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}接著在 layout.tsx 使用:
// app/layout.tsx
import QueryProvider from './QueryProvider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-TW">
<body>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
)
}詳見官方文件: Advanced Server Rendering
打開 Devtools 後,你會看到每個 query 的狀態。
| 狀態 | 意思 |
|---|---|
fresh | 資料還在新鮮時間內 |
stale | 資料已過期,下次符合條件時可能 refetch |
fetching | 正在請求資料 |
paused | 請求暫停,常見於離線狀態 |
inactive | 目前沒有 component 使用這個 query |
error | query 發生錯誤 |
最常看的其實是這三個:
| 你想查什麼 | 看哪裡 |
|---|---|
| API 有沒有重新打 | fetching 狀態 |
| cache 有沒有資料 | cached data |
| query key 有沒有寫錯 | query key 列表 |
useQuery 的 cache假設你有一個文章列表:
import { useQuery } from '@tanstack/react-query'
type Post = {
id: string
title: string
}
async function fetchPosts(): Promise<Post[]> {
const res = await fetch('/api/posts')
if (!res.ok) {
throw new Error('Failed to fetch posts')
}
return res.json()
}
export function PostList() {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
if (isPending) {
return <p>Loading...</p>
}
if (isError) {
return <p>{error.message}</p>
}
return (
<div>
{isFetching && <p>資料更新中...</p>}
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}打開 Devtools 後,你應該會看到:
['posts']你可以檢查:
| 檢查項目 | 說明 |
|---|---|
| query key | 是否為 ['posts'] |
| data | API 回傳的文章列表 |
| status | success / pending / error |
| fetch status | fetching / paused |
| stale 狀態 | fresh 或 stale |
這比一直 console.log(data) 乾淨很多。
TanStack Query 是用 queryKey 來判斷 cache 是否為同一份資料。
假設文章詳情頁這樣寫:
function usePost(postId: string) {
return useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
})
}Devtools 會看到:
['post', 'abc-123']
['post', 'def-456']這是合理的,因為不同文章應該有不同 cache。
但如果你寫成這樣:
function usePost(postId: string) {
return useQuery({
queryKey: ['post'],
queryFn: () => fetchPost(postId),
})
}所有文章都會共用同一個 cache key。
可能造成:
| 問題 | 原因 |
|---|---|
| A 文章顯示 B 文章資料 | query key 沒有包含 postId |
| 切換文章時資料怪怪的 | cache 被錯誤共用 |
| invalidate 不精準 | 無法針對單一文章更新 |
這種問題用 Devtools 很容易看出來。
isPending 與 isFetching 差在哪?這是 TanStack Query 很常搞混的地方。
| 狀態 | 意思 | 適合用途 |
|---|---|---|
isPending | 第一次還沒有資料 | 初始 loading |
isFetching | 正在請求資料 | 背景更新提示 |
isError | 請求失敗 | 錯誤畫面 |
data | cache 裡的資料 | 畫面內容 |
常見寫法:
if (isPending) {
return <p>Loading...</p>
}
return (
<>
{isFetching && <p>資料更新中...</p>}
<PostList data={data} />
</>
)這樣第一次進頁面會看到 loading。 之後背景重新抓資料時,不會整頁閃成 loading,而是顯示「資料更新中」。
Devtools 可以幫你確認現在到底是第一次載入,還是背景 refetch。
staleTime 代表資料多久後會從 fresh 變成 stale。
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 1000 * 60 * 5,
})這代表資料在 5 分鐘內都會被視為 fresh。
| 設定 | 行為 |
|---|---|
staleTime: 0 | 很快變 stale |
staleTime: 5 分鐘 | 5 分鐘內維持 fresh |
staleTime: Infinity | 永遠 fresh,除非手動 invalidate |
在 Devtools 裡,你可以直接看到 query 是 fresh 還是 stale。
這對判斷「為什麼 API 又重新打了」很有幫助。
gcTime 代表 query 變成 inactive 後,cache 會保留多久。
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
gcTime: 1000 * 60 * 10,
})這代表 component 不再使用這個 query 後,cache 會保留 10 分鐘。
| 設定 | 控制內容 |
|---|---|
staleTime | 資料多久變舊 |
gcTime | inactive cache 留多久 |
簡單記:
staleTime = 新鮮期限gcTime = 垃圾回收時間一個管資料新不新,一個管 cache 留不留。
TanStack Query v5 的 Devtools 支援觀察 mutations。 也就是說,你不只能看 query,也可以看新增、更新、刪除這類操作。
常見 mutation 範例:
import { useMutation, useQueryClient } from '@tanstack/react-query'
async function createPost(input: { title: string }) {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(input),
})
if (!res.ok) {
throw new Error('Failed to create post')
}
return res.json()
}
export function CreatePostButton() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts'],
})
},
})
return (
<button
onClick={() => {
mutation.mutate({ title: 'TanStack Query Devtools 教學' })
}}
>
新增文章
</button>
)
}按下新增後,可以在 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:
const createPostMutation = useMutation({
mutationKey: ['posts', 'create'],
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts'],
})
},
})更新文章可以這樣寫:
const updatePostMutation = useMutation({
mutationKey: ['posts', 'update'],
mutationFn: updatePost,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ['post', variables.postId],
})
queryClient.invalidateQueries({
queryKey: ['posts'],
})
},
})刪除文章可以這樣寫:
const deletePostMutation = useMutation({
mutationKey: ['posts', 'delete'],
mutationFn: deletePost,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts'],
})
},
})這樣在 Devtools 裡就比較容易分辨:
['posts', 'create']
['posts', 'update']
['posts', 'delete']如果沒有 mutationKey,mutation 多起來時會像一鍋沒有標籤的火鍋料,很難看出誰是誰。
useMutationState 搭配 Mutation KeymutationKey 也可以搭配 useMutationState,用來取得特定 mutation 的狀態。
例如只取得正在新增文章的 mutation:
import { useMutationState } from '@tanstack/react-query'
const creatingPosts = useMutationState({
filters: {
mutationKey: ['posts', 'create'],
status: 'pending',
},
})可以拿來做:
| 場景 | 用途 |
|---|---|
| 顯示全域 loading | 有特定 mutation pending 時顯示 |
| 禁用按鈕 | 避免重複送出 |
| 顯示 optimistic UI | 根據 variables 預先更新畫面 |
| Debug mutation flow | 確認 mutation 是否真的正在執行 |
例如:
const isCreatingPost = creatingPosts.length > 0
return (
<button disabled={isCreatingPost}>
{isCreatingPost ? '新增中...' : '新增文章'}
</button>
)| 項目 | Query Key | Mutation Key |
|---|---|---|
| 主要用途 | 識別 cache data | 識別 mutation 操作 |
| 常見情境 | 查詢列表、詳情、篩選資料 | 新增、更新、刪除資料 |
| 是否影響快取命中 | 會 | 不像 query key 那樣用來讀取 cache |
| Devtools 可見性 | 可觀察 query 狀態 | 可觀察 mutation 狀態 |
| 常搭配 API | useQuery、invalidateQueries | useMutation、useMutationState |
簡單記:
queryKey = 這份資料是誰mutationKey = 這個操作是誰mutation 本身只是執行寫入操作。 例如新增文章成功後,TanStack Query 不會自動知道你的文章列表要重新抓。
所以通常會在 onSuccess 裡面 invalidate 相關 query:
const mutation = useMutation({
mutationKey: ['posts', 'create'],
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts'],
})
},
})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 |
例如這種寫法要小心:
useQuery({
queryKey: ['posts', { keyword, timestamp: Date.now() }],
queryFn: fetchPosts,
})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:
useMutation({
mutationFn: createPost,
})
useMutation({
mutationFn: updatePost,
})
useMutation({
mutationFn: deletePost,
})Devtools 裡可能不容易辨識。
建議補上 mutationKey:
useMutation({
mutationKey: ['posts', 'create'],
mutationFn: createPost,
})
useMutation({
mutationKey: ['posts', 'update'],
mutationFn: updatePost,
})
useMutation({
mutationKey: ['posts', 'delete'],
mutationFn: deletePost,
})這樣 debug 時會清楚很多。
除了右下角浮動按鈕,TanStack Query Devtools 也支援 panel 形式。
官方範例: TanStack Query Devtools Panel Example
簡化範例:
'use client'
import { useState } from 'react'
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
export function QueryDebugPanel() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen((prev) => !prev)}>
{isOpen ? '關閉 Devtools' : '開啟 Devtools'}
</button>
{isOpen && (
<ReactQueryDevtoolsPanel onClose={() => setIsOpen(false)} />
)}
</div>
)
}這種方式適合內部後台、debug mode,或開發環境專用工具列。
實務上,我通常會用 Devtools 檢查這幾件事:
| 情境 | 我會看什麼 |
|---|---|
| 串新 API | data shape 是否符合畫面需要 |
| 做列表篩選 | query key 是否包含 filter |
| 做 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 裡那些看不見的小怪獸照出來。