昨天我們建立了 React Query 資料狀態管理,學會了如何優化地處理 API 請求、快取和同步。今天我們要轉向前端開發的另一個重要議題:表單處理與驗證。
在現代前端應用中,表單是使用者互動的核心,尤其是在 OTP 服務中:
React Hook Form 在現代 React 開發中脫穎而出的原因:
// ❌ 傳統受控組件:每次輸入都會重新渲染
const [phone, setPhone] = useState('');
const [otp, setOtp] = useState('');
// 每次輸入都觸發整個組件重新渲染
<input value={phone} onChange={(e) => setPhone(e.target.value)} />
// ✅ React Hook Form:最小化重新渲染
const { register, handleSubmit } = useForm();
// 只在必要時才重新渲染
<input {...register('phone')} />
// ❌ 手動驗證:繁瑣且容易出錯
const [errors, setErrors] = useState({});
const validatePhone = (phone) => {
if (!phone) return '手機號碼為必填';
if (!/^\+886\d{9}$/.test(phone)) return '手機號碼格式錯誤';
return null;
};
// ✅ React Hook Form:宣告式驗證
const { register } = useForm();
<input
{...register('phone', {
required: '手機號碼為必填',
pattern: {
value: /^\+886\d{9}$/,
message: '手機號碼格式錯誤'
}
})}
/>
cd apps/kyo-dashboard
pnpm add react-hook-form @hookform/resolvers zod
使用 Zod 建立型別安全的驗證規則:
// src/schemas/otpSchemas.ts
import { z } from 'zod';
// 台灣手機號碼格式驗證
const phoneRegex = /^(\+886|0)(9\d{8})$/;
export const otpSendSchema = z.object({
phone: z
.string()
.min(1, '手機號碼為必填')
.regex(phoneRegex, '請輸入正確的台灣手機號碼格式'),
templateId: z
.number()
.optional()
.nullable(),
});
export const otpVerifySchema = z.object({
phone: z
.string()
.min(1, '手機號碼為必填')
.regex(phoneRegex, '請輸入正確的台灣手機號碼格式'),
otp: z
.string()
.min(1, '驗證碼為必填')
.length(6, '驗證碼必須為 6 位數字')
.regex(/^\d{6}$/, '驗證碼只能包含數字'),
});
// 模板管理表單
export const templateSchema = z.object({
name: z
.string()
.min(1, '模板名稱為必填')
.max(50, '模板名稱不能超過 50 個字元'),
content: z
.string()
.min(1, '模板內容為必填')
.max(500, '模板內容不能超過 500 個字元')
.refine(
(content) => content.includes('{code}'),
'模板內容必須包含 {code} 占位符'
),
isActive: z.boolean().default(true),
});
// 推斷 TypeScript 類型
export type OtpSendForm = z.infer<typeof otpSendSchema>;
export type OtpVerifyForm = z.infer<typeof otpVerifySchema>;
export type TemplateForm = z.infer<typeof templateSchema>;
// src/hooks/useValidatedForm.ts
import { useForm, UseFormProps } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
interface UseValidatedFormProps<T extends z.ZodType> extends UseFormProps<z.infer<T>> {
schema: T;
}
export function useValidatedForm<T extends z.ZodType>({
schema,
...formProps
}: UseValidatedFormProps<T>) {
return useForm<z.infer<T>>({
resolver: zodResolver(schema),
mode: 'onBlur', // 在失去焦點時驗證
reValidateMode: 'onChange', // 在輸入時重新驗證
...formProps,
});
}
// src/components/forms/OtpSendForm.tsx
import { useState } from 'react';
import {
TextInput,
Button,
Select,
Stack,
Group,
Alert,
Paper,
Title,
} from '@mantine/core';
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
import { useValidatedForm } from '../../hooks/useValidatedForm';
import { useSendOtp } from '../../hooks/useOtp';
import { useTemplates } from '../../hooks/useTemplates';
import { otpSendSchema, type OtpSendForm } from '../../schemas/otpSchemas';
interface OtpSendFormProps {
onSuccess?: (data: { msgId: string; phone: string }) => void;
}
export function OtpSendForm({ onSuccess }: OtpSendFormProps) {
const [successData, setSuccessData] = useState<{ msgId: string; phone: string } | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
reset,
} = useValidatedForm({
schema: otpSendSchema,
defaultValues: {
phone: '',
templateId: null,
},
});
const { data: templates, isLoading: templatesLoading } = useTemplates();
const sendOtpMutation = useSendOtp();
const onSubmit = async (data: OtpSendForm) => {
try {
// 格式化手機號碼
const formattedPhone = data.phone.startsWith('+886')
? data.phone
: data.phone.replace(/^0/, '+886');
const result = await sendOtpMutation.mutateAsync({
phone: formattedPhone,
templateId: data.templateId || undefined,
});
setSuccessData({
msgId: result.msgId,
phone: formattedPhone,
});
onSuccess?.(result);
reset(); // 重置表單
} catch (error) {
// 錯誤已經由 React Query 的 onError 處理
console.error('Send OTP failed:', error);
}
};
// 監聽手機號碼輸入,提供即時格式提示
const phoneValue = watch('phone');
const getPhoneFormatHint = (phone: string) => {
if (!phone) return '請輸入手機號碼(例:0987654321 或 +886987654321)';
if (phone.startsWith('09') && phone.length === 10) {
return `將轉換為:+886${phone.slice(1)}`;
}
if (phone.startsWith('+886') && phone.length === 13) {
return '✓ 格式正確';
}
return '請輸入正確的台灣手機號碼';
};
if (successData) {
return (
<Paper p="xl" shadow="sm" radius="md">
<Stack>
<Alert
icon={<IconCheck size="1rem" />}
title="驗證碼已發送"
color="green"
variant="light"
>
<Stack gap="xs">
<div>訊息 ID: <strong>{successData.msgId}</strong></div>
<div>發送至: <strong>{successData.phone}</strong></div>
<div>請查看您的簡訊並輸入 6 位數驗證碼</div>
</Stack>
</Alert>
<Button
variant="outline"
onClick={() => setSuccessData(null)}
>
發送另一個驗證碼
</Button>
</Stack>
</Paper>
);
}
return (
<Paper p="xl" shadow="sm" radius="md">
<form onSubmit={handleSubmit(onSubmit)}>
<Stack>
<Title order={3}>發送 OTP 驗證碼</Title>
<TextInput
label="手機號碼"
placeholder="0987654321 或 +886987654321"
description={getPhoneFormatHint(phoneValue || '')}
error={errors.phone?.message}
{...register('phone')}
/>
<Select
label="簡訊模板"
placeholder="選擇模板(可選)"
description="未選擇將使用預設模板"
data={templates?.map(template => ({
value: template.id.toString(),
label: template.name,
disabled: !template.isActive,
})) || []}
loading={templatesLoading}
clearable
{...register('templateId', {
setValueAs: (value) => value ? parseInt(value, 10) : null,
})}
/>
{sendOtpMutation.error && (
<Alert
icon={<IconAlertCircle size="1rem" />}
title="發送失敗"
color="red"
variant="light"
>
{sendOtpMutation.error.message}
</Alert>
)}
<Group justify="flex-end">
<Button
type="submit"
loading={isSubmitting || sendOtpMutation.isPending}
disabled={Object.keys(errors).length > 0}
>
發送驗證碼
</Button>
</Group>
</Stack>
</form>
</Paper>
);
}
// src/components/forms/OtpVerifyForm.tsx
import { useState, useEffect } from 'react';
import {
TextInput,
Button,
Stack,
Group,
Alert,
Paper,
Title,
Text,
PinInput,
} from '@mantine/core';
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
import { useValidatedForm } from '../../hooks/useValidatedForm';
import { useVerifyOtp } from '../../hooks/useOtp';
import { otpVerifySchema, type OtpVerifyForm } from '../../schemas/otpSchemas';
interface OtpVerifyFormProps {
initialPhone?: string;
onSuccess?: (data: { success: boolean }) => void;
}
export function OtpVerifyForm({ initialPhone, onSuccess }: OtpVerifyFormProps) {
const [countdown, setCountdown] = useState(0);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue,
watch,
} = useValidatedForm({
schema: otpVerifySchema,
defaultValues: {
phone: initialPhone || '',
otp: '',
},
});
const verifyOtpMutation = useVerifyOtp();
// 倒數計時器(防止重複發送)
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
const onSubmit = async (data: OtpVerifyForm) => {
try {
// 格式化手機號碼
const formattedPhone = data.phone.startsWith('+886')
? data.phone
: data.phone.replace(/^0/, '+886');
const result = await verifyOtpMutation.mutateAsync({
phone: formattedPhone,
otp: data.otp,
});
if (result.success) {
onSuccess?.(result);
}
} catch (error) {
console.error('Verify OTP failed:', error);
}
};
const otpValue = watch('otp');
return (
<Paper p="xl" shadow="sm" radius="md">
<form onSubmit={handleSubmit(onSubmit)}>
<Stack>
<Title order={3}>驗證 OTP 驗證碼</Title>
<TextInput
label="手機號碼"
placeholder="0987654321 或 +886987654321"
error={errors.phone?.message}
{...register('phone')}
/>
<Stack gap="xs">
<Text size="sm" fw={500}>驗證碼</Text>
<PinInput
length={6}
type="number"
placeholder="○"
value={otpValue || ''}
onChange={(value) => setValue('otp', value)}
error={!!errors.otp}
/>
{errors.otp && (
<Text size="xs" c="red">
{errors.otp.message}
</Text>
)}
<Text size="xs" c="dimmed">
請輸入收到的 6 位數驗證碼
</Text>
</Stack>
{verifyOtpMutation.error && (
<Alert
icon={<IconAlertCircle size="1rem" />}
title="驗證失敗"
color="red"
variant="light"
>
{verifyOtpMutation.error.message}
</Alert>
)}
{verifyOtpMutation.data && !verifyOtpMutation.data.success && (
<Alert
icon={<IconAlertCircle size="1rem" />}
title="驗證碼錯誤"
color="yellow"
variant="light"
>
<Stack gap="xs">
<div>剩餘嘗試次數:{verifyOtpMutation.data.attemptsLeft || 0}</div>
{verifyOtpMutation.data.attemptsLeft === 0 && (
<div>帳號已被鎖定,請稍後再試</div>
)}
</Stack>
</Alert>
)}
<Group justify="space-between">
<Button
variant="subtle"
disabled={countdown > 0}
onClick={() => {
// 觸發重新發送 OTP 的邏輯
setCountdown(60);
}}
>
{countdown > 0 ? `重新發送 (${countdown}s)` : '重新發送驗證碼'}
</Button>
<Button
type="submit"
loading={isSubmitting || verifyOtpMutation.isPending}
disabled={Object.keys(errors).length > 0 || !otpValue || otpValue.length !== 6}
>
驗證
</Button>
</Group>
</Stack>
</form>
</Paper>
);
}
// src/components/forms/TemplateForm.tsx
import {
TextInput,
Textarea,
Button,
Stack,
Group,
Switch,
Paper,
Title,
Text,
Code,
} from '@mantine/core';
import { useValidatedForm } from '../../hooks/useValidatedForm';
import { useCreateTemplate, useUpdateTemplate } from '../../hooks/useTemplates';
import { templateSchema, type TemplateForm } from '../../schemas/otpSchemas';
interface TemplateFormProps {
template?: {
id: number;
name: string;
content: string;
isActive: boolean;
};
onSuccess?: () => void;
onCancel?: () => void;
}
export function TemplateForm({ template, onSuccess, onCancel }: TemplateFormProps) {
const isEditing = !!template;
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
} = useValidatedForm({
schema: templateSchema,
defaultValues: {
name: template?.name || '',
content: template?.content || '',
isActive: template?.isActive ?? true,
},
});
const createMutation = useCreateTemplate();
const updateMutation = useUpdateTemplate();
const onSubmit = async (data: TemplateForm) => {
try {
if (isEditing) {
await updateMutation.mutateAsync({ id: template.id, ...data });
} else {
await createMutation.mutateAsync(data);
}
onSuccess?.();
} catch (error) {
console.error('Save template failed:', error);
}
};
const contentValue = watch('content');
const getPreview = (content: string) => {
return content.replace('{code}', '123456');
};
return (
<Paper p="xl" shadow="sm" radius="md">
<form onSubmit={handleSubmit(onSubmit)}>
<Stack>
<Title order={3}>
{isEditing ? '編輯模板' : '新增模板'}
</Title>
<TextInput
label="模板名稱"
placeholder="例:登入驗證碼"
error={errors.name?.message}
{...register('name')}
/>
<Stack gap="xs">
<Textarea
label="模板內容"
placeholder="您的驗證碼:{code},請於 5 分鐘內完成驗證。"
description="使用 {code} 作為驗證碼的占位符"
rows={4}
error={errors.content?.message}
{...register('content')}
/>
{contentValue && contentValue.includes('{code}') && (
<div>
<Text size="sm" fw={500} mb={4}>預覽效果:</Text>
<Code block p="sm">
{getPreview(contentValue)}
</Code>
</div>
)}
</Stack>
<Switch
label="啟用模板"
description="停用的模板不會出現在發送選項中"
{...register('isActive')}
/>
<Group justify="flex-end">
{onCancel && (
<Button variant="outline" onClick={onCancel}>
取消
</Button>
)}
<Button
type="submit"
loading={isSubmitting || createMutation.isPending || updateMutation.isPending}
disabled={Object.keys(errors).length > 0}
>
{isEditing ? '更新' : '建立'}
</Button>
</Group>
</Stack>
</form>
</Paper>
);
}
// src/pages/OtpPage.tsx
import { useState } from 'react';
import { Container, Stack, Tabs } from '@mantine/core';
import { OtpSendForm } from '../components/forms/OtpSendForm';
import { OtpVerifyForm } from '../components/forms/OtpVerifyForm';
export function OtpPage() {
const [activeTab, setActiveTab] = useState<string | null>('send');
const [lastSentPhone, setLastSentPhone] = useState<string>('');
const handleSendSuccess = (data: { msgId: string; phone: string }) => {
setLastSentPhone(data.phone);
setActiveTab('verify');
};
const handleVerifySuccess = () => {
// 處理驗證成功的邏輯
console.log('OTP verified successfully!');
};
return (
<Container size="sm" py="xl">
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="send">發送驗證碼</Tabs.Tab>
<Tabs.Tab value="verify">驗證驗證碼</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="send" pt="xl">
<OtpSendForm onSuccess={handleSendSuccess} />
</Tabs.Panel>
<Tabs.Panel value="verify" pt="xl">
<OtpVerifyForm
initialPhone={lastSentPhone}
onSuccess={handleVerifySuccess}
/>
</Tabs.Panel>
</Tabs>
</Container>
);
}
// src/hooks/useAsyncValidation.ts
import { useState, useCallback } from 'react';
import { debounce } from 'lodash-es';
interface AsyncValidationOptions {
debounceMs?: number;
}
export function useAsyncValidation<T>(
validator: (value: T) => Promise<string | undefined>,
options: AsyncValidationOptions = {}
) {
const [isValidating, setIsValidating] = useState(false);
const [error, setError] = useState<string | undefined>();
const debouncedValidator = useCallback(
debounce(async (value: T) => {
setIsValidating(true);
try {
const result = await validator(value);
setError(result);
} catch (err) {
setError('驗證發生錯誤');
} finally {
setIsValidating(false);
}
}, options.debounceMs || 500),
[validator, options.debounceMs]
);
const validate = useCallback((value: T) => {
if (!value) {
setError(undefined);
setIsValidating(false);
return;
}
debouncedValidator(value);
}, [debouncedValidator]);
return { validate, isValidating, error };
}
// 使用範例:檢查手機號碼是否已被使用
export function usePhoneValidation() {
return useAsyncValidation(async (phone: string) => {
const response = await fetch(`/api/phone/check?phone=${encodeURIComponent(phone)}`);
const result = await response.json();
if (!response.ok) {
throw new Error(result.message);
}
if (result.exists) {
return '此手機號碼已被註冊';
}
return undefined;
});
}
// src/hooks/useFormPersistence.ts
import { useEffect } from 'react';
import { UseFormReturn } from 'react-hook-form';
export function useFormPersistence<T>(
form: UseFormReturn<T>,
key: string,
exclude: (keyof T)[] = []
) {
const { watch, reset } = form;
// 從 localStorage 恢復表單狀態
useEffect(() => {
const savedData = localStorage.getItem(`form:${key}`);
if (savedData) {
try {
const parsed = JSON.parse(savedData);
reset(parsed);
} catch (error) {
console.warn('Failed to restore form data:', error);
}
}
}, [reset, key]);
// 監聽表單變化並保存
useEffect(() => {
const subscription = watch((data) => {
const dataToSave = { ...data };
// 排除敏感欄位
exclude.forEach(field => {
delete dataToSave[field];
});
localStorage.setItem(`form:${key}`, JSON.stringify(dataToSave));
});
return () => subscription.unsubscribe();
}, [watch, key, exclude]);
// 清除保存的資料
const clearSavedData = () => {
localStorage.removeItem(`form:${key}`);
};
return { clearSavedData };
}
通過整合 React Hook Form 和 Zod,我們建立了一個強大的表單處理系統:
✅ 型別安全驗證:使用 Zod 確保執行時型別安全
✅ 高效能表單:最小化重新渲染,提升使用者體驗
✅ 即時驗證:提供即時錯誤回饋和格式提示
✅ 可重用組件:建立了可複用的表單組件庫
✅ 無障礙支援:正確的 ARIA 標籤和鍵盤導航
✅ 進階功能:異步驗證、表單持久化等企業級功能