
React Hook Form + Zod: 타입 안전한 폼 검증
폼 검증 로직을 직접 작성하다가 엣지 케이스 지옥에 빠졌다. React Hook Form과 Zod 조합이 폼 개발의 최적해였다.

폼 검증 로직을 직접 작성하다가 엣지 케이스 지옥에 빠졌다. React Hook Form과 Zod 조합이 폼 개발의 최적해였다.
any를 쓰면 타입스크립트를 쓰는 의미가 없습니다. 제네릭(Generics)을 통해 유연하면서도 타입 안전성(Type Safety)을 모두 챙기는 방법을 정리합니다. infer, keyof 등 고급 기법 포함.

부모에서 전달한 props가 undefined로 나와서 앱이 크래시되는 문제 해결

대용량 폼에서 입력 지연을 해결하는 디바운싱과 최적화 기법

TypeScript/JavaScript에서 절대 경로 import 설정이 안 될 때의 원인을 '지도와 택시 기사' 비유로 설명합니다. CJS vs ESM 역사적 배경과 모노레포 설정, 팀 컨벤션까지 총정리.

회원가입 폼을 만들었다. 이메일, 비밀번호, 비밀번호 확인. 간단해 보였다. 그런데 요구사항이 하나씩 추가되기 시작했다.
"비밀번호는 8자 이상, 영문+숫자 조합이어야 해요" "이메일 중복 확인도 필요해요" "비밀번호 확인이 일치하지 않으면 실시간으로 알려주세요" "제출 중에는 버튼을 비활성화해야 해요"
useState로 각 필드를 관리하고, onChange마다 검증 로직을 실행하고, 에러 메시지를 또 따로 관리했다. 코드가 200줄을 넘어가는데도 여전히 버그가 나왔다. 사용자가 빠르게 타이핑하면 검증이 꼬이고, 제출 버튼을 연타하면 중복 요청이 발생했다.
이건 마치 수동 변속기 차를 몰면서 엔진 RPM, 클러치 타이밍, 기어 위치를 동시에 신경 써야 하는 것과 같았다. 자동 변속기만 있다면 운전에만 집중할 수 있을 텐데.
그때 React Hook Form과 Zod를 만났다. 이 조합은 내게 자동 변속기를 쥐여줬다.
React Hook Form(RHF)의 핵심 통찰은 비제어 컴포넌트(uncontrolled components) 였다. 일반적인 React 폼은 모든 입력값을 state로 관리한다. 사용자가 타이핑할 때마다 리렌더링이 발생한다. 10개 필드가 있으면? 타이핑 한 번에 컴포넌트 전체가 10번 리렌더링될 수 있다.
RHF는 다르게 접근한다. ref를 사용해 DOM 자체에서 값을 읽는다. 리렌더링은 정말 필요할 때만 발생한다. 검증이 실패했을 때, 제출할 때. 그게 전부다.
import { useForm } from 'react-hook-form';
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log(data); // { email: "...", password: "..." }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
register가 하는 일은 간단하다. ref를 연결하고, name을 설정한다. 그게 전부다. useState도 없고, onChange 핸들러도 없다. 하지만 handleSubmit을 호출하면 모든 값이 깔끔하게 객체로 모인다.
이건 마치 물류 창고에서 모든 상자를 실시간으로 추적하는 대신, 출고 시점에만 검수하는 것과 같다. 훨씬 효율적이다.
검증 로직을 직접 작성하면 이런 코드가 나온다:
if (!email.includes('@')) {
setErrors({ ...errors, email: 'Invalid email' });
}
if (password.length < 8) {
setErrors({ ...errors, password: 'Password too short' });
}
문제는 세 가지다:
Zod는 이 모든 걸 한 번에 해결한다. 스키마가 진실의 원천(single source of truth) 이 된다.
import { z } from 'zod';
const signupSchema = z.object({
email: z.string().email('Valid email required'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, 'Must contain letters and numbers'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
// TypeScript 타입 자동 생성
type SignupFormData = z.infer<typeof signupSchema>;
// { email: string; password: string; confirmPassword: string; }
스키마를 보면 검증 규칙이 한눈에 들어온다. 그리고 z.infer로 TypeScript 타입이 자동으로 생성된다. 타입과 검증 로직이 항상 동기화된다. 스키마를 수정하면 타입도 자동으로 바뀐다.
이건 마치 건축 설계도가 시공 매뉴얼이자 안전 검사 체크리스트 역할을 동시에 하는 것과 같다. 하나의 문서로 모든 게 해결된다.
React Hook Form과 Zod를 연결하는 건 zodResolver 한 줄이면 된다.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z.object({
email: z.string().email('Valid email required'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type SignupFormData = z.infer<typeof signupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onBlur', // 포커스 벗어날 때 검증
});
const onSubmit = async (data: SignupFormData) => {
// data는 이미 검증된 타입 안전한 객체
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} placeholder="Email" />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<input {...register('password')} type="password" placeholder="Password" />
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<div>
<input {...register('confirmPassword')} type="password" placeholder="Confirm Password" />
{errors.confirmPassword && <span className="error">{errors.confirmPassword.message}</span>}
</div>
<button type="submit" disabled={isSubmitting || !isDirty}>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</form>
);
}
resolver: zodResolver(signupSchema) 한 줄로:
formState는 폼의 상태를 제공한다:
isDirty: 사용자가 뭔가 수정했는가?isSubmitting: 제출 중인가?isValid: 현재 폼이 유효한가?이 정보들로 UX를 정교하게 제어할 수 있다. 제출 중에는 버튼을 비활성화하고, 수정사항이 없으면 저장 버튼을 숨기는 식으로.
실제로는 단순한 폼이 없다. "개인 사용자는 이름만, 기업 사용자는 사업자등록번호도 필수"같은 조건부 검증이 필요하다.
const profileSchema = z.object({
userType: z.enum(['individual', 'business']),
name: z.string().min(2),
businessNumber: z.string().optional(),
}).refine(
(data) => {
// 기업 사용자는 사업자등록번호 필수
if (data.userType === 'business') {
return data.businessNumber && data.businessNumber.length === 10;
}
return true;
},
{
message: 'Business number required for business accounts',
path: ['businessNumber'],
}
);
.refine()은 커스텀 검증 로직을 추가하는 방법이다. 여러 필드를 조합한 복잡한 조건도 표현할 수 있다. path로 에러가 어느 필드에 붙을지 지정한다.
이건 마치 퍼즐 조각이 맞는지 확인하는데, 주변 조각의 모양도 함께 고려하는 것과 같다. 전체 맥락을 본다.
"친구 초대하기" 기능을 만든다고 해보자. 사용자가 + 버튼을 누르면 이메일 입력 필드가 추가된다. 몇 개가 추가될지 모른다.
import { useForm, useFieldArray } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const inviteSchema = z.object({
friends: z.array(
z.object({
email: z.string().email('Valid email required'),
name: z.string().min(1, 'Name required'),
})
).min(1, 'Add at least one friend'),
});
type InviteFormData = z.infer<typeof inviteSchema>;
function InviteFriendsForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: {
friends: [{ email: '', name: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'friends',
});
const onSubmit = (data: InviteFormData) => {
console.log(data.friends); // [{ email: "...", name: "..." }, ...]
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`friends.${index}.email`)}
placeholder="Email"
/>
{errors.friends?.[index]?.email && (
<span>{errors.friends[index].email.message}</span>
)}
<input
{...register(`friends.${index}.name`)}
placeholder="Name"
/>
{errors.friends?.[index]?.name && (
<span>{errors.friends[index].name.message}</span>
)}
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ email: '', name: '' })}>
Add Friend
</button>
<button type="submit">Send Invites</button>
</form>
);
}
useFieldArray는 배열 형태의 폼 데이터를 관리한다. append로 추가하고, remove로 삭제한다. Zod 스키마는 z.array()로 배열 검증을 정의한다. 각 항목이 스키마를 만족하는지 자동으로 확인된다.
에러도 배열 구조로 나온다. errors.friends?.[index]?.email로 접근하면 해당 필드의 에러 메시지를 가져올 수 있다.
이건 마치 컨베이어 벨트에 상자를 계속 추가하는데, 각 상자마다 품질 검사가 자동으로 이루어지는 것과 같다. 개수가 변해도 시스템은 그대로다.
Zod의 진짜 강점은 클라이언트와 서버에서 같은 스키마를 사용할 수 있다는 점이다. Monorepo나 풀스택 프레임워크(Next.js, Remix)를 쓴다면 이게 게임 체인저다.
// shared/schemas/signup.ts (클라이언트와 서버 모두에서 import)
import { z } from 'zod';
export const signupSchema = z.object({
email: z.string().email('Valid email required'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
export type SignupFormData = z.infer<typeof signupSchema>;
클라이언트에서:
// components/SignupForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema } from '@/shared/schemas/signup';
function SignupForm() {
const { register, handleSubmit } = useForm({
resolver: zodResolver(signupSchema),
});
// ...
}
서버에서 (Next.js API Route 예시):
// app/api/signup/route.ts
import { signupSchema } from '@/shared/schemas/signup';
export async function POST(request: Request) {
const body = await request.json();
// 서버에서도 같은 스키마로 검증
const result = signupSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten() },
{ status: 400 }
);
}
const { email, password } = result.data; // 타입 안전!
// 회원가입 로직...
}
클라이언트 검증을 우회하려는 악의적인 요청도 서버에서 같은 스키마로 막는다. 검증 규칙이 바뀌면 한 곳만 수정하면 된다. 타입도 자동으로 동기화된다.
이건 마치 건물의 정문과 후문에 같은 출입 규칙을 적용하는 것과 같다. 하나의 보안 정책으로 모든 입구를 관리한다.
긴 폼은 여러 단계로 나누는 게 UX에 좋다. "기본 정보 → 상세 정보 → 확인" 같은 구조.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState } from 'react';
// 단계별 스키마
const step1Schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const step2Schema = z.object({
name: z.string().min(2),
phone: z.string().regex(/^\d{10,11}$/),
});
const step3Schema = z.object({
agreeToTerms: z.boolean().refine((val) => val === true, {
message: 'You must agree to terms',
}),
});
// 전체 스키마
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type FormData = z.infer<typeof fullSchema>;
function MultiStepForm() {
const [step, setStep] = useState(1);
const {
register,
handleSubmit,
trigger,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(fullSchema),
mode: 'onTouched',
});
const nextStep = async () => {
let isValid = false;
if (step === 1) {
isValid = await trigger(['email', 'password']);
} else if (step === 2) {
isValid = await trigger(['name', 'phone']);
}
if (isValid) {
setStep(step + 1);
}
};
const onSubmit = (data: FormData) => {
console.log('Final data:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{step === 1 && (
<div>
<h2>Step 1: Account</h2>
<input {...register('email')} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="button" onClick={nextStep}>Next</button>
</div>
)}
{step === 2 && (
<div>
<h2>Step 2: Personal Info</h2>
<input {...register('name')} placeholder="Name" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('phone')} placeholder="Phone" />
{errors.phone && <span>{errors.phone.message}</span>}
<button type="button" onClick={() => setStep(1)}>Back</button>
<button type="button" onClick={nextStep}>Next</button>
</div>
)}
{step === 3 && (
<div>
<h2>Step 3: Confirm</h2>
<label>
<input type="checkbox" {...register('agreeToTerms')} />
I agree to terms and conditions
</label>
{errors.agreeToTerms && <span>{errors.agreeToTerms.message}</span>}
<button type="button" onClick={() => setStep(2)}>Back</button>
<button type="submit">Submit</button>
</div>
)}
</form>
);
}
핵심은 trigger()다. 특정 필드들만 검증할 수 있다. 1단계에서 "Next" 버튼을 누르면 이메일과 비밀번호만 검증한다. 통과하면 2단계로 넘어간다.
전체 스키마는 .merge()로 합친다. 최종 제출 시에는 모든 필드가 검증된다.
이건 마치 게임에서 스테이지를 클리어하는 것과 같다. 각 스테이지마다 목표가 있고, 클리어해야 다음으로 넘어간다. 하지만 최종 보스는 모든 스킬을 다 써야 이긴다.
에러 메시지를 어떻게 보여줄지도 중요하다. 실제로 자주 쓰는 패턴을 정리했다.
인라인 에러 (필드 바로 밑):
<div className="field">
<input {...register('email')} />
{errors.email && (
<span className="error-message">{errors.email.message}</span>
)}
</div>
폼 상단에 전체 에러 요약:
{Object.keys(errors).length > 0 && (
<div className="error-summary">
<h4>Please fix the following errors:</h4>
<ul>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>{error.message}</li>
))}
</ul>
</div>
)}
커스텀 에러 컴포넌트로 재사용:
function FormField({
label,
name,
register,
error,
...inputProps
}: {
label: string;
name: string;
register: any;
error?: { message?: string };
[key: string]: any;
}) {
return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<input id={name} {...register(name)} {...inputProps} />
{error && <span className="error">{error.message}</span>}
</div>
);
}
// 사용
<FormField
label="Email"
name="email"
register={register}
error={errors.email}
type="email"
/>
에러 메시지가 일관된 스타일로 표시되면 사용자 경험이 훨씬 좋아진다. 어디에 문제가 있는지 명확하게 보인다.
React Hook Form과 Zod 조합은 단순히 라이브러리 두 개를 섞은 게 아니다. 폼 개발의 패러다임을 바꿨다.
이전: 상태 관리, 검증 로직, 타입 정의를 따로 작성 → 동기화 지옥 이후: 스키마 하나로 검증+타입+에러 메시지 통합 → 단일 진실의 원천
성능: 비제어 컴포넌트로 불필요한 리렌더링 제거
타입 안전성: z.infer로 TypeScript 타입 자동 생성
재사용성: 클라이언트와 서버에서 같은 스키마 공유
확장성: 복잡한 조건부 검증, 동적 필드, 멀티스텝 모두 지원
폼 하나 만드는데 200줄 짜던 시절은 끝났다. 이제는 스키마 10줄, 컴포넌트 30줄이면 충분하다. 나머지 시간은 진짜 비즈니스 로직에 쓸 수 있다.
수동 변속기에서 자동 변속기로 바꾼 기분이다. 운전의 본질에 집중할 수 있게 됐다. React Hook Form + Zod는 이제 내 모든 프로젝트의 표준이다.
I built a signup form. Email, password, password confirmation. Seemed simple. Then requirements started piling up.
"Password must be at least 8 characters with letters and numbers" "We need email duplicate checking" "Show real-time validation when passwords don't match" "Disable button while submitting"
I managed each field with useState, ran validation logic on every onChange, and managed error messages separately. The code exceeded 200 lines but bugs kept appearing. Fast typing broke validation, button mashing triggered duplicate requests.
It felt like driving a manual transmission car while simultaneously monitoring engine RPM, clutch timing, and gear position. If only I had an automatic transmission, I could focus on driving.
Then I discovered React Hook Form and Zod. This combination handed me an automatic transmission.
React Hook Form's (RHF) core insight is uncontrolled components. Typical React forms manage all input values in state. Every keystroke triggers a re-render. With 10 fields? A single keystroke could re-render the entire component 10 times.
RHF takes a different approach. It uses refs to read values directly from the DOM. Re-renders only happen when truly necessary. When validation fails. When submitting. That's it.
import { useForm } from 'react-hook-form';
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log(data); // { email: "...", password: "..." }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
What register does is simple. It connects a ref and sets a name. That's all. No useState, no onChange handlers. But when you call handleSubmit, all values are neatly collected into an object.
It's like a warehouse that doesn't track every box in real-time, but only inspects them at shipping time. Much more efficient.
Writing validation logic manually produces code like this:
if (!email.includes('@')) {
setErrors({ ...errors, email: 'Invalid email' });
}
if (password.length < 8) {
setErrors({ ...errors, password: 'Password too short' });
}
Three problems emerge:
Zod solves all of this at once. The schema becomes the single source of truth.
import { z } from 'zod';
const signupSchema = z.object({
email: z.string().email('Valid email required'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, 'Must contain letters and numbers'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
// Automatic TypeScript type generation
type SignupFormData = z.infer<typeof signupSchema>;
// { email: string; password: string; confirmPassword: string; }
The schema shows validation rules at a glance. And z.infer automatically generates TypeScript types. Types and validation logic stay synchronized. Modify the schema and types update automatically.
It's like having a blueprint that serves simultaneously as construction manual and safety inspection checklist. One document solves everything.
Connecting React Hook Form and Zod takes one line with zodResolver.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z.object({
email: z.string().email('Valid email required'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type SignupFormData = z.infer<typeof signupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onBlur', // Validate on blur
});
const onSubmit = async (data: SignupFormData) => {
// data is already validated and type-safe
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} placeholder="Email" />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<input {...register('password')} type="password" placeholder="Password" />
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<div>
<input {...register('confirmPassword')} type="password" placeholder="Confirm Password" />
{errors.confirmPassword && <span className="error">{errors.confirmPassword.message}</span>}
</div>
<button type="submit" disabled={isSubmitting || !isDirty}>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</form>
);
}
With resolver: zodResolver(signupSchema):
formState provides form status:
isDirty: Has the user modified anything?isSubmitting: Currently submitting?isValid: Is the form currently valid?This information enables precise UX control. Disable button while submitting, hide save button when no changes exist.
Real-world forms are never simple. You need conditional validation like "individual users need name only, business users also need business registration number."
const profileSchema = z.object({
userType: z.enum(['individual', 'business']),
name: z.string().min(2),
businessNumber: z.string().optional(),
}).refine(
(data) => {
// Business users require business number
if (data.userType === 'business') {
return data.businessNumber && data.businessNumber.length === 10;
}
return true;
},
{
message: 'Business number required for business accounts',
path: ['businessNumber'],
}
);
.refine() adds custom validation logic. Complex conditions combining multiple fields can be expressed. path specifies which field gets the error.
It's like checking if puzzle pieces fit while also considering surrounding piece shapes. You see the full context.
Imagine building an "invite friends" feature. Users click + button to add email input fields. You don't know how many will be added.
import { useForm, useFieldArray } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const inviteSchema = z.object({
friends: z.array(
z.object({
email: z.string().email('Valid email required'),
name: z.string().min(1, 'Name required'),
})
).min(1, 'Add at least one friend'),
});
type InviteFormData = z.infer<typeof inviteSchema>;
function InviteFriendsForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: {
friends: [{ email: '', name: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'friends',
});
const onSubmit = (data: InviteFormData) => {
console.log(data.friends); // [{ email: "...", name: "..." }, ...]
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`friends.${index}.email`)}
placeholder="Email"
/>
{errors.friends?.[index]?.email && (
<span>{errors.friends[index].email.message}</span>
)}
<input
{...register(`friends.${index}.name`)}
placeholder="Name"
/>
{errors.friends?.[index]?.name && (
<span>{errors.friends[index].name.message}</span>
)}
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ email: '', name: '' })}>
Add Friend
</button>
<button type="submit">Send Invites</button>
</form>
);
}
useFieldArray manages array-shaped form data. Add with append, delete with remove. The Zod schema defines array validation with z.array(). Each item is automatically checked against the schema.
Errors also follow array structure. Access with errors.friends?.[index]?.email to get that field's error message.
It's like continuously adding boxes to a conveyor belt where each box gets automatic quality inspection. The count changes but the system stays the same.
Zod's real strength is using the same schema on client and server. If you're using a monorepo or full-stack framework (Next.js, Remix), this becomes a game changer.
// shared/schemas/signup.ts (imported by both client and server)
import { z } from 'zod';
export const signupSchema = z.object({
email: z.string().email('Valid email required'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
export type SignupFormData = z.infer<typeof signupSchema>;
On the client:
// components/SignupForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema } from '@/shared/schemas/signup';
function SignupForm() {
const { register, handleSubmit } = useForm({
resolver: zodResolver(signupSchema),
});
// ...
}
On the server (Next.js API Route example):
// app/api/signup/route.ts
import { signupSchema } from '@/shared/schemas/signup';
export async function POST(request: Request) {
const body = await request.json();
// Validate with same schema on server
const result = signupSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten() },
{ status: 400 }
);
}
const { email, password } = result.data; // Type-safe!
// Signup logic...
}
Malicious requests bypassing client validation get blocked by the same schema on the server. When validation rules change, modify one place. Types synchronize automatically.
It's like applying the same entry rules to a building's front and back doors. One security policy manages all entrances.
Long forms benefit from splitting into multiple steps. Structure like "Basic Info → Detailed Info → Confirmation."
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState } from 'react';
// Step-specific schemas
const step1Schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const step2Schema = z.object({
name: z.string().min(2),
phone: z.string().regex(/^\d{10,11}$/),
});
const step3Schema = z.object({
agreeToTerms: z.boolean().refine((val) => val === true, {
message: 'You must agree to terms',
}),
});
// Full schema
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type FormData = z.infer<typeof fullSchema>;
function MultiStepForm() {
const [step, setStep] = useState(1);
const {
register,
handleSubmit,
trigger,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(fullSchema),
mode: 'onTouched',
});
const nextStep = async () => {
let isValid = false;
if (step === 1) {
isValid = await trigger(['email', 'password']);
} else if (step === 2) {
isValid = await trigger(['name', 'phone']);
}
if (isValid) {
setStep(step + 1);
}
};
const onSubmit = (data: FormData) => {
console.log('Final data:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{step === 1 && (
<div>
<h2>Step 1: Account</h2>
<input {...register('email')} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="button" onClick={nextStep}>Next</button>
</div>
)}
{step === 2 && (
<div>
<h2>Step 2: Personal Info</h2>
<input {...register('name')} placeholder="Name" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('phone')} placeholder="Phone" />
{errors.phone && <span>{errors.phone.message}</span>}
<button type="button" onClick={() => setStep(1)}>Back</button>
<button type="button" onClick={nextStep}>Next</button>
</div>
)}
{step === 3 && (
<div>
<h2>Step 3: Confirm</h2>
<label>
<input type="checkbox" {...register('agreeToTerms')} />
I agree to terms and conditions
</label>
{errors.agreeToTerms && <span>{errors.agreeToTerms.message}</span>}
<button type="button" onClick={() => setStep(2)}>Back</button>
<button type="submit">Submit</button>
</div>
)}
</form>
);
}
The key is trigger(). You can validate only specific fields. When clicking "Next" in step 1, only email and password get validated. Pass and proceed to step 2.
Merge the full schema with .merge(). Final submission validates all fields.
It's like clearing stages in a game. Each stage has objectives, and you must clear to proceed. But the final boss requires using all skills.
How you display error messages matters. Here are common real-world patterns.
Inline errors (directly below field):
<div className="field">
<input {...register('email')} />
{errors.email && (
<span className="error-message">{errors.email.message}</span>
)}
</div>
Error summary at top of form:
{Object.keys(errors).length > 0 && (
<div className="error-summary">
<h4>Please fix the following errors:</h4>
<ul>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>{error.message}</li>
))}
</ul>
</div>
)}
Reusable custom error component:
function FormField({
label,
name,
register,
error,
...inputProps
}: {
label: string;
name: string;
register: any;
error?: { message?: string };
[key: string]: any;
}) {
return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<input id={name} {...register(name)} {...inputProps} />
{error && <span className="error">{error.message}</span>}
</div>
);
}
// Usage
<FormField
label="Email"
name="email"
register={register}
error={errors.email}
type="email"
/>
Consistent error message styling greatly improves user experience. Problems become clearly visible.