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>
  )
}

常見面試考點

Q1
React Query 和 Redux 的差異?

Redux 管理客戶端狀態(UI 狀態、使用者偏好),React Query 管理伺服器狀態(API 資料、快取)。兩者可共存,分工不同。

Q2
staleTime 與 gcTime 的差別?

staleTime 控制「資料多久變舊」(舊了才觸發背景 refetch);gcTime 控制「無訂閱的資料在記憶體中保留多久」(到期才清除快取)。

Q3
isLoading vs isFetching?

isLoading 只在「沒有快取資料且第一次載入」時為 true;isFetching 在任何 fetch(含背景更新)時都為 true。

Q4
樂觀更新失敗時如何回滾?

onMutate 備份快取並回傳 context,在 onErrorsetQueryData 還原備份,並在 onSettled 重新 invalidate 同步伺服器真實狀態。