昨天我們成功建立了 ORPC 客戶端,實現了型別安全的前後端通訊,並完成了 Vercel 部署。今天我們要來討論現代前端開發的核心議題:合適的資料狀態管理。
當你的應用變得複雜時,會發現純粹的客戶端狀態管理已經不夠了。我們需要一個更適合的方式來處理:
在現代前端應用中,大部分的狀態其實都是伺服器狀態。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()
});
cd apps/kyo-dashboard
pnpm add @tanstack/react-query @tanstack/react-query-devtools
首先建立一個全域的 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,
},
},
});
// 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>
);
}
建立可重用的 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',
});
},
});
}
// 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',
});
}
},
});
}
現在你的元件變得超級簡潔:
// 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'] });
},
});
}
別忘了使用 DevTools 來觀察查詢狀態:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// 在你的 App 元件底部加入
<ReactQueryDevtools initialIsOpen={false} />
這個工具可以讓你:
通過整合 React Query,我們的應用現在具備了:
✅ 智慧快取:自動管理資料的新鮮度和過期
✅ 背景同步:在使用者不察覺的情況下保持資料最新
✅ 錯誤處理:統一的錯誤處理和使用者提示
✅ 載入狀態:精確的載入狀態追蹤
✅ 樂觀更新:即時的使用者回饋
✅ 離線支援:網路恢復時自動同步