Forms — react-hook-form and zod
Forms — react-hook-form and zod
Forms are the part you meet most often in any app.
1. Controlled vs Uncontrolled
React inputs follow one of two models.
Controlled:
const [v, setV] = useState("")
<input value={v} onChange={(e) => setV(e.target.value)} />
React state is the source of truth. A re-render fires on every keystroke.
Uncontrolled:
const ref = useRef<HTMLInputElement>(null)
<input ref={ref} defaultValue="" />
The DOM is the source of truth. No re-render on each keystroke. In large forms, the controlled re-render cost accumulates. Libraries grew up to fill this place.
2. react-hook-form
react-hook-form is a library Bill Luo created in 2019. License MIT.
The core idea is to default to uncontrolled and let the library manage form state through refs. Re-renders do not fire on every input; they happen only on validation timing or per field.
import { useForm } from "react-hook-form"
function Login() {
const { register, handleSubmit, formState: { errors } } = useForm<{email: string; password: string}>()
const submit = (v: any) => console.log(v)
return (
<form onSubmit={handleSubmit(submit)}>
<input {...register("email", { required: true })} />
{errors.email && <p>이메일 필수</p>}
<input type="password" {...register("password", { minLength: 8 })} />
<button>로그인</button>
</form>
)
}
Alternatives:
- Formik (2018) — once the largest library. Controlled model. Maintenance has slowed currently.
- TanStack Form (2024) — headless, framework-compatible.
- conform (2023) — emphasizes standard form behavior (progressive enhancement).
3. Schema validation libraries
| Library | First release | Author | Notes |
|---|---|---|---|
| joi | 2012 | hapi team | Node backend camp. |
| yup | 2015 | jquense | Often used with Formik. |
| zod | 2020 | Colin McDonnell | TS-first. Most widely adopted. |
| valibot | 2023 | Fabian Hiller | Tree-shaking friendly. Per-function imports. |
| ArkType | 2023 | David Blass | Use TS expressions directly as a parser. |
| Effect Schema | 2023 | Effect | Effect ecosystem. |
The core of zod:
import { z } from "zod"
const Login = z.object({
email: z.string().email(),
password: z.string().min(8),
remember: z.boolean().default(false),
})
type Login = z.infer<typeof Login> // static type auto-inferred
const r = Login.safeParse(data)
if (!r.success) console.log(r.error.format())
else console.log(r.data)
The fact that the same schema yields both static type and runtime validation simultaneously is widely cited.
4. react-hook-form + zod
@hookform/resolvers connects the two libraries.
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자 이상"),
})
function Login() {
const { register, handleSubmit, formState: { errors } } =
useForm<z.infer<typeof Schema>>({ resolver: zodResolver(Schema) })
return (
<form onSubmit={handleSubmit(v => console.log(v))}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button>로그인</button>
</form>
)
}
5. Boundary validation
Types live only at compile time. Values that come from the outside (HTTP responses, form submissions, environment variables, JSON.parse results) must be validated at runtime. This place is called the boundary.
Four boundaries that often appear:
HTTP response:
const Resp = z.object({ id: z.string(), title: z.string() })
const r = await fetch("/api/post/1")
const data = Resp.parse(await r.json())
Form submission — react-hook-form + zodResolver fills this place. But validating only on the client can be bypassed. Validate again on the server with the same schema.
Environment variables:
const Env = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
NODE_ENV: z.enum(["development", "test", "production"]),
})
export const env = Env.parse(process.env)
JSON.parse, localStorage — the schema can change between save time and load time. Validate the schema on load and on failure either migrate or reset.
6. Standard Schema
Standard Schema (2024) is a common interface zod, valibot, ArkType, Effect Schema, and others agreed on. It lets form tools accept any schema without per-library adapters.
7. Combining with Server Actions
Small tools that pair 19's Server Actions with zod grew up.
next-safe-action— validates Server Action inputs with zod.zsa— similar place.
The principle that forms must be validated on both client and server is the starting point of these tools.
8. Large forms + async validation
const SignUp = z.object({
email: z.string().email(),
password: z.string().min(8),
confirm: z.string(),
age: z.coerce.number().int().min(14, "14세 이상만 가입"),
}).refine((d) => d.password === d.confirm, {
message: "비밀번호가 일치하지 않습니다",
path: ["confirm"],
})
Async validation (email duplication):
const SignUp = z.object({
email: z.string().email().refine(async (v) => {
const r = await fetch(`/api/check?email=${encodeURIComponent(v)}`)
return r.ok
}, "이미 사용 중인 이메일"),
})
9. Reusing the same schema in Server Action
// shared/schemas.ts
export const SignUp = z.object({...})
// app/sign-up/actions.ts
"use server"
import { SignUp } from "@/shared/schemas"
export async function signUp(_: unknown, form: FormData) {
const r = SignUp.safeParse(Object.fromEntries(form))
if (!r.success) return { errors: r.error.flatten().fieldErrors }
await db.user.create({ data: r.data })
return { ok: true }
}
10. Common pitfalls
Client-only validation — validating a form only on the client gets bypassed by direct fetch. Validate again on the server with the same schema.
Re-render cost of controlled — making a large form fully controlled re-renders almost every component on every keystroke. react-hook-form or split useState is the answer.
refine cost — a heavy computation inside a synchronous refine runs on every keystroke. Handle it with debounce or async refine.
Optional field and empty string — HTML forms send blank inputs as "". Just optional() in zod will not pass it. Use z.string().optional().or(z.literal("")) or a preprocessing transform.
i18n of error messages — zod's default messages are in English. Swap a Korean dictionary via setErrorMap (helper libraries like zod-i18n-map).
Closing thoughts
The react-hook-form + zod combination greatly reduces the cost of writing forms. The same schema does both type extraction and validation on client and server simultaneously. Boundary validation placed at the first place where external data enters the code is the simplest answer.
Next
- bundlers
- tauri-mobile-admob
We refer to react-hook-form, zod official, zod GitHub, @hookform/resolvers, valibot, ArkType, TanStack Form, and Standard Schema.