AlgoInsight

Free lesson · React Deep Dive

Why React Hook Form (vs Vanilla)

Hard8 min readreactjavascriptfrontend

Lesson 21.1 - Why React Hook Form (vs Vanilla)

The motivation lesson.

The previous modules covered ecosystem-level decisions - state management, server state, routing. Forms might seem like they shouldn't need a library - inputs are built into the browser. This lesson covers why "just use vanilla React" for forms produces specific pain, and what React Hook Form (RHF) does about it. By the end, you'll know whether your forms genuinely need a library and what RHF buys you concretely.

The framing for the lesson: vanilla forms in React are surprisingly painful for non-trivial cases. The pain isn't obvious at first - a simple login form works fine without a library. The pain compounds as forms grow - more fields, more validation, more conditional logic, more re-renders. React Hook Form is the library that addresses the compound pain without forcing structure on simple cases.


The Vanilla Approach

A typical vanilla form:

function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [emailError, setEmailError] = useState('')
  const [passwordError, setPasswordError] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)
  
  const validate = () => {
    let valid = true
    if (!email.includes('@')) {
      setEmailError('Invalid email')
      valid = false
    } else {
      setEmailError('')
    }
    if (password.length < 8) {
      setPasswordError('Min 8 characters')
      valid = false
    } else {
      setPasswordError('')
    }
    return valid
  }
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!validate()) return
    
    setIsSubmitting(true)
    try {
      await login({ email, password })
    } catch (error) {
      // ...
    } finally {
      setIsSubmitting(false)
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      {emailError && <span>{emailError}</span>}
      
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      {passwordError && <span>{passwordError}</span>}
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Log in'}
      </button>
    </form>
  )
}

This works. Two fields, simple validation, submit state. Maybe 50 lines.

Now consider what happens as the form grows.


The Compounding Costs

A specific list of where the vanilla pattern starts to hurt.

Every keystroke re-renders the whole form. Each setEmail/setPassword triggers a re-render. For a 2-field form, this is fine. For a 30-field form, every keystroke re-renders all 30 fields. Performance degrades; complex inputs (date pickers, rich editors) lag.

Validation logic gets scattered. Each field's validation is somewhere in the component. Cross-field validation ("password and confirm-password must match") is awkward. The validation rules aren't reusable.

Error state piles up. Each field has its own useState for error. Touched state. Dirty state. Submitted state. Pretty soon you have 10+ state variables.

Async validation is awkward. "Check if this email is already taken on the server." Needs debouncing, race-condition handling, loading state. Each field that needs it gets its own implementation.

Field arrays are painful. A form with a dynamic list of items (invoice line items, tag inputs, etc.) requires careful state shape and manipulation. Adding, removing, reordering - each requires bespoke code.

Server errors don't map cleanly. The server returns "field X had error Y." You need to take that response and somehow display the error next to field X. Without library support, this is fragile.

Submit state coordination is tricky. Disabled while submitting. Loading indicator. Reset after success. Error display after failure. Repeated across every form.

Form-level state (dirty, valid, submitting, submitted) needs derivation from individual field states.

Each of these costs is small on its own. Together, they're substantial. For any non-trivial form, you end up reimplementing what React Hook Form provides. The reimplementation is buggy, inconsistent across your codebase, and time-consuming to write.


What React Hook Form Provides

A specific list of capabilities:

The library is ~10KB. It's actively maintained (created by Bill Luo, now used by major React projects worldwide). It has the largest community and ecosystem of any forms library.


The Performance Story

A specific point worth understanding. The single biggest performance difference between vanilla and RHF is the re-render strategy.

flowchart TB
    subgraph Vanilla["Vanilla React: keystroke in field 1"]
        V1["onChange fires"]
        V2["setState triggers"]
        V3["WHOLE FORM re-renders"]
        V4["All 30 fields re-render"]
        V1 --> V2 --> V3 --> V4
    end
    
    subgraph RHF["React Hook Form: keystroke in field 1"]
        R1["onChange fires"]
        R2["RHF stores value in ref"]
        R3["Only field 1 re-renders<br/>(if at all)"]
        R1 --> R2 --> R3
    end

    style Vanilla fill:#f87171,stroke:#ef4444,color:#000
    style RHF fill:#34d399,stroke:#10b981,color:#000
A diagram comparing what happens on a keystroke in vanilla React versus React Hook Form. In vanilla, the input's onChange fires. The handler calls setState, updating React state. React's cascade rule means the whole component re-renders. Every field in the form re-renders, even though only one field's value changed. For a form with thirty fields including complex ones like date pickers and rich text editors, this is substantial work for every single keystroke. The form starts to lag. In React Hook Form, the input's onChange fires. RHF's register handler captures the value into a ref  - not React state. The ref update doesn't trigger a React re-render. Only field 1 re-renders if its specific subscription requires it, like watching its value for conditional rendering. The other twenty-nine fields don't re-render. Typing remains smooth regardless of form size. The mechanism uses refs and uncontrolled inputs precisely to avoid the cascade rule. This is the fundamental performance shift.

The vanilla pattern uses controlled inputs - the React state is the source of truth; the input reads from state on each render. Every keystroke updates state; state update triggers re-render; the whole form re-renders.

RHF uses uncontrolled inputs by default. The browser maintains the input's value. RHF stores values in refs. Reads happen on submit (or when you explicitly subscribe to a field). No re-render on every keystroke.

The difference is small on a 2-field form. On a 30-field form with complex inputs, RHF is dramatically smoother. On a 100-field form (admin panels, large settings pages), vanilla is unusable; RHF works fine.


When Vanilla Is Fine

A specific honest framing. Vanilla forms are fine for genuinely simple cases.

A login form with 2 fields and basic validation? Vanilla works. You probably don't need RHF.

A "leave us your email" newsletter signup? Vanilla works.

A simple comment box? Vanilla works.

The threshold: about 3-5 fields with non-trivial validation, or any form you can imagine growing in complexity. Below the threshold, vanilla is fine and adding a library is overhead. Above the threshold, RHF pays for itself quickly.

Most production apps have at least a few forms above the threshold (signup, settings, editing forms). Those benefit from RHF. The simpler forms can stay vanilla.

For consistency, many teams use RHF for all forms - the overhead for simple cases is small, and consistency makes the codebase easier to work with. That's a reasonable choice.


The Alternative Landscape

A brief tour of other options.

Formik

The pre-RHF dominant library. Mature, still maintained, but development has slowed substantially. The API is controlled-first (every keystroke updates form state); performance is similar to vanilla. Most teams migrating away from Formik move to RHF for the performance and modern API.

For existing Formik codebases: stay unless you have performance issues. For new code: RHF.

TanStack Form

A newer entrant (from Tanner Linsley, who built TanStack Query and TanStack Router). More flexible API, stronger TypeScript inference, framework-agnostic core. Less mature ecosystem; smaller community.

For projects already using TanStack tooling (Query, Router) and valuing the consistency, TanStack Form is a reasonable choice. For most other projects, RHF's larger ecosystem and stability win.

React 19's Native Form Actions

React 19 introduced server actions and improvements to form handling - useActionState, the action prop on <form>, useFormStatus. These work for simple forms and integrate well with server actions in frameworks like Next.js.

The native features handle simple cases without a library. They don't replace RHF for complex forms (no validation library integration, no field arrays, no client-side state management beyond actions). They complement RHF - use native for simple cases, RHF for complex.

Conform

A newer library focused on server actions and progressive enhancement. Strong story for SSR/server-action apps. Less common outside those contexts.

For Next.js / Remix-style apps heavy on server actions, Conform is worth knowing. For client-side React work, RHF is more general.


A Specific Decision Framework

When to use each:

For the rest of this module, we use React Hook Form. The patterns transfer to other libraries; the specific API doesn't.


A Specific Anti-Pattern: Reinventing RHF

A pattern I'd flag. Engineers sometimes write a custom "form hook" - useForm of their own - that wraps vanilla useState patterns. The motivation is "I don't want a library dependency."

The problem: the custom form hook becomes RHF, badly. You implement the same patterns (centralized state, validation, error handling, submit state). You hit the same edge cases. You discover the same gotchas. By the time you've handled half of what RHF handles, you've spent more time building and debugging the hook than RHF would have cost you in download size and reading docs.

The dependency aversion isn't worth it for forms. RHF is small, stable, well-maintained, and battle-tested. Use it.

The same reasoning applies to "I'll just use Formik because we know it." Formik is fine; if you know it well and the migration cost is high, stay. But for new code, RHF's performance advantages and modern API are worth the small learning investment.


What's Coming in This Module

A preview of what we cover.

By the end, you'll be able to build production-grade forms confidently. The "I hate form work" feeling that's common in React developers? Largely a function of not having the right tools and patterns. With RHF + Zod and the patterns in this module, forms become manageable, even pleasant.


What I Want You to Take From This Lesson

Six things.

One: Real forms have ~50 small concerns that compound. Vanilla React handles them all manually.

Two: The biggest pain in vanilla forms is performance - every keystroke re-renders the whole form.

Three: React Hook Form uses uncontrolled inputs by default. Values in refs; no re-render per keystroke.

Four: RHF provides centralized state, validation integration, field arrays, async support, server error mapping - what you'd build anyway, but well.

Five: Vanilla is fine for genuinely simple forms (1-3 fields, no complex needs).

Six: For most production forms, React Hook Form + Zod is the recommended modern stack.


Try It

Exercise: Warmup (20 minutes). Audit forms in a project. Categorize each as "simple enough for vanilla" or "complex enough to benefit from RHF."

Exercise: Core (30 minutes). Build a 5-field form in vanilla React with full validation, error display, and submit handling. Note the lines of code and the awkward parts.

Exercise: Core (30 minutes). Build the same form sketch in React Hook Form (you'll learn the API in 21.2 - for now, look at the docs and follow examples). Compare lines of code and clarity.

Exercise: Stretch (45 minutes). Take an existing vanilla form in a project. Estimate the cost of migrating to RHF. Decide if it's worth doing.


What's Next

Lesson 21.2 covers the React Hook Form core API - register, Controller, handleSubmit, watch. The vocabulary you'll use every day.

Back to React Deep Dive