React TanStack Query 非同步狀態管理套件

在 React 中處理伺服器狀態(Server State,即從 API 獲取的資料)往往比處理本地狀態(Client State)更複雜。你需要考慮:

  • 快取 (Caching)
  • 去重複請求 (Deduplicating multiple requests)
  • 背景更新 (Background updates)
  • 資料過期 (Stale data)
  • 載入中與錯誤狀態的處理

TanStack Query(舊稱 React Query)就是為了解決這些問題而生的。它通常被描述為 React 中缺少的資料獲取 (Data-fetching) 函式庫,但更準確地說,它是強大的非同步狀態管理工具。

安裝

npm install @tanstack/react-query @tanstack/react-query-devtools

Setup

首先,我們需要在應用程式最外層包裹 QueryClientProvider

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// 建立一個 client 實例
const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyComponent />

      {/* 開啟 DevTools (預設只在開發環境顯示) */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

獲取資料 (Fetching Data) - useQuery

useQuery 是最核心的 Hook,用於 Get 類型的請求。它需要兩個主要參數:

  1. queryKey: 一個陣列,用來唯一識別這個資料。當 key 改變時,React Query 會自動重新獲取資料。
  2. queryFn: 一個回傳 Promise 的函式,用來實際執行請求。
import { useQuery } from '@tanstack/react-query';

function Todos() {
  const { isPending, isError, data, error } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/todos').then((res) => res.json()),
  });

  // 處理載入狀態
  if (isPending) {
    return <span>載入中...</span>;
  }

  // 處理錯誤狀態
  if (isError) {
    return <span>發生錯誤: {error.message}</span>;
  }

  // 渲染資料
  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

帶參數的 Query

當你的請求依賴於某些變數(例如 ID 或篩選條件)時,將這些變數放入 queryKey 中。

function Todo({ todoId }) {
  const { data } = useQuery({
    // 當 todoId 改變時,React Query 會自動重新執行 queryFn
    queryKey: ['todo', todoId],
    queryFn: async () => {
      const res = await fetch(`/api/todos/${todoId}`);
      return res.json();
    },
    // 如果沒有 todoId,就不執行 (Dependent Query)
    enabled: !!todoId,
  });
}

修改資料 (Mutations) - useMutation

對於 Create/Update/Delete 等會改變伺服器資料的請求,我們使用 useMutation

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      });
    },
    onSuccess: () => {
      // 成功後,讓 'todos' 的快取失效,觸發重新抓取列表
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return (
    <button
      onClick={() => {
        mutation.mutate({ title: 'Learn React Query' });
      }}
    >
      新增待辦事項
      {mutation.isPending && ' (新增中...)'}
    </button>
  );
}

快取與過期 (Caching & Stale Time)

React Query 的預設行為非常積極,它假設資料隨時可能過期。

  • staleTime (預設: 0): 資料從「新鮮」(fresh) 變成「過期」(stale) 的時間。只要資料是 stale 的,React Query 就會在以下情況自動在背景重新抓取 (Refetch):
    • 新的 query instance mount 時
    • 視窗重新取得焦點 (Window refocus) 時
    • 網路重新連線時
  • gcTime (預設: 5分鐘): "Garbage Collection Time",未使用的快取資料在記憶體中保留的時間。

如果你想調整這些設定:

const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  // 1 分鐘內資料都算新鮮,不會自動背景重抓
  staleTime: 1000 * 60,
  // 快取會保留 10 分鐘
  gcTime: 1000 * 60 * 10,
});

分頁查詢 (Paginated Queries)

這是一個常見的需求。React Query 可以透過 keepPreviousData (v5 改為 placeholderData) 來優化分頁體驗,讓使用者在切換頁面時,不會看到載入中的閃爍。

import { keepPreviousData } from '@tanstack/react-query';

function PaginationExample() {
  const [page, setPage] = useState(0);

  const { isPending, isError, data, isPlaceholderData } = useQuery({
    queryKey: ['projects', page],
    queryFn: () => fetchProjects(page),
    // 在抓取下一頁資料時,保留並顯示上一頁的資料
    placeholderData: keepPreviousData,
  });

  return (
    <div>
      {/* 渲染資料... */}
      <button onClick={() => setPage((old) => Math.max(old - 1, 0))} disabled={page === 0}>
        上一頁
      </button>
      <button onClick={() => setPage((old) => old + 1)} disabled={isPlaceholderData}>
        下一頁
      </button>
    </div>
  );
}

無限滾動 (Infinite Queries)

對於「載入更多」或無限滾動的列表,使用 useInfiniteQuery。它的主要區別在於 queryFn 會接收一個 pageParam 對象,你需要用 getNextPageParam 來決定下一頁的參數。

import { useInfiniteQuery } from '@tanstack/react-query';

function InfiniteScroll() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ['projects-infinite'],
    queryFn: ({ pageParam = 0 }) => fetchProjects(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage, allPages) => {
      // 假設 API 回傳有 nextCursor,沒有則回傳 undefined 代表結束
      return lastPage.nextCursor;
    },
  });

  return (
    <>
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.projects.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </React.Fragment>
      ))}
      <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
        {isFetchingNextPage ? '載入中...' : hasNextPage ? '載入更多' : '沒有更多了'}
      </button>
    </>
  );
}

平行查詢 (Parallel Queries)

在 React Components 中,如果你寫了兩個 useQuery,它們預設就會同時 (Parallel) 發出請求,這是 React Query 的預設行為,不需要額外設定。

// 兩者同時請求,互不阻塞
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const teamsQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTeams });

動態平行查詢 (useQueries)

如果你的 query 數量是動態的(例如根據一個 ID 陣列去抓取多個項目),受限於 React Hook 規則,你不能在迴圈中呼叫 useQuery。這時要使用 useQueries

import { useQueries } from '@tanstack/react-query';

function DynamicUsers({ userIds }) {
  const userQueries = useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id),
    })),
  });

  // userQueries 是一個 Query Result 陣列
}

樂觀更新 (Optimistic Updates)

為了提供更好的 UX,我們可以在伺服器回應 之前 就先更新 UI。如果請求失敗,再回滾 (Rollback) 到原本的狀態。這通常在 onMutate 中實作。

const mutation = useMutation({
  mutationFn: updateTodo,
  // 當 mutate 被呼叫時:
  onMutate: async (newTodo) => {
    // 1. 取消相關查詢,避免舊資料覆蓋
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    // 2. 取得之前的快照值 (用於回滾)
    const previousTodos = queryClient.getQueryData(['todos']);

    // 3. 樂觀地更新 Cache
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);

    // 4. 回傳 context,包含 snapshot
    return { previousTodos };
  },
  // 如果發生錯誤:
  onError: (err, newTodo, context) => {
    // 使用 context 中的 snapshot 回滾資料
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  // 不管成功或失敗:
  onSettled: () => {
    // 重新抓取資料,確保是伺服器最新的狀態
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

預先抓取 (Prefetching)

如果我們可以預測使用者下一步會看什麼(例如滑鼠 hover 到連結上),可以預先載入資料,讓切換頁面時感覺幾乎是瞬間完成。

const queryClient = useQueryClient();

const prefetchTodo = async (id) => {
  // 預先抓取並快取,預設會保留 staleTime 的時間
  await queryClient.prefetchQuery({
    queryKey: ['todo', id],
    queryFn: () => fetchTodo(id),
  });
};

return (
  <Link
    to={`/todo/${id}`}
    onMouseEnter={() => prefetchTodo(id)} // 滑鼠移上去就開始載入
  >
    {title}
  </Link>
);

總結

使用 TanStack Query 後,你幾乎不需要在元件中使用 useEffect 來抓取資料,也不需要手動維護 loadingerror 的 state。它不但簡化了程式碼,還自動提供了快取和同步的強大功能。