表單 (react-hook-form + zod)
規則
- Schema 優先:定義
zod schema,透過 z.infer 推導型別。由客戶端與伺服器共享的單一事實來源。
- 使用
@hookform/resolvers/zod (zodResolver)。模式設為 onTouched 以獲得較佳 UX;在 action 中於伺服器端再次驗證。
- 為表單加上型別:
useForm<FormValues>。不使用 any 作為欄位值。
- 無障礙錯誤提示:在訊息上使用
aria-invalid、aria-describedby、role="alert"。將 <label htmlFor> 與對應輸入關聯。
- 透過伺服器動作提交(使用
formAction 或呼叫伺服器函數的 action)。顯示 pending 狀態及伺服器錯誤狀態。
模式
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const Schema = z.object({
email: z.string().email('請輸入有效的電子郵件'),
password: z.string().min(8, '至少 8 個字元'),
});
type Values = z.infer<typeof Schema>;
export function LoginForm({ onSubmit }: { onSubmit: (v: Values) => Promise<{ ok: boolean; error?: string }> }) {
const { register, handleSubmit, formState: { errors, isSubmitting }, setError } = useForm<Values>({
resolver: zodResolver(Schema),
mode: 'onTouched',
});
return (
<form onSubmit={handleSubmit(async (v) => {
const r = await onSubmit(v);
if (!r.ok) setError('root', { message: r.error ?? '失敗' });
})}>
<label htmlFor="email">電子郵件</label>
<input id="email" type="email" autoComplete="email"
aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-err' : undefined}
{...register('email')} />
{errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}
<label htmlFor="password">密碼</label>
<input id="password" type="password" autoComplete="current-password" {...register('password')} />
{errors.password && <p role="alert">{errors.password.message}</p>}
{errors.root && <p role="alert">{errors.root.message}</p>}
<button disabled={isSubmitting}>{isSubmitting ? '登入中…' : '登入'}</button>
</form>
);
}
檢查清單