---
title: "14 — Forms, Types, and Validation — React Hook Form, Formik, TypeScript, and Zod"
uid: forms-types-validation
tags: ["react-hook-form", "react", "typescript", "roadmap:react", "zod", "forms", "formik", "validation"]
excerpt: "Three jobs, not one: a form library manages field state and submission, TypeScript catches mistakes at development time, Zod validates anything crossing a trust boundary at runtime."
date: 2026-08-13T03:27:43+0000
source: https://www.aveshina.my.id/en/blog/forms-types-validation
---

Forms and types converge on one question — how does data stay correct from a user's keystrokes to my app's logic? The split that organized it: **a form library manages field state and submission, TypeScript catches mistakes during development, and Zod validates anything that crosses a trust boundary at runtime.** [1][2][3][4] These are three different jobs at three different times; conflating them is where most form-and-validation confusion comes from.

## Why a form library at all

A form sounds simple — some inputs and a submit button. In practice, building it in vanilla React means managing state for every field, tracking touched/dirty flags, computing validation errors, syncing errors back to the UI, and handling submission. Each piece is small; the aggregate is a wall of boilerplate, which is why the roadmap points at form libraries [1].

## React Hook Form: the performant default

React Hook Form (RHF) is the dominant choice for new React work [2]. Its defining trait is **performance through uncontrolled components** — instead of re-rendering the whole form on every keystroke by holding each field in React state, RHF registers inputs via refs and reads their values at submit time. The practical effect: large forms don't slow down, because typing in one field doesn't re-render the others.

```
const { register, handleSubmit, formState: { errors } } = useForm();

return (
  <form onSubmit={handleSubmit((data) => console.log(data))}>
    <input {...register('email', { required: true })} />
    {errors.email && <span>required</span>}
    <button type="submit">Save</button>
  </form>
);
```

RHF pairs cleanly with a schema validator (most commonly Zod, via @hookform/resolvers/zod) so the validation rules live in one schema, not scattered across register calls.

## Formik: the older, controlled alternative

Formik is the library that defined this category [3]. Its model is **controlled** — form values live in React state, which makes every keystroke trigger a re-render. That's simpler to reason about than RHF's ref-based approach, but it's why Formik struggles on large forms. Formik is still a solid, well-understood library with great Yup integration; for new work I default to React Hook Form for the performance, but Formik is what I read in older codebases.

## TypeScript: compile-time safety, not runtime validation

The roadmap pairs TypeScript with validation under "Types and Validation," and the critical distinction is **when each operates** [4]. TypeScript is a static type system that catches mistakes **during development** — wrong prop types, missing fields, typos in property names surface as red squiggles in the editor before I ever run the code. It's a developer-experience and correctness layer, and it's become the default for any serious React project.

The thing TypeScript explicitly does **not** do is validate data at runtime. A user: User annotation is a compile-time claim; if fetch('/api/user') returns { name: 42 } at runtime, TypeScript can't help, because it isn't running. Any data that crosses a trust boundary — a network response, a localStorage read, user form input — needs **runtime** validation, which is a different tool.

## Zod: runtime validation at the boundaries

Zod is a TypeScript-first schema declaration and validation library, and it's the standard answer to the runtime-validation gap [5][6]. I declare a schema once; Zod both validates data against it at runtime **and** infers the matching TypeScript type, so I never write the type twice.

```
const UserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().positive(),
});
type User = z.infer<typeof UserSchema>;   // the type is derived from the schema

const result = UserSchema.safeParse(unknownData);
if (!result.success) {
  // result.error lists every field that failed
} else {
  // result.data is typed as User — safe to use
}
```

The model that clicked: **Zod guards trust boundaries.** [5][6] Anything that arrives from outside my code — an API response, form input, a URL param, a localStorage value — is unknown until I've validated it. TypeScript is fine for data my own code constructed (because the type system tracked it the whole way), but for anything external, the parse-then-use pattern is what keeps runtime bugs out.

```figure
<svg viewBox="0 0 740 300" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Three jobs at three times. Left: a form library (React Hook Form / Formik) manages field state and submission during user interaction. Middle: TypeScript catches type mistakes during development (red squiggle in editor). Right: Zod validates data crossing a trust boundary (API response, form input) at runtime.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <!-- forms -->
    <rect x="40" y="50" width="200" height="180" rx="10" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="140" y="74" font-size="13" font-weight="700" fill="#422006" text-anchor="middle">Form library</text>
    <text x="140" y="98" font-size="11" fill="#422006" text-anchor="middle">React Hook Form · Formik</text>
    <text x="140" y="124" font-size="10" fill="#422006" text-anchor="middle">field state</text>
    <text x="140" y="142" font-size="10" fill="#422006" text-anchor="middle">touched / dirty</text>
    <text x="140" y="160" font-size="10" fill="#422006" text-anchor="middle">submit handling</text>
    <text x="140" y="178" font-size="10" fill="#422006" text-anchor="middle">error display</text>
    <text x="140" y="208" font-size="9.5" fill="#64748b" text-anchor="middle" font-style="italic">during user interaction</text>

    <!-- typescript -->
    <rect x="270" y="50" width="200" height="180" rx="10" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="370" y="74" font-size="13" font-weight="700" fill="#1e1b4b" text-anchor="middle">TypeScript</text>
    <text x="370" y="98" font-size="11" fill="#1e1b4b" text-anchor="middle">static type system</text>
    <text x="370" y="130" font-size="10" fill="#1e1b4b" text-anchor="middle">wrong prop types</text>
    <text x="370" y="148" font-size="10" fill="#1e1b4b" text-anchor="middle">missing fields</text>
    <text x="370" y="166" font-size="10" fill="#1e1b4b" text-anchor="middle">property typos</text>
    <text x="370" y="208" font-size="9.5" fill="#64748b" text-anchor="middle" font-style="italic">caught during development</text>
    <text x="370" y="224" font-size="9.5" fill="#7f1d1d" text-anchor="middle" font-style="italic">not at runtime</text>

    <!-- zod -->
    <rect x="500" y="50" width="200" height="180" rx="10" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="600" y="74" font-size="13" font-weight="700" fill="#500724" text-anchor="middle">Zod</text>
    <text x="600" y="98" font-size="11" fill="#500724" text-anchor="middle">runtime validation</text>
    <text x="600" y="124" font-size="10" fill="#500724" text-anchor="middle">API responses</text>
    <text x="600" y="142" font-size="10" fill="#500724" text-anchor="middle">form input</text>
    <text x="600" y="160" font-size="10" fill="#500724" text-anchor="middle">localStorage reads</text>
    <text x="600" y="178" font-size="10" fill="#500724" text-anchor="middle">URL params</text>
    <text x="600" y="208" font-size="9.5" fill="#64748b" text-anchor="middle" font-style="italic">at trust boundaries</text>
    <text x="600" y="224" font-size="9.5" fill="#500724" text-anchor="middle" font-style="italic">schema → inferred type</text>

    <text x="370" y="270" font-size="11" fill="#475569" text-anchor="middle" font-style="italic">three jobs at three times — state during use, types at write-time, validation at the boundary</text>
  </g>
</svg>
```

## How they compose: a real form

The three tools stack. A typical React form for me: TypeScript throughout for editor safety, React Hook Form for field state and submission, a Zod schema for validation, and the @hookform/resolvers/zod adapter wiring the schema into RHF. On submit, the same Zod schema runs; when the API responds, another Zod schema validates the response before I treat it as typed. The schema is the single source of truth — TypeScript types are inferred from it, so the type and the validator can't drift.

## How I use this

For any new React project, TypeScript is non-negotiable — it's the baseline for developer-experience correctness on anything beyond a throwaway. For forms, React Hook Form is my default, with a Zod schema owning the validation rules and the form resolvers adapter gluing them together. Formik I maintain in legacy code but don't start new forms with. The mental discipline that ties it together: I ask where each piece of data came from. If my own code constructed it, TypeScript tracked it and that's enough. If it came from outside — a network call, user input, storage — it's unknown until Zod has parsed it, and only then does it become a typed value I can use. That single rule keeps both the editor happy and the runtime honest.

## References

[1] R. Wieruch, "How to use forms in React," robinwieruch.de, 2023. [Online]. Available: [https://www.robinwieruch.de/react-form/](https://www.robinwieruch.de/react-form/)

[2] React Hook Form, "React Hook Form — performant, flexible and extensible forms," react-hook-form.com, 2024. [Online]. Available: [https://react-hook-form.com/](https://react-hook-form.com/)

[3] Formik, "Formik — build forms in React," formik.org, 2024. [Online]. Available: [https://formik.org/](https://formik.org/)

[4] Microsoft, "TypeScript — the language," typescriptlang.org, 2024. [Online]. Available: [https://www.typescriptlang.org/](https://www.typescriptlang.org/)

[5] Zod, "Zod — TypeScript-first schema validation," zod.dev, 2024. [Online]. Available: [https://zod.dev/](https://zod.dev/)

[6] M. Hacks, "When should you use Zod?," totaltypescript.com, 2023. [Online]. Available: [https://www.totaltypescript.com/when-should-you-use-zod](https://www.totaltypescript.com/when-should-you-use-zod)

```quiz
Q: React Hook Form's performance advantage over Formik comes from:
- using uncontrolled components via refs, so typing in one field doesn't re-render the whole form
- running validation on a web worker
- compiling forms to native code
correct: 0
explain: RHF registers inputs as refs and reads values at submit, avoiding per-keystroke re-renders. Formik's controlled model holds values in state, re-rendering on every keystroke — which hurts large forms.

Q: TypeScript's role vs Zod's role:
- TypeScript validates at runtime; Zod at compile time
- TypeScript catches mistakes during development; Zod validates data crossing a trust boundary at runtime
- they are the same tool under different names
correct: 1
explain: TypeScript is a static, compile-time-only type system — it doesn't run in production. Zod parses and validates data at runtime, at boundaries where untrusted data enters (API responses, form input, storage).

Q: Why pair a Zod schema with React Hook Form via @hookform/resolvers/zod?
- so the validation rules live in one schema, and TypeScript types can be inferred from the same schema — single source of truth
- because React Hook Form cannot validate anything itself
- to replace TypeScript entirely
correct: 0
explain: One Zod schema drives both the form's validation (via the resolver) and the TypeScript types (via z.infer). The validator and the type can't drift, and rules aren't scattered across register calls.

Q: A fetch('/api/user') returns data you want to use as a typed User. The correct pattern is:
- cast it: const user = response as User
- parse it with a Zod schema first; only use the result if it passes
- trust it — TypeScript already proved it's a User
correct: 1
explain: Network responses cross a trust boundary and are unknown at runtime. `as User` is a compile-time lie. Parse with Zod and use the typed result only on success.

Q: The three jobs — form state, compile-time types, runtime validation — operate:
- at three different times: during user interaction, during development, at trust boundaries
- all simultaneously at runtime
- only in production
correct: 0
explain: The form library runs during interaction, TypeScript catches errors while you write code, and Zod validates when untrusted data crosses into your app. Three distinct jobs at three distinct times.
```
