React Query
TanStack Query v5 — 伺服器狀態管理的標準解法,涵蓋快取、背景更新、Mutation 與進階模式。
為什麼需要 React Query?
React 本身只管理 UI 狀態(客戶端狀態),但 API 資料屬於 伺服器狀態——它存在於遠端、可能隨時變化、需要同步。 用 useState + useEffect 手動處理時會面臨:
- 每個元件重複撰寫 loading / error / data 狀態
- 資料在不同元件間重複 fetch,沒有共享快取
- 視窗重新獲取焦點時資料過時
- 競態條件(race condition)難以處理
- 分頁、無限捲動實作複雜
React Query 以 QueryKey 為主鍵的快取層解決以上所有問題。
安裝與初始化
安裝
npm install @tanstack/react-query在根層包裹 QueryClientProvider
整個應用只需一個 QueryClient 實例。 用 useState 初始化,確保每次 render 不會重建。
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export default function Providers({ children }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 資料 1 分鐘內視為新鮮,不重新 fetch
retry: 1, // 請求失敗最多重試 1 次
},
},
}))
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}useQuery — 讀取資料
基本用法
queryKey 是快取的唯一識別碼,類似資料庫主鍵。queryFn 必須回傳 Promise。
import { useQuery } from '@tanstack/react-query'
type User = { id: number; name: string; email: string }
const fetchUser = async (id: number): Promise<User> => {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Network response was not ok')
return res.json()
}
function UserProfile({ userId }: { userId: number }) {
const {
data, // 回傳的資料(型別自動推斷為 User | undefined)
isLoading, // 第一次 fetch 中(尚無快取)
isFetching, // 任何 fetch 中(含背景更新)
isError, // 發生錯誤
error, // 錯誤物件
isSuccess, // 成功且有資料
refetch, // 手動重新 fetch
} = useQuery({
queryKey: ['user', userId], // key 含動態 id,userId 變化會自動重新 fetch
queryFn: () => fetchUser(userId),
})
if (isLoading) return <p>載入中...</p>
if (isError) return <p>錯誤:{error.message}</p>
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
<button onClick={() => refetch()}>重新整理</button>
</div>
)
}QueryKey 設計原則
QueryKey 是陣列,React Query 會做深層比較。把所有影響請求結果的變數都放入 key。
// ✅ 資源 + id
useQuery({ queryKey: ['todos', todoId], queryFn: ... })
// ✅ 資源 + 過濾參數物件(物件內容相同則快取命中)
useQuery({ queryKey: ['todos', { status: 'done', page: 1 }], queryFn: ... })
// ✅ 巢狀資源
useQuery({ queryKey: ['users', userId, 'posts'], queryFn: ... })
// ❌ 避免用會每次都產生新參考的物件
useQuery({ queryKey: ['todos', { filter }], queryFn: ... })
// 若 filter 每次 render 都是新物件,快取永遠不會命中!
// → 改用 useMemo 或直接傳入 primitive 值select — 轉換 / 訂閱部分資料
select 在快取資料更新後執行,只有 select 的結果改變時才觸發重新渲染。
type Todo = { id: number; title: string; done: boolean }
function TodoCount() {
// 整個 todos 陣列快取,但此元件只訂閱 "完成的數量"
const { data: doneCount } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos: Todo[]) => todos.filter(t => t.done).length,
})
return <span>完成:{doneCount} 項</span>
// todos 陣列新增一筆未完成項目時,此元件不會重新渲染
}enabled — 條件式 fetch
設為 false 時 query 掛起不執行,常用於依賴前一個 query 的結果(Dependent Queries)。
function UserPosts({ userId }: { userId: number | null }) {
// Step 1: 取得使用者
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId!),
enabled: userId != null, // userId 為 null 時不 fetch
})
// Step 2: 有了 user 的 email 才去取 posts(Dependent Query)
const { data: posts } = useQuery({
queryKey: ['posts', user?.email],
queryFn: () => fetchPostsByEmail(user!.email),
enabled: !!user?.email, // user 還沒來時不執行
})
return <div>{posts?.map(p => <p key={p.id}>{p.title}</p>)}</div>
}快取生命週期
staleTime(新鮮時間)
資料從 fetch 完成後,在 staleTime 內視為「新鮮」,不會觸發背景重新 fetch。 預設為 0,即立刻變舊。
gcTime(垃圾回收時間)
當 query 沒有元件訂閱後,快取資料保留 gcTime 才被清除。 預設為 5分鐘。
// 靜態設定資料(如國家列表)→ 幾乎不過期
useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
staleTime: Infinity, // 永遠新鮮,只 fetch 一次
})
// 即時性高的資料(如股票報價)→ 快速過期 + 定時輪詢
useQuery({
queryKey: ['stock', ticker],
queryFn: () => fetchStock(ticker),
staleTime: 0,
refetchInterval: 5000, // 每 5 秒自動重新 fetch
})自動重新 fetch 的時機:視窗重新獲得焦點(refetchOnWindowFocus)、網路重新連線(refetchOnReconnect)、元件重新掛載(refetchOnMount)。都可在 QueryClient 或個別 query 設定關閉。
useMutation — 寫入資料
基本 Mutation + 使快取失效
Mutation 成功後用 invalidateQueries 讓對應快取失效, React Query 會自動在背景重新 fetch 讓 UI 同步最新資料。
import { useMutation, useQueryClient } from '@tanstack/react-query'
type NewTodo = { title: string }
type Todo = { id: number; title: string; done: boolean }
const createTodo = async (newTodo: NewTodo): Promise<Todo> => {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!res.ok) throw new Error('建立失敗')
return res.json()
}
function AddTodo() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
// 讓 todos 快取失效 → 觸發背景重新 fetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
onError: (err) => {
console.error('新增失敗:', err.message)
},
})
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const title = new FormData(e.currentTarget).get('title') as string
mutation.mutate({ title })
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="待辦事項" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '新增中...' : '新增'}
</button>
{mutation.isError && <p>錯誤:{mutation.error.message}</p>}
</form>
)
}樂觀更新(Optimistic Update)
在 API 回應前就先更新 UI,失敗時自動回滾。讓使用者感覺操作即時生效,適合 Like、勾選等互動。
function TodoList() {
const queryClient = useQueryClient()
const toggleMutation = useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: JSON.stringify({ done: !todo.done }),
}),
// 1. 在 API 呼叫前執行:先快取現有資料、再手動設定新值
onMutate: async (toggledTodo) => {
// 取消進行中的 refetch,避免覆蓋樂觀更新
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 備份目前快取
const previous = queryClient.getQueryData<Todo[]>(['todos'])
// 樂觀地更新快取
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.map(t =>
t.id === toggledTodo.id ? { ...t, done: !t.done } : t
)
)
// 回傳 context 供 onError 使用
return { previous }
},
// 2. 失敗時回滾
onError: (_err, _toggledTodo, context) => {
if (context?.previous) {
queryClient.setQueryData(['todos'], context.previous)
}
},
// 3. 成功或失敗後都重新同步伺服器資料
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return (
<ul>
{todos?.map(todo => (
<li key={todo.id} onClick={() => toggleMutation.mutate(todo)}>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.title}
</span>
</li>
))}
</ul>
)
}分頁與無限捲動
placeholderData — 分頁不閃爍
切換頁碼時,用 placeholderData: keepPreviousData保留上一頁資料,直到新頁面載入完成,避免畫面空白閃爍。
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { useState } from 'react'
function PaginatedPosts() {
const [page, setPage] = useState(1)
const { data, isPlaceholderData } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetch(`/api/posts?page=${page}`).then(r => r.json()),
placeholderData: keepPreviousData, // 切頁時保留舊資料
})
return (
<div>
{/* isPlaceholderData = true 時顯示半透明表示載入中 */}
<ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
<div>
<button
onClick={() => setPage(p => Math.max(p - 1, 1))}
disabled={page === 1}
>上一頁</button>
<span> 第 {page} 頁 </span>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data?.hasNextPage}
>下一頁</button>
</div>
</div>
)
}useInfiniteQuery — 無限捲動
所有頁面的資料累積在單一快取條目中,用 fetchNextPage 載入更多。
import { useInfiniteQuery } from '@tanstack/react-query'
type PostsPage = {
posts: { id: number; title: string }[]
nextCursor: number | null
}
function InfinitePostList() {
const {
data, // { pages: PostsPage[], pageParams: unknown[] }
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam }) =>
fetch(`/api/posts?cursor=${pageParam ?? ''}`).then(r => r.json()),
initialPageParam: null,
getNextPageParam: (lastPage: PostsPage) => lastPage.nextCursor,
// 若 nextCursor 為 null,getNextPageParam 回傳 undefined → hasNextPage = false
})
return (
<div>
{data?.pages.map((page, i) => (
<ul key={i}>
{page.posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? '載入中...' : hasNextPage ? '載入更多' : '已全部載入'}
</button>
</div>
)
}最佳實踐:Custom Hook 封裝
將所有 query/mutation 封裝成 Custom Hook,元件只需呼叫 hook,不需知道 queryKey 細節。
// hooks/useTodos.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
const TODO_KEY = ['todos'] as const
export function useTodos() {
return useQuery({
queryKey: TODO_KEY,
queryFn: (): Promise<Todo[]> =>
fetch('/api/todos').then(r => r.json()),
})
}
export function useCreateTodo() {
const qc = useQueryClient()
return useMutation({
mutationFn: (title: string) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ title }),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
onSuccess: () => qc.invalidateQueries({ queryKey: TODO_KEY }),
})
}
export function useDeleteTodo() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) =>
fetch(`/api/todos/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: TODO_KEY }),
})
}
// 元件中使用 —— 完全不需要知道快取細節
function TodoApp() {
const { data: todos, isLoading } = useTodos()
const createTodo = useCreateTodo()
const deleteTodo = useDeleteTodo()
if (isLoading) return <p>載入中...</p>
return (
<ul>
{todos?.map(todo => (
<li key={todo.id}>
{todo.title}
<button onClick={() => deleteTodo.mutate(todo.id)}>刪除</button>
</li>
))}
<button onClick={() => createTodo.mutate('新任務')}>新增</button>
</ul>
)
}常見面試考點
Redux 管理客戶端狀態(UI 狀態、使用者偏好),React Query 管理伺服器狀態(API 資料、快取)。兩者可共存,分工不同。
staleTime 控制「資料多久變舊」(舊了才觸發背景 refetch);gcTime 控制「無訂閱的資料在記憶體中保留多久」(到期才清除快取)。
isLoading 只在「沒有快取資料且第一次載入」時為 true;isFetching 在任何 fetch(含背景更新)時都為 true。
在 onMutate 備份快取並回傳 context,在 onError 用 setQueryData 還原備份,並在 onSettled 重新 invalidate 同步伺服器真實狀態。