iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 7

Day 7: React Query 與資料狀態管理

  • 分享至 

  • xImage
  •  

前情提要

昨天我們成功建立了 ORPC 客戶端,實現了型別安全的前後端通訊,並完成了 Vercel 部署。今天我們要來討論現代前端開發的核心議題:合適的資料狀態管理

當你的應用變得複雜時,會發現純粹的客戶端狀態管理已經不夠了。我們需要一個更適合的方式來處理:

  • 🔄 API 請求的載入狀態
  • 📦 資料快取與同步
  • 🔁 背景重新驗證
  • ⚡ 樂觀更新
  • 🌐 離線支援

為什麼選擇 React Query?

在現代前端應用中,大部分的狀態其實都是伺服器狀態。React Query(現在叫 TanStack Query)專門解決這個問題:

// ❌ 傳統方式:手動管理一大堆狀態
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
  setLoading(true);
  fetch('/api/templates')
    .then(res => res.json())
    .then(data => {
      setTemplates(data);
      setLoading(false);
    })
    .catch(err => {
      setError(err);
      setLoading(false);
    });
}, []);

// ✅ React Query:一行搞定所有狀態
const { data: templates, isLoading, error } = useQuery({
  queryKey: ['templates'],
  queryFn: () => api.templates.list()
});

實作步驟

1. 安裝相關套件

cd apps/kyo-dashboard
pnpm add @tanstack/react-query @tanstack/react-query-devtools

2. 設定 Query Client

首先建立一個全域的 Query Client 配置:

// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 資料被認為是新鮮的時間(5分鐘)
      staleTime: 5 * 60 * 1000,
      // 背景重新取得的間隔(10分鐘)
      refetchInterval: 10 * 60 * 1000,
      // 視窗重新聚焦時重新取得
      refetchOnWindowFocus: true,
      // 網路重新連線時重新取得
      refetchOnReconnect: true,
      // 重試次數
      retry: (failureCount, error: any) => {
        // 4xx 錯誤不重試
        if (error?.status >= 400 && error?.status < 500) {
          return false;
        }
        // 最多重試 3 次
        return failureCount < 3;
      },
    },
    mutations: {
      // 預設重試一次
      retry: 1,
    },
  },
});

3. 包裹應用程式

// src/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './lib/queryClient';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MantineProvider theme={theme}>
        <Notifications />
        <Router>
          {/* 你的應用內容 */}
        </Router>
        {/* 開發時顯示 React Query DevTools */}
        <ReactQueryDevtools initialIsOpen={false} />
      </MantineProvider>
    </QueryClientProvider>
  );
}

4. 建立 API 客戶端 Hooks

建立可重用的 API hooks:

// src/hooks/useTemplates.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';

interface Template {
  id: number;
  name: string;
  content: string;
  isActive: boolean;
  createdAt: string;
  updatedAt: string;
}

// 查詢模板列表
export function useTemplates() {
  return useQuery({
    queryKey: ['templates'],
    queryFn: async (): Promise<Template[]> => {
      const response = await fetch('http://localhost:3000/api/templates');
      if (!response.ok) {
        throw new Error('Failed to fetch templates');
      }
      return response.json();
    },
  });
}

// 建立模板
export function useCreateTemplate() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>) => {
      const response = await fetch('http://localhost:3000/api/templates', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(template),
      });

      if (!response.ok) {
        throw new Error('Failed to create template');
      }

      return response.json();
    },
    onSuccess: () => {
      // 建立成功後,立即重新取得模板列表
      queryClient.invalidateQueries({ queryKey: ['templates'] });
      notifications.show({
        title: '成功',
        message: '模板建立成功!',
        color: 'green',
      });
    },
    onError: (error) => {
      notifications.show({
        title: '錯誤',
        message: `建立模板失敗:${error.message}`,
        color: 'red',
      });
    },
  });
}

// 更新模板
export function useUpdateTemplate() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ id, ...template }: Partial<Template> & { id: number }) => {
      const response = await fetch(`http://localhost:3000/api/templates/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(template),
      });

      if (!response.ok) {
        throw new Error('Failed to update template');
      }

      return response.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['templates'] });
      notifications.show({
        title: '成功',
        message: '模板更新成功!',
        color: 'green',
      });
    },
  });
}

// 刪除模板
export function useDeleteTemplate() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (id: number) => {
      const response = await fetch(`http://localhost:3000/api/templates/${id}`, {
        method: 'DELETE',
      });

      if (!response.ok) {
        throw new Error('Failed to delete template');
      }
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['templates'] });
      notifications.show({
        title: '成功',
        message: '模板刪除成功!',
        color: 'green',
      });
    },
  });
}

5. OTP 相關的 Hooks

// src/hooks/useOtp.ts
import { useMutation } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';

interface OtpSendRequest {
  phone: string;
  templateId?: number;
}

interface OtpVerifyRequest {
  phone: string;
  otp: string;
}

export function useSendOtp() {
  return useMutation({
    mutationFn: async (data: OtpSendRequest) => {
      const response = await fetch('http://localhost:3000/api/otp/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'Failed to send OTP');
      }

      return response.json();
    },
    onSuccess: (data) => {
      notifications.show({
        title: '驗證碼已發送',
        message: `訊息 ID: ${data.msgId}`,
        color: 'green',
      });
    },
    onError: (error) => {
      notifications.show({
        title: '發送失敗',
        message: error.message,
        color: 'red',
      });
    },
  });
}

export function useVerifyOtp() {
  return useMutation({
    mutationFn: async (data: OtpVerifyRequest) => {
      const response = await fetch('http://localhost:3000/api/otp/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'Verification failed');
      }

      return response.json();
    },
    onSuccess: (data) => {
      if (data.success) {
        notifications.show({
          title: '驗證成功',
          message: '手機號碼驗證完成!',
          color: 'green',
        });
      } else {
        notifications.show({
          title: '驗證失敗',
          message: `驗證碼錯誤,剩餘嘗試次數:${data.attemptsLeft || 0}`,
          color: 'yellow',
        });
      }
    },
  });
}

6. 在元件中使用

現在你的元件變得超級簡潔:

// src/pages/templates.tsx
import { useTemplates, useCreateTemplate, useDeleteTemplate } from '../hooks/useTemplates';

export function TemplatesPage() {
  const { data: templates, isLoading, error } = useTemplates();
  const createMutation = useCreateTemplate();
  const deleteMutation = useDeleteTemplate();

  if (isLoading) return <Loader />;
  if (error) return <Alert color="red">載入失敗:{error.message}</Alert>;

  return (
    <Container>
      <Stack>
        <Group justify="space-between">
          <Title order={2}>簡訊模板管理</Title>
          <Button
            onClick={() => setCreateModalOpen(true)}
            loading={createMutation.isPending}
          >
            新增模板
          </Button>
        </Group>

        <Table>
          <Table.Thead>
            <Table.Tr>
              <Table.Th>名稱</Table.Th>
              <Table.Th>內容</Table.Th>
              <Table.Th>狀態</Table.Th>
              <Table.Th>操作</Table.Th>
            </Table.Tr>
          </Table.Thead>
          <Table.Tbody>
            {templates?.map((template) => (
              <Table.Tr key={template.id}>
                <Table.Td>{template.name}</Table.Td>
                <Table.Td>{template.content}</Table.Td>
                <Table.Td>
                  <Badge color={template.isActive ? 'green' : 'gray'}>
                    {template.isActive ? '啟用' : '停用'}
                  </Badge>
                </Table.Td>
                <Table.Td>
                  <Group gap="xs">
                    <Button size="xs" variant="light">編輯</Button>
                    <Button
                      size="xs"
                      color="red"
                      variant="light"
                      loading={deleteMutation.isPending}
                      onClick={() => deleteMutation.mutate(template.id)}
                    >
                      刪除
                    </Button>
                  </Group>
                </Table.Td>
              </Table.Tr>
            ))}
          </Table.Tbody>
        </Table>
      </Stack>
    </Container>
  );
}

進階功能:樂觀更新

對於使用者體驗要求較高的操作,我們可以使用樂觀更新:

export function useToggleTemplateStatus() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => {
      const response = await fetch(`http://localhost:3000/api/templates/${id}/toggle`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ isActive }),
      });

      if (!response.ok) throw new Error('Failed to toggle status');
      return response.json();
    },

    // 樂觀更新:立即更新 UI,不等待伺服器回應
    onMutate: async ({ id, isActive }) => {
      // 取消進行中的查詢,避免覆蓋樂觀更新
      await queryClient.cancelQueries({ queryKey: ['templates'] });

      // 備份當前資料
      const previousTemplates = queryClient.getQueryData(['templates']);

      // 樂觀更新
      queryClient.setQueryData(['templates'], (old: Template[]) =>
        old?.map(template =>
          template.id === id ? { ...template, isActive } : template
        )
      );

      // 回傳備份資料,以便錯誤時復原
      return { previousTemplates };
    },

    // 錯誤時復原
    onError: (err, variables, context) => {
      if (context?.previousTemplates) {
        queryClient.setQueryData(['templates'], context.previousTemplates);
      }
    },

    // 無論成功或失敗,都重新取得最新資料
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['templates'] });
    },
  });
}

React Query DevTools

別忘了使用 DevTools 來觀察查詢狀態:

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// 在你的 App 元件底部加入
<ReactQueryDevtools initialIsOpen={false} />

這個工具可以讓你:

  • 🔍 查看所有活躍的查詢和突變
  • 📊 監控快取狀態
  • 🔄 手動觸發重新取得
  • 📈 分析效能指標

今日成果

通過整合 React Query,我們的應用現在具備了:

智慧快取:自動管理資料的新鮮度和過期
背景同步:在使用者不察覺的情況下保持資料最新
錯誤處理:統一的錯誤處理和使用者提示
載入狀態:精確的載入狀態追蹤
樂觀更新:即時的使用者回饋
離線支援:網路恢復時自動同步


上一篇
Day 6: 型別安全的 API 整合與前端部署實戰
下一篇
Day 8: React Hook Form 與進階表單處理
系列文
30 天製作工作室 SaaS 產品 (前端篇)8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言