폼 (react-hook-form + zod)
규칙
- 스키마 우선:
zod 스키마를 정의하고 z.infer로 타입을 파생시킵니다. 클라이언트와 서버가 공유하는 단일 진실 공급원을 만듭니다.
@hookform/resolvers/zod (zodResolver)를 사용합니다. UX를 위해 mode를 onTouched로 설정하고, 액션에서 서버 측에서도 동일한 스키마로 재검증합니다.
- 폼 타입을 지정합니다:
useForm<FormValues>. 필드 값에 any를 사용하지 않습니다.
- 접근성 오류:
aria-invalid, aria-describedby, role="alert"를 메시지에 사용합니다. <label htmlFor>를 연결합니다.
- Server Action (
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>
);
}
체크리스트