website logogarylin.dev

搜尋文章

搜尋文章標題、描述、分類或標籤

React Query 實用指南:在 Next.js 和 React.js 中的數據獲取

2025/03/02Code
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 檔案來管理:

src/providers/query-provider.tsx
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 中使用:

src/app/layout.tsx
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:

src/hooks/useUsers.ts
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 分鐘內不會重新抓取
    });
}

在元件中使用:

src/components/UserList.tsx
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 端先抓資料,用戶看到頁面時資料已經準備好了:

src/app/users/page.tsx
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:處理資料更新

用來處理新增、修改、刪除等操作:

src/hooks/useCreateUser.ts
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);
        },
    });
}

在元件中使用:

src/components/CreateUserForm.tsx
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. 分割大查詢

不要一次抓太多資料,可以分成多個小查詢。