在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毫秒延遲的極致順暢體驗!