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 類型的請求。它需要兩個主要參數:
- queryKey: 一個陣列,用來唯一識別這個資料。當 key 改變時,React Query 會自動重新獲取資料。
- 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 來抓取資料,也不需要手動維護 loading 和 error 的 state。它不但簡化了程式碼,還自動提供了快取和同步的強大功能。