Skip to content

Form State with React Hook Form

advanced18 min read

Why Forms Are a Special Category of State

Forms look simple from the outside but are secretly one of the hardest state problems in frontend. Think about what a robust form actually needs:

  • Track the value of every field
  • Track which fields have been touched, changed, or focused
  • Validate on blur, on change, or on submit (each with different UX trade-offs)
  • Show contextual error messages
  • Handle dependent validation (field B's rules depend on field A's value)
  • Manage dynamic field arrays (add/remove items)
  • Handle submission (including async, pending states, and server errors)
  • Reset to initial values or a new default

Stuffing all this into Redux or Zustand is technically possible but massively overkill. Form state is ephemeral — it exists during editing and vanishes on submit. It has a clear lifecycle that doesn't benefit from global state management.

Mental Model

React Hook Form treats the DOM as the source of truth. Instead of React controlling every input via state (controlled components), RHF lets the browser manage input values natively (uncontrolled components) and only reads them when needed — on validation or submission. It's like the difference between a micromanager checking in every second and a project manager who trusts the team and reviews at milestones. The result: far fewer re-renders, especially in large forms.

Uncontrolled vs Controlled: The Performance Gap

Here's the fundamental insight behind React Hook Form: controlled inputs re-render on every keystroke.

// CONTROLLED — re-renders entire form component on every keystroke
function ControlledForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [bio, setBio] = useState('');

  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <textarea value={bio} onChange={(e) => setBio(e.target.value)} />
    </form>
  );
}

With 20 fields, typing in any field re-renders the entire component (and all 20 inputs) on every keystroke. That's 20 field re-renders per character typed.

React Hook Form avoids this by registering inputs as uncontrolled:

// UNCONTROLLED — zero re-renders while typing
import { useForm } from 'react-hook-form';

interface FormData {
  name: string;
  email: string;
  bio: string;
}

function UncontrolledForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>();

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name', { required: 'Name is required' })} />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register('email', { pattern: { value: /^\S+@\S+$/, message: 'Invalid email' } })} />
      {errors.email && <span>{errors.email.message}</span>}

      <textarea {...register('bio', { maxLength: { value: 500, message: 'Max 500 chars' } })} />
      {errors.bio && <span>{errors.bio.message}</span>}
    </form>
  );
}

register returns { name, ref, onChange, onBlur } — it connects the input directly to RHF's internal store without React state. The component only re-renders when form state metadata changes (errors, submission status), not on every keystroke.

Quiz
A form has 30 fields. The user types 'hello' in the first field. How many component re-renders occur with controlled useState vs React Hook Form?

Controller: When You Need Controlled Components

Some UI libraries (date pickers, rich text editors, custom select components) don't expose a native ref and require controlled props. For these, use Controller:

import { useForm, Controller } from 'react-hook-form';
import { DatePicker } from '@/components/ui/DatePicker';

function EventForm() {
  const { control, handleSubmit } = useForm<EventFormData>();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="eventDate"
        control={control}
        rules={{ required: 'Date is required' }}
        render={({ field, fieldState }) => (
          <DatePicker
            value={field.value}
            onChange={field.onChange}
            error={fieldState.error?.message}
          />
        )}
      />
    </form>
  );
}

Controller bridges RHF's internal store with controlled components. Use register for native HTML inputs, Controller for custom components that need value + onChange.

Zod Integration for Validation

React Hook Form integrates with Zod for schema-based validation — the same schema validates both client and server:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const signupSchema = z.object({
  username: z.string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be at most 20 characters')
    .regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores'),
  email: z.string().email('Invalid email address'),
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain an uppercase letter')
    .regex(/[0-9]/, 'Must contain a number'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords must 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) => {
    await createAccount(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('username')} placeholder="Username" />
      {errors.username && <span>{errors.username.message}</span>}

      <input {...register('email')} type="email" placeholder="Email" />
      {errors.email && <span>{errors.email.message}</span>}

      <input {...register('password')} type="password" placeholder="Password" />
      {errors.password && <span>{errors.password.message}</span>}

      <input {...register('confirmPassword')} type="password" placeholder="Confirm password" />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating account...' : 'Sign Up'}
      </button>
    </form>
  );
}

The zodResolver runs the full Zod schema on validation and maps errors to field names. z.infer generates the TypeScript type from the schema — single source of truth for types and validation.

Quiz
You define a Zod schema with z.object and use zodResolver. Where does validation run?

Form State Metadata

React Hook Form tracks rich metadata about your form:

const {
  formState: {
    isDirty,        // any field changed from default?
    isValid,        // all validations passing?
    isSubmitting,   // submission in progress?
    isSubmitSuccessful,
    submitCount,    // how many times submitted
    errors,         // validation errors by field name
    dirtyFields,    // which fields were modified
    touchedFields,  // which fields were focused and blurred
  },
} = useForm<MyFormData>();

This metadata is proxied — RHF only subscribes to the fields you actually destructure. If you only use errors and isSubmitting, changes to isDirty don't trigger re-renders.

Field Arrays

Dynamic forms where users add/remove items:

import { useForm, useFieldArray } from 'react-hook-form';

interface InvoiceForm {
  lineItems: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
  }>;
}

function InvoiceEditor() {
  const { register, control, handleSubmit } = useForm<InvoiceForm>({
    defaultValues: {
      lineItems: [{ description: '', quantity: 1, unitPrice: 0 }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'lineItems',
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`lineItems.${index}.description`)} />
          <input {...register(`lineItems.${index}.quantity`, { valueAsNumber: true })} type="number" />
          <input {...register(`lineItems.${index}.unitPrice`, { valueAsNumber: true })} type="number" />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}>
        Add Line Item
      </button>
    </form>
  );
}

Always use field.id as the key, not the array index. RHF generates stable IDs for each field array item — using index as key causes bugs when items are reordered or removed.

Common Trap

Never use the array index as the key prop for field array items. When you remove item at index 2 from a 5-item list, items at index 3 and 4 shift to 2 and 3. React thinks items 2 and 3 "changed" and item 4 was "removed" — the wrong item disappears and the wrong inputs get the wrong values. Always use the stable field.id from useFieldArray.

When to Use What

ApproachBest ForRe-rendersComplexity
React Hook FormComplex forms: validation, field arrays, multi-stepMinimal (uncontrolled)Medium
useActionState (React 19)Simple forms backed by Server ActionsOn submission onlyLow
Controlled useStateSingle field or very simple 2-3 field formsEvery keystrokeLow
FormikLegacy codebases already using it (don't migrate for fun)Every keystroke (controlled)Medium
Quiz
You have a simple contact form with name, email, and message fields, submitted via a React 19 Server Action. Which approach is most appropriate?
What developers doWhat they should do
Using register for third-party UI components (date pickers, custom selects) that don't forward ref
register works by attaching a ref to the DOM element. If the component doesn't expose a ref to the underlying input, register can't read or set the value. Controller bridges the gap.
Use Controller for components that need controlled value + onChange props
Watching all fields with watch() at the form level for conditional rendering
watch() without arguments subscribes to ALL field changes, re-rendering the component on every change across every field. watch('specificField') only subscribes to that field.
Use watch('specificField') to watch only the field that drives the condition
Storing form state in a global store (Redux/Zustand) so it persists across navigation
Form state is ephemeral by nature. Putting it in a global store couples it to the app lifecycle instead of the form lifecycle. If you need cross-navigation persistence, sessionStorage is lighter.
If form persistence is needed, use RHF with a persist utility or save to sessionStorage on unmount
Key Rules
  1. 1Use register for native HTML inputs. Use Controller for custom components without ref forwarding.
  2. 2Zod + zodResolver gives you type-safe validation with a single schema for both client and server.
  3. 3Field arrays need field.id as key — never use the array index.
  4. 4formState is proxied — only destructure what you need to minimize re-renders.
  5. 5For simple Server Action forms, useActionState (React 19) is lighter than React Hook Form.
  6. 6Form state is ephemeral. Don't put it in global stores. It lives during editing and dies on submit.