iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Vibe Coding

Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地系列 第 17

Day 17|前後端串接TanStack Query (React Query) 異步資料流實戰

  • 分享至 

  • xImage
  •  

在Day16中,我們成功將Next.jsRouteHandlers與PrismaORM徹底串接,讓CRUD操作能在PostgreSQL資料庫中實現真正的持久化。

然而,如果前端組件依然使用傳統的useEffect+fetch手動維護資料,我們將面臨一連串棘手問題:競態條件(RaceConditions)、缺乏快取(CacheMissing)、重複請求(RedundantFetches)以及資料新增/修改後畫面未能自動同步更新。

今天(Day17),我們將在前端導入React生態系最強大的伺服器狀態(ServerState)管理庫TanStackQueryv5(ReactQuery)!我們將建立APIClient封裝、配置QueryClientProvider、封裝CustomCustomHooks,並實作Mutations與OptimisticUpdates(樂觀更新)!

1.為什麼是TanStackQuery?(ClientStatevsServerState)
在現代前端開發中,狀態主要分為兩大類:

ClientState:完全由前端UI控制(如:Modal是否開啟、Sidebar開合狀態、DarkMode切換),適合用ReactuseState或Zustand管理。

ServerState:存儲於遠端資料庫(如:技能清單、用戶資料),具備非同步性、共享性與潛在的過時風險(Stale)。

2.實戰步驟1:安裝與設定QueryClientProvider
步驟A:安裝套件
在Terminal執行:

npm install @tanstack/react-query @tanstack/react-query-devtools

步驟B:建立API Client封裝(lib/api/skillsClient.ts)
請AI建立前端呼叫API端點的獨立Client:
// lib/api/skillsClient.ts
import { Skill, CreateSkillInput, UpdateSkillInput } from '@/types/skill';

export async function fetchSkills(category?: string, search?: string): Promise<Skill[]> {
const params = new URLSearchParams();
if (category && category !== 'All') params.append('category', category);
if (search) params.append('search', search);

const res = await fetch(/api/skills?${params.toString()});
const json = await res.json();

if (!res.ok) throw new Error(json.error || '無法載入技能資料');
return json.data;
}

export async function createSkill(input: CreateSkillInput): Promise {
const res = await fetch('/api/skills', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();

if (!res.ok) throw new Error(json.error || '新增技能失敗');
return json.data;
}

export async function updateSkill({ id, ...data }: UpdateSkillInput & { id: string }): Promise {
const res = await fetch(/api/skills/${id}, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const json = await res.json();

if (!res.ok) throw new Error(json.error || '更新技能失敗');
return json.data;
}

export async function deleteSkill(id: string): Promise {
const res = await fetch(/api/skills/${id}, { method: 'DELETE' });
const json = await res.json();

if (!res.ok) throw new Error(json.error || '刪除技能失敗');
}
步驟C:建立Provider組件(components/providers/QueryProvider.tsx)
由於Next.jsAppRouter預設為ServerComponent,我們需要將Provider封裝至帶有'useclient'的組件中,並確保QueryClient實例不會在SSR過程中跨請求洩漏:

// components/providers/QueryProvider.tsx
'use client';

import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

export default function QueryProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 1000 * 60 * 5, // 5 分鐘內視為 fresh,避免重複 fetch
            gcTime: 1000 * 60 * 30,    // 垃圾回收時間 30 分鐘
            refetchOnWindowFocus: false,
            retry: 1,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

請將QueryProvider包覆在app/layout.tsx的中。

3.實戰步驟2:封裝靈魂CustomHooks(hooks/useSkills.ts)
開啟Cursor,對著@hooks/useSkills.ts發送Prompt:

@lib/api/skillsClient.ts
@types/skill.ts

請幫我建立 `hooks/useSkills.ts`,將 TanStack Query 的 `useQuery` 與 `useMutation` 整合封裝為乾淨的 Custom Hooks。

需求細節:
1.`useSkills(category, search)` Hook:
   - 使用 `useQuery` 讀取清單。
   - Query Key 設定為 `['skills', { category, search }]`。
2.`useCreateSkill()` Mutation:
   - 成功後調用 `queryClient.invalidateQueries({ queryKey: ['skills'] })`,自動重新刷新列表。
3.`useUpdateSkill()` Mutation:
   - 實作 Optimistic Updates(樂觀更新):在 API 回傳前先更新快取的 UI,若發生 Error 則自動 Rollback(回滾)舊值!
4.`useDeleteSkill()` Mutation:
   - 成功後 invalidate `['skills']` 快取。

AI產出的hooks/useSkills.ts:

// hooks/useSkills.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
  fetchSkills,
  createSkill,
  updateSkill,
  deleteSkill,
} from '@/lib/api/skillsClient';
import { Skill } from '@/types/skill';

export const SKILLS_QUERY_KEY = ['skills'];

// 1. 讀取技能清單
export function useSkills(category?: string, search?: string) {
  return useQuery({
    queryKey: [...SKILLS_QUERY_KEY, { category, search }],
    queryFn: () => fetchSkills(category, search),
  });
}

// 2. 新增技能
export function useCreateSkill() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createSkill,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: SKILLS_QUERY_KEY });
    },
  });
}

// 3. 更新技能 (含 Optimistic Updates 樂觀更新)
export function useUpdateSkill() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateSkill,
    onMutate: async (updatedSkill) => {
      // 取消相關的發送中的重新抓取,避免覆蓋樂觀更新
      await queryClient.cancelQueries({ queryKey: SKILLS_QUERY_KEY });

      // 快照先前的快取資料
      const previousSkills = queryClient.getQueryData<Skill[]>(SKILLS_QUERY_KEY);

      // 樂觀更新本地快取
      if (previousSkills) {
        queryClient.setQueryData<Skill[]>(SKILLS_QUERY_KEY, (old = []) =>
          old.map((skill) =>
            skill.id === updatedSkill.id ? { ...skill, ...updatedSkill } : skill
          )
        );
      }

      return { previousSkills };
    },
    onError: (_err, _newSkill, context) => {
      // 發生錯誤時回滾舊狀態
      if (context?.previousSkills) {
        queryClient.setQueryData(SKILLS_QUERY_KEY, context.previousSkills);
      }
    },
    onSettled: () => {
      // 完成後與伺服器重新同步
      queryClient.invalidateQueries({ queryKey: SKILLS_QUERY_KEY });
    },
  });
}

// 4. 刪除技能
export function useDeleteSkill() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: deleteSkill,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: SKILLS_QUERY_KEY });
    },
  });
}

4.實戰步驟3:前端主頁面全面替換Hook(app/page.tsx)
現在,我們打開主頁面app/page.tsx,請AI將舊有的全域Zustand/In-MemoryState替換為我們剛才寫好的useSkillsHooks!

@hooks/useSkills.ts
@app/page.tsx

請重構`app/page.tsx`,改用`useSkills`進行資料讀取與操作。

需求細節:
1.使用`const{data:skills=[],isLoading,isError,error}=useSkills(selectedCategory,searchKeyword);`。
2.處理`isLoading`狀態,顯示Skeleton載入骨架屏。
3.處理`isError`狀態,顯示醒目的錯誤提示面板與重試按鈕。
4.將技能卡片的熟練度微調按鈕直接綁定`useUpdateSkill().mutate(...)`。

今天我們成功完成VibePulse前後端連線的最終躍升:

在Next.jsAppRouter中配置了符合最佳實踐的QueryClientProvider。

封裝了型別安全的APIClient與ReactQueryCustomHooks。

導入了OptimisticUpdates(樂觀更新),讓用戶在修改熟練度時獲得0毫秒延遲的極致順暢體驗!


上一篇
Day 16|將Route Handlers與Prisma ORM徹底串接CRUD完整落地
下一篇
Day 18|進階功能實作多欄位搜尋、Prisma分頁與URL狀態同步
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言