React 폼은 "비제어"로 두고, 검증은 Zod 스키마 하나로
input마다 useState를 걸면 한 글자 칠 때마다 폼 전체가 리렌더링된다. React Hook Form은 반대로 간다 — ref로 비제어로 두고, zodResolver로 검증과 타입을 스키마 한 곳에서 뽑는다. 순수 HTML form의 감각과 이어진다.
React로 폼을 처음 만들면 보통 input마다 useState를 걸어 제어 컴포넌트로 만들어요.
그런데 이러면 한 글자 칠 때마다 폼 전체가 리렌더링돼요.
필드가 20개면 타이핑이 버벅이기 시작하고요.
React Hook Form(RHF)은 반대로 가요.
register()가 input에 ref를 꽂아 비제어(uncontrolled)로 두고, 값은 React state가 아니라 DOM에서 직접 읽어요.
타이핑해도 state 업데이트가 없으니 리렌더링도 없죠.
퍼블리싱 시절 순수 HTML <form>을 submit하면 브라우저가 알아서 값을 모아 보내주던 그 감각에 가까워요.
React는 제출 시점과 에러 표시에만 개입하고요.
여기에 zodResolver를 물리면 검증 규칙과 타입이 스키마 한 곳에서 나와요.
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 검증 규칙 = 타입의 단일 출처(single source of truth)
const schema = z.object({
email: z.string().email('이메일 형식이 아니에요'),
age: z.coerce.number().min(19, '19세 이상만 가입할 수 있어요'),
});
type FormValues = z.infer<typeof schema>; // { email: string; age: number }
export function JoinForm() {
const {
register, // input에 ref+name+onBlur를 꽂아주는 함수 (비제어)
handleSubmit, // 검증 통과했을 때만 내 onSubmit을 불러줌
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: '', age: 19 }, // 비제어라 초기값은 여기서
});
return (
<form onSubmit={handleSubmit(async (data) => { /* data는 이미 FormValues */ })}>
<label htmlFor="email">이메일</label>
<input id="email" {...register('email')} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<label htmlFor="age">나이</label>
<input id="age" type="number" {...register('age')} />
{errors.age && <p role="alert">{errors.age.message}</p>}
<button disabled={isSubmitting}>가입</button>
</form>
);
}
포인트 하나.
<input type="number">도 DOM 값은 문자열이라 z.number()만 쓰면 검증에서 튕겨요.
그래서 z.coerce.number()로 변환을 스키마에 맡겨요.
“Zod로 검증하니 안전하고 완성”이라고 넘기기 쉬운데, 자주 빠지는 게 있어요.
에러 피드백을 빼먹기 쉬워요.
formState.errors를 안 쓰면 검증에 걸렸을 때 handleSubmit이 조용히 제출만 막아요.
사용자 입장에선 “가입 버튼이 안 눌리는 고장난 폼”이 되죠.
필드 아래에 errors.email?.message를 role="alert"로 띄워야 해요.
서버 검증도 빼먹기 쉽고요.
클라이언트 검증은 UX용 편의일 뿐, /api/signup은 curl이나 Postman으로 얼마든지 호출돼요.
같은 schema를 서버 라우트에서 한 번 더 schema.parse(await req.json())로 통과시켜야 실제 방어가 돼요.
(경계에서 검증이 그대로 적용되는 거죠.)
그리고 접근성.
<label htmlFor>·id 연결, type="email", autoComplete 같은 속성 — 퍼블리싱에서 늘 챙기던 건데 로직에 신경 쓰다 자주 놓쳐요.
isSubmitting으로 버튼을 안 막으면 더블 클릭에 요청이 두 번 나가고요.
ShadCN의 Form 컴포넌트가 바로 RHF + zodResolver를 감싼 얇은 껍데기라, 이 구조를 모르면 shadcn 폼을 커스터마이즈하는 순간 막혀요.
같은 Zod 스키마를 클라이언트 폼과 서버 라우트에서 함께 쓰면 검증 규칙을 한 벌로 유지할 수 있고요.
폼 값은 굳이 state로 붙잡지 말고 DOM에 맡기고, 검증과 타입은 스키마 하나에서 뽑는 것.
리렌더링도 줄고, 규칙도 한 곳에 모여요.
댓글