React Query 實用指南:在 Next.js 和 React.js 中的數據獲取
React QueryNext.jsReact.js+2 more
為什麼要用 React Query?
還記得以前每次要抓 API 資料,都要寫一堆 useState 和 useEffect:
javascript
// 以前的我...
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch('/api/users');
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
範例是用原生 fetch 啦,當然我知道現在 Client 端主流是用 axios ,但這不是重點,重點是每次都要自己處理 Loading、error 等,同時有 cache 需求時也還要寫的比較麻煩,直到我在看 Next.js 13 的官網文件時,才發現它們推薦在 Client 呼叫 API 使用 SWR 或 React Query,我才知道原來還有這麼好用的東西,一開始是先試試 SWR,但當我用過 React Query 後就回不去了XDD
什麼是 React Query?
React Query(現在叫 TanStack Query)是專門處理 API 狀態的函式庫。它幫你處理:
- Cache 管理:自動快取資料,避免重複請求
- 背景更新:當頁面重新聚焦時自動更新資料
- 錯誤處理:統一的錯誤處理機制
- Loading 狀態:自動管理載入狀態
- 樂觀更新:先更新 UI,失敗再回復
最重要的是,程式碼變得超級簡潔,然後 cache 設定變得超簡單,還有 devtools 在測試時超級好用!
安裝和基本設定
安裝套件
bash
pnpm add @tanstack/react-query
在 Next.js 中設定
我習慣建立一個 providers 檔案來管理:
typescript
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export default function QueryProvider({ children }: { children: React.ReactNode }) {
// 這樣每個用戶都有自己的 QueryClient 實例
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 5 分鐘後資料就算過期
staleTime: 5 * 60 * 1000,
// 快取保存 10 分鐘
gcTime: 10 * 60 * 1000,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
{/* 開發時很好用的測試工具 */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
然後在 layout 中使用:
typescript
import QueryProvider from '@/providers/query-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-TW">
<body>
<QueryProvider>
{children}
</QueryProvider>
</body>
</html>
);
}
useQuery:抓取資料的神器
這是我最常用的 hook:
typescript
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
// 抓取用戶資料的函數,可以另外寫在關於 api 的檔案裡
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
}
// 自訂 hook
export function useUsers() {
return useQuery({
queryKey: ['users'], // 快取的鍵值
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000, // 5 分鐘內不會重新抓取
});
}
在元件中使用:
typescript
import { useUsers } from '@/hooks/useUsers';
export default function UserList() {
const { data, isLoading, error } = useUsers();
if (isLoading) return <div>載入中...</div>;
if (error) return <div>錯誤:{error.message}</div>;
return (
<ul>
{data?.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
);
}
就這麼簡單!不用自己管理 loading、error、data 狀態。
在 Next.js 中的 Server 端預取
Next.js 的好處是可以在 Server 端先抓資料,用戶看到頁面時資料已經準備好了:
typescript
import { QueryClient, HydrationBoundary, dehydrate } from '@tanstack/react-query';
import UserList from '@/components/UserList';
async function fetchUsers() {
const response = await fetch('https://api.example.com/users');
return response.json();
}
export default async function UsersPage() {
const queryClient = new QueryClient();
// 在伺服器端預取資料
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div>
<h1>用戶列表</h1>
<UserList />
</div>
</HydrationBoundary>
);
}
useMutation:處理資料更新
用來處理新增、修改、刪除等操作:
typescript
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserData {
name: string;
email: string;
}
async function createUser(userData: CreateUserData) {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return response.json();
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onSuccess: () => {
// 新增成功後,讓用戶列表重新抓取
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error) => {
console.error('建立用戶失敗:', error);
},
});
}
在元件中使用:
typescript
import { useState } from 'react';
import { useCreateUser } from '@/hooks/useCreateUser';
export default function CreateUserForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const createUserMutation = useCreateUser();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createUserMutation.mutate({ name, email });
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="姓名"
required
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<button
type="submit"
disabled={createUserMutation.isPending}
>
{createUserMutation.isPending ? '建立中...' : '建立用戶'}
</button>
</form>
);
}
staleTime vs gcTime:我一直搞混的概念
這兩個參數我一開始總是搞不清楚:
staleTime(過期時間)
資料被認為是「新鮮」的時間。在這段時間內,不會重新抓取資料。
gcTime(垃圾回收時間,以前叫 cacheTime)
快取資料會保存多久。超過這個時間,資料會被從記憶體中清除。
typescript
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000, // 5 分鐘內資料是新鮮的
gcTime: 30 * 60 * 1000, // 30 分鐘後清除快取
});
我的建議:
- staleTime:根據資料更新頻率設定(使用者資料可能設 5 分鐘)
- gcTime:通常設定比 staleTime 長一點
實戰技巧分享
1. 條件性查詢
有時候你需要在某些條件滿足時才抓取資料:
typescript
function useUserProfile(userId?: number) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId!),
enabled: !!userId, // 只有當 userId 存在時才執行
});
}
2. 分頁查詢
typescript
function useUsers(page: number = 1) {
return useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
keepPreviousData: true, // 切換頁面時保留前一頁資料
});
}
3. 樂觀更新
先更新 UI,API 失敗再回復:
typescript
const updateUserMutation = useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
// 取消進行中的查詢
await queryClient.cancelQueries({ queryKey: ['users'] });
// 先更新快取
const previousUsers = queryClient.getQueryData(['users']);
queryClient.setQueryData(['users'], (old: User[]) =>
old.map((user) => (user.id === newUser.id ? newUser : user))
);
return { previousUsers };
},
onError: (err, newUser, context) => {
// 失敗時回復
queryClient.setQueryData(['users'], context?.previousUsers);
},
onSettled: () => {
// 不管成功失敗都重新抓取
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
效能優化建議
1. 適當設定 staleTime
typescript
// 很少變動的資料
const { data } = useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
staleTime: 30 * 60 * 1000, // 30 分鐘
});
// 經常變動的資料
const { data } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 30 * 1000, // 30 秒
});
2. 使用 select 減少重新渲染
typescript
const { data: userNames } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users) => users.map((user) => user.name), // 只關心名字
});
3. 分割大查詢
不要一次抓太多資料,可以分成多個小查詢。