React Query 實用指南:在 Next.js 和 React.js 中的數據獲取
什麼是 React Query?
React Query (現在也稱為 TanStack Query) 是一個強大的數據獲取和狀態管理庫,專為 React 應用設計,解決了前端應用中數據獲取的複雜問題。
- 聲明式數據獲取:簡化 API 調用和數據管理
- 自動緩存和重新獲取:智能管理數據的新鮮度
- 內置的加載和錯誤狀態:無需手動管理這些狀態
- 伺服器狀態同步:保持 UI 與伺服器數據同步
安裝和基本設置
首先,安裝 React Query 庫:
# npm
npm install @tanstack/react-query
# yarn
yarn add @tanstack/react-query
# pnpm
pnpm add @tanstack/react-query
在 React 應用中設置
在 React 應用中,需要在應用的根部設置 QueryClientProvider
:
// src/main.tsx 或 src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
// 創建一個 QueryClient 實例
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分鐘
gcTime: 5 * 60 * 1000, // 5分鐘
retry: 1, // 失敗時重試1次
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} /> {/* 開發工具 */}
</QueryClientProvider>
</React.StrictMode>,
);
在 Next.js 應用中設置
在 Next.js 中,我們通常在 layout.tsx
(App Router) 中設置:
App Router
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分鐘
gcTime: 5 * 60 * 1000, // 5分鐘
},
},
});
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// app/layout.tsx
import Providers from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
QueryClient 常用配置選項
QueryClient
是 React Query 的核心,負責管理所有查詢和緩存。以下是常用的配置選項:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 數據被視為過時前的時間(毫秒)
gcTime: 5 * 60 * 1000, // 未使用的數據被垃圾回收前的時間(毫秒)
retry: 3, // 失敗時重試次數
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // 重試延遲
refetchOnWindowFocus: true, // 窗口獲得焦點時重新獲取
refetchOnMount: true, // 元件掛載時重新獲取
refetchOnReconnect: true, // 網絡重新連接時重新獲取
suspense: false, // 是否使用 React Suspense
},
mutations: {
retry: 0, // 變更操作默認不重試
},
},
});
staleTime vs gcTime:關鍵差異
React Query 中兩個最重要的時間配置是 staleTime
和 gcTime
,理解它們的差異對於優化應用性能至關重要:
staleTime(過時時間)
- 定義:數據被視為"新鮮"的時間長度
- 作用:控制數據何時需要重新獲取
- 默認值:0(數據立即被視為過時)
- 行為:
- 當數據未過時時,不會在背景重新獲取
- 當元件重新掛載或窗口重新獲得焦點時,不會重新獲取未過時的數據
gcTime(垃圾回收時間)
- 定義:非活動查詢結果在緩存中保留的時間長度
- 作用:控制未使用的查詢數據何時從緩存中移除
- 默認值:5分鐘(300000毫秒)
- 行為:
- 當查詢不再被任何元件使用時,計時開始
- 計時結束後,查詢數據從緩存中移除
- 之前稱為
cacheTime
實際應用示例
// 頻繁變化的數據,短的過時時間
const { data: stockPrices } = useQuery({
queryKey: ['stocks'],
queryFn: fetchStockPrices,
staleTime: 10 * 1000, // 10秒後過時
gcTime: 60 * 1000, // 1分鐘後垃圾回收
});
// 不常變化的數據,長的過時時間
const { data: userProfile } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUserProfile(userId),
staleTime: 24 * 60 * 60 * 1000, // 24小時後過時
gcTime: 7 * 24 * 60 * 60 * 1000, // 7天後垃圾回收
});
數據流程
-
當新元件請求相同查詢時,TanStack Query 會:
- 先立即返回緩存中的數據(即使是過時的)
- 然後在背景中重新獲取新數據
-
這種過時但可用的策略有幾個好處:
- 用戶立即看到內容(雖然可能稍微過時)
- 避免了頁面空白等待
- 當新數據獲取完成後,UI會自動更新
useQuery:基本數據獲取
useQuery
是 React Query 最常用的 Hook,用於從 API 獲取數據:
import { useQuery } from '@tanstack/react-query';
// 定義獲取函數
const fetchTodos = async () => {
const response = await fetch('https://api.example.com/todos');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};
function TodoList() {
// 使用 useQuery 獲取數據
const {
data, // 獲取的數據
isLoading, // 是否正在加載
isError, // 是否發生錯誤
error, // 錯誤對象
refetch, // 手動重新獲取的函數
isFetching, // 是否正在獲取(包括背景重新獲取)
} = useQuery({
queryKey: ['todos'], // 唯一標識這個查詢的鍵
queryFn: fetchTodos, // 獲取數據的函數
staleTime: 60 * 1000, // 1分鐘後數據過時
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<div>
<h1>Todo List</h1>
<button onClick={() => refetch()}>Refresh</button>
{isFetching && <div>Refreshing...</div>}
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}
帶參數的查詢
function UserProfile({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId], // 包含參數的查詢鍵
queryFn: () => fetchUser(userId),
enabled: !!userId, // 只有當 userId 存在時才執行查詢
});
// ...
}
依賴查詢
function UserPosts({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// 依賴於上一個查詢的結果
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPosts(userId),
enabled: !!user, // 只有當用戶數據加載完成後才獲取帖子
});
// ...
}
在 Next.js 中使用 React Query
App Router 中的預獲取
在 App Router 中,我們可以在 Server Components 中預獲取數據:
// app/todos/page.tsx
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/getQueryClient';
import TodoList from './TodoList';
// 獲取函數
async function fetchTodos() {
const res = await fetch('https://api.example.com/todos');
return res.json();
}
export default async function TodosPage() {
const queryClient = getQueryClient();
// 預獲取數據
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
);
}
// app/todos/TodoList.tsx (Client Component)
('use client');
import { useQuery } from '@tanstack/react-query';
// 獲取函數(與服務器相同)
const fetchTodos = async () => {
const res = await fetch('https://api.example.com/todos');
return res.json();
};
export default function TodoList() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// ...渲染數據
}
創建 getQueryClient 輔助函數
// lib/getQueryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { cache } from 'react';
// 使用 React 的 cache 函數確保每個請求只創建一個 QueryClient
export const getQueryClient = cache(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
},
},
}),
);
變更數據:useMutation
useMutation
用於處理數據變更操作,如創建、更新或刪除數據:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddTodoForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
// 定義變更操作
const addTodoMutation = useMutation({
mutationFn: async (newTodo) => {
const response = await fetch('https://api.example.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
return response.json();
},
// 變更成功後更新查詢緩存
onSuccess: (data) => {
// 使緩存失效,強制重新獲取
queryClient.invalidateQueries({ queryKey: ['todos'] });
// 或者直接更新緩存
queryClient.setQueryData(['todos'], (oldData) => [...oldData, data]);
// 重置表單
setTitle('');
},
});
const handleSubmit = (e) => {
e.preventDefault();
addTodoMutation.mutate({ title, completed: false });
};
return (
<form onSubmit={handleSubmit}>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Add a new todo" />
<button type="submit" disabled={addTodoMutation.isPending}>
{addTodoMutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
{addTodoMutation.isError && <div>Error: {addTodoMutation.error.message}</div>}
</form>
);
}
高級用法
無限查詢(分頁加載)
使用 useInfiniteQuery
實現"加載更多"功能:
import { useInfiniteQuery } from '@tanstack/react-query';
function InfiniteTodoList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = useInfiniteQuery({
queryKey: ['infiniteTodos'],
queryFn: async ({ pageParam = 1 }) => {
const res = await fetch(`https://api.example.com/todos?page=${pageParam}`);
return res.json();
},
getNextPageParam: (lastPage, allPages) => {
return lastPage.nextPage ?? undefined;
},
});
if (status === 'pending') return <div>Loading...</div>;
if (status === 'error') return <div>Error!</div>;
return (
<div>
{data.pages.map((page, i) => (
<React.Fragment key={i}>
{page.todos.map((todo) => (
<div key={todo.id}>{todo.title}</div>
))}
</React.Fragment>
))}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load More' : 'Nothing more to load'}
</button>
</div>
);
}
樂觀更新
在等待伺服器響應的同時立即更新 UI:
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const toggleMutation = useMutation({
mutationFn: async () => {
const response = await fetch(`https://api.example.com/todos/${todo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
});
return response.json();
},
// 樂觀更新
onMutate: async (variables) => {
// 取消任何外部獲取
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 保存之前的數據
const previousTodos = queryClient.getQueryData(['todos']);
// 樂觀地更新緩存
queryClient.setQueryData(['todos'], (old) =>
old.map((t) => (t.id === todo.id ? { ...t, completed: !t.completed } : t)),
);
// 返回上下文對象
return { previousTodos };
},
// 如果變更失敗,使用保存的數據回滾
onError: (err, variables, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
// 變更完成後(無論成功或失敗)
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</span>
</div>
);
}
查詢取消
React Query 支持取消正在進行的查詢:
import { useQuery } from '@tanstack/react-query';
function SearchResults({ query }) {
const { data, isLoading } = useQuery({
queryKey: ['search', query],
queryFn: async ({ signal }) => {
// 傳遞 AbortSignal 到 fetch
const response = await fetch(`https://api.example.com/search?q=${query}`, { signal });
return response.json();
},
enabled: !!query,
});
// ...
}
實用技巧和最佳實踐
1. 使用查詢鍵結構化數據
// 不好的做法
useQuery({ queryKey: ['todos-by-user-1'], ... })
// 好的做法
useQuery({ queryKey: ['todos', { userId: 1 }], ... })
2. 使用 select 轉換數據
const { data: todoCount } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.length, // 只返回計數
});
3. 使用 placeholderData 提供即時 UI 反饋
const { data } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
placeholderData: () => {
// 從另一個查詢中獲取數據作為佔位符
const todos = queryClient.getQueryData(['todos']);
return todos?.find((todo) => todo.id === id);
},
});
4. 使用 keepPreviousData 在分頁查詢之間保持數據
const { data, isPreviousData } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodosByPage(page),
keepPreviousData: true, // 加載新頁面時保留舊數據
});
5. 使用 setQueryDefaults 設置特定查詢的默認選項
// 在應用初始化時
queryClient.setQueryDefaults(['todos'], {
staleTime: 5 * 60 * 1000, // 5分鐘
gcTime: 10 * 60 * 1000, // 10分鐘
});
常見問題和解決方案
1. 數據不更新
如果數據不更新,可能是 staleTime
設置過長。嘗試:
// 減少 staleTime 或強制重新獲取
const { data, refetch } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 0, // 立即過時
});
// 手動重新獲取
<button onClick={() => refetch()}>Refresh</button>;
2. 緩存管理
清除特定查詢或所有查詢:
// 清除特定查詢
queryClient.removeQueries({ queryKey: ['todos', { userId: 1 }] });
// 使特定查詢失效(觸發重新獲取)
queryClient.invalidateQueries({ queryKey: ['todos'] });
// 重置所有查詢
queryClient.resetQueries();
3. 類型安全的查詢
使用 TypeScript 定義查詢類型:
interface Todo {
id: number;
title: string;
completed: boolean;
}
const { data } = useQuery<Todo[], Error>({
queryKey: ['todos'],
queryFn: fetchTodos,
});
總結
React Query 是一個強大的數據獲取和狀態管理庫,可以大大簡化 React 和 Next.js 應用中的數據獲取邏輯。通過正確配置 staleTime
和 gcTime
,以及利用 useQuery
、useMutation
和 HydrationBoundary
等 API,你可以構建高性能、響應式的應用,同時提供出色的用戶體驗。
關鍵要點:
- QueryClient 是核心,負責管理所有查詢和緩存
- staleTime 控制數據何時需要重新獲取
- gcTime 控制未使用的數據何時從緩存中移除
- useQuery 用於獲取數據
- useMutation 用於變更數據
- HydrationBoundary 和 prefetchQuery 用於服務器端渲染和數據預獲取
通過掌握這些概念和 API,你可以在 React 和 Next.js 應用中實現高效、可靠的數據獲取策略。