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

__Frontend
React QueryNext.jsReact.jsData FetchingTanStack Query

什麼是 React Query?

React Query (現在也稱為 TanStack Query) 是一個強大的數據獲取和狀態管理庫,專為 React 應用設計,解決了前端應用中數據獲取的複雜問題。

  • 聲明式數據獲取:簡化 API 調用和數據管理
  • 自動緩存和重新獲取:智能管理數據的新鮮度
  • 內置的加載和錯誤狀態:無需手動管理這些狀態
  • 伺服器狀態同步:保持 UI 與伺服器數據同步

安裝和基本設置

首先,安裝 React Query 庫:

bash
# npm
npm install @tanstack/react-query
 
# yarn
yarn add @tanstack/react-query
 
# pnpm
pnpm add @tanstack/react-query

在 React 應用中設置

在 React 應用中,需要在應用的根部設置 QueryClientProvider

tsx
// 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

tsx
// 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 的核心,負責管理所有查詢和緩存。以下是常用的配置選項:

tsx
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 中兩個最重要的時間配置是 staleTimegcTime,理解它們的差異對於優化應用性能至關重要:

staleTime(過時時間)

  • 定義:數據被視為"新鮮"的時間長度
  • 作用:控制數據何時需要重新獲取
  • 默認值:0(數據立即被視為過時)
  • 行為
    • 當數據未過時時,不會在背景重新獲取
    • 當元件重新掛載或窗口重新獲得焦點時,不會重新獲取未過時的數據

gcTime(垃圾回收時間)

  • 定義:非活動查詢結果在緩存中保留的時間長度
  • 作用:控制未使用的查詢數據何時從緩存中移除
  • 默認值:5分鐘(300000毫秒)
  • 行為
    • 當查詢不再被任何元件使用時,計時開始
    • 計時結束後,查詢數據從緩存中移除
    • 之前稱為 cacheTime

實際應用示例

tsx
// 頻繁變化的數據,短的過時時間
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 獲取數據:

tsx
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>
    );
}

帶參數的查詢

tsx
function UserProfile({ userId }) {
    const { data: user } = useQuery({
        queryKey: ['user', userId], // 包含參數的查詢鍵
        queryFn: () => fetchUser(userId),
        enabled: !!userId, // 只有當 userId 存在時才執行查詢
    });
 
    // ...
}

依賴查詢

tsx
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 中預獲取數據:

tsx
// 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 輔助函數

tsx
// 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 用於處理數據變更操作,如創建、更新或刪除數據:

tsx
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 實現"加載更多"功能:

tsx
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:

tsx
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 支持取消正在進行的查詢:

tsx
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. 使用查詢鍵結構化數據

tsx
// 不好的做法
useQuery({ queryKey: ['todos-by-user-1'], ... })
 
// 好的做法
useQuery({ queryKey: ['todos', { userId: 1 }], ... })

2. 使用 select 轉換數據

tsx
const { data: todoCount } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select: (data) => data.length, // 只返回計數
});

3. 使用 placeholderData 提供即時 UI 反饋

tsx
const { data } = useQuery({
    queryKey: ['todo', id],
    queryFn: () => fetchTodo(id),
    placeholderData: () => {
        // 從另一個查詢中獲取數據作為佔位符
        const todos = queryClient.getQueryData(['todos']);
        return todos?.find((todo) => todo.id === id);
    },
});

4. 使用 keepPreviousData 在分頁查詢之間保持數據

tsx
const { data, isPreviousData } = useQuery({
    queryKey: ['todos', page],
    queryFn: () => fetchTodosByPage(page),
    keepPreviousData: true, // 加載新頁面時保留舊數據
});

5. 使用 setQueryDefaults 設置特定查詢的默認選項

tsx
// 在應用初始化時
queryClient.setQueryDefaults(['todos'], {
    staleTime: 5 * 60 * 1000, // 5分鐘
    gcTime: 10 * 60 * 1000, // 10分鐘
});

常見問題和解決方案

1. 數據不更新

如果數據不更新,可能是 staleTime 設置過長。嘗試:

tsx
// 減少 staleTime 或強制重新獲取
const { data, refetch } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    staleTime: 0, // 立即過時
});
 
// 手動重新獲取
<button onClick={() => refetch()}>Refresh</button>;

2. 緩存管理

清除特定查詢或所有查詢:

tsx
// 清除特定查詢
queryClient.removeQueries({ queryKey: ['todos', { userId: 1 }] });
 
// 使特定查詢失效(觸發重新獲取)
queryClient.invalidateQueries({ queryKey: ['todos'] });
 
// 重置所有查詢
queryClient.resetQueries();

3. 類型安全的查詢

使用 TypeScript 定義查詢類型:

tsx
interface Todo {
    id: number;
    title: string;
    completed: boolean;
}
 
const { data } = useQuery<Todo[], Error>({
    queryKey: ['todos'],
    queryFn: fetchTodos,
});

總結

React Query 是一個強大的數據獲取和狀態管理庫,可以大大簡化 React 和 Next.js 應用中的數據獲取邏輯。通過正確配置 staleTimegcTime,以及利用 useQueryuseMutationHydrationBoundary 等 API,你可以構建高性能、響應式的應用,同時提供出色的用戶體驗。

關鍵要點:

  • QueryClient 是核心,負責管理所有查詢和緩存
  • staleTime 控制數據何時需要重新獲取
  • gcTime 控制未使用的數據何時從緩存中移除
  • useQuery 用於獲取數據
  • useMutation 用於變更數據
  • HydrationBoundaryprefetchQuery 用於服務器端渲染和數據預獲取

通過掌握這些概念和 API,你可以在 React 和 Next.js 應用中實現高效、可靠的數據獲取策略。

參考來源