skip to content

React Hook Form

React Hook Form keeps inputs uncontrolled by default, so typing does not re-render the whole form, and plugs into schema validators through resolvers. Interviewers ask about register versus Controller and why the re-render story matters on large forms.

on this pageshow

questions

6

In React Hook Form, what does register('email', { required: true }) give an input, and what does handleSubmit do before calling your callback?

level: juniorimportance: must knowfreq 75%

answer

  1. four props to spread
  2. ref reads the DOM value
  3. rules stored with the field
  4. preventDefault, validate, then onValid

basics

~20 s

In React Hook Form, register returns name, ref, onChange and onBlur to spread onto a native input. handleSubmit prevents the default submit, validates every field, then calls onValid(data) only when no errors remain, otherwise onInvalid(errors).

solid answer

~40 s

`register('email', { required: 'Email is required' })` returns `{ name, ref, onChange, onBlur }`, which you spread onto a native `<input>`. The `ref` lets the library read the value straight from the DOM element and focus it when it is invalid; the rules object is stored with the field. `handleSubmit(onValid, onInvalid?)` builds the submit handler: it calls `preventDefault()`, sets `formState.isSubmitting` to `true`, runs validation (the built-in rules, or the resolver if one is configured), and then calls `onValid(data)` with the collected values when there are no errors — or `onInvalid(errors)` and focuses the first invalid field when there are. Finally it sets `isSubmitting` back to `false`, `isSubmitted` to `true` and increments `submitCount`. Messages are read from `formState.errors.email?.message`.

code

tsx · 40 lines
tsx
import { useForm } from 'react-hook-form';

type Contact = { name: string; email: string; seats: number };

export function ContactForm({ save }: { save: (c: Contact) => Promise<void> }) {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<Contact>({ defaultValues: { name: '', email: '', seats: 1 } });

  const onValid = async (data: Contact) => {
    try {
      await save(data);
    } catch {
      setError('root.serverError', { message: 'Could not save, try again' });
    }
  };

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

      <input
        {...register('email', {
          required: 'Email is required',
          pattern: { value: /^\S+@\S+$/, message: 'Enter a valid email' },
        })}
      />
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <input type="number" {...register('seats', { valueAsNumber: true, min: 1 })} />

      {errors.root?.serverError && <p role="alert">{errors.root.serverError.message}</p>}
      <button disabled={isSubmitting}>Send</button>
    </form>
  );
}

go deeper

for a junior

Be able to write a registered input and a handleSubmit-wrapped form from memory, and name the four props register returns.

for a middle

Walk through handleSubmit's order: preventDefault, isSubmitting, validation, onValid or onInvalid, then submitCount, and explain why the ref matters.

for a senior

Show how you surface server failures with setError on a root key, keep isSubmitting honest with an awaited callback, and avoid clobbering register's handlers.

for a principal

Discuss when uncontrolled registration is the right default for a team's forms and where controlled components force exceptions.

## What register gives an input React Hook Form (RHF) is built around **registration**: instead of holding each field's value in React state, you hand the library a reference to the real DOM input and let the browser keep the value. `register(name, options?)` is the call that does this. It returns a small object: | Property | Type | What it is for | |---|---|---| | `name` | string | The field path, e.g. `email` or `address.city` | | `ref` | ref callback | Connects RHF to the DOM element so it can read the value and focus it | | `onChange` | handler | Tells RHF the value changed, so it can update its store and maybe validate | | `onBlur` | handler | Tells RHF the field was left, which marks it touched and may trigger validation | The idiomatic use is to spread it: `<input {...register('email')} />`. Because the input stays **uncontrolled** — there is no `value` prop — typing does not have to re-render the component that called `useForm`. The `name` is a **path**: `register('address.city')` produces `{ address: { city: ... } }` in the submitted data, and `register('tags.0')` produces an array entry. ## Built-in rules The second argument holds validation rules that live with the field: - `required` — a boolean or a message string, e.g. `required: 'Email is required'` - `min` / `max` — numeric bounds - `minLength` / `maxLength` — string length bounds - `pattern` — a regular expression, optionally `{ value, message }` - `validate` — a function (or an object of named functions) returning `true`, a message string, or `false` - `valueAsNumber` / `valueAsDate` — convert the string the input holds before validation runs Each rule accepts either a bare value or `{ value, message }`. Native inputs always hold strings, so a `type="number"` field reaches your data as a string unless you add `valueAsNumber: true`. ## What handleSubmit does, in order `handleSubmit(onValid, onInvalid?)` does not submit anything itself; it **returns** an async event handler you pass to `<form onSubmit>`. When the form is submitted, that handler: 1. Calls `event.preventDefault()`, so the page does not reload. 2. Sets `formState.isSubmitting` to `true`. 3. Validates: runs the resolver if `useForm` was given one, otherwise every registered field's built-in rules. 4. If there are **no errors**, awaits `onValid(data, event)` with the collected values. 5. If there **are** errors, awaits `onInvalid(errors, event)` when you supplied it, and focuses the first invalid field (the `shouldFocusError` option, `true` by default). 6. Sets `isSubmitting` to `false`, `isSubmitted` to `true`, `isSubmitSuccessful`, and increments `submitCount`. Two details matter in production. First, because step 4 is **awaited**, `isSubmitting` stays `true` for as long as an async `onValid` is waiting on a request, which is exactly what you want for disabling a button. Second, `handleSubmit` **does not swallow** an error thrown by `onValid`: it records `isSubmitSuccessful: false`, finishes the state update, then rethrows. The documented pattern is a `try/catch` inside `onValid` that calls `setError('root.serverError', { message })` for a server-side failure. Inputs registered with `disabled: true` are left out of the submitted data (their value is `undefined`). ## Reading errors Errors live in `formState.errors`, shaped like your values: `errors.email?.message`, `errors.address?.city?.message`. Each entry also carries a `type` such as `'required'` or `'pattern'`, which lets you pick a message per rule. ```tsx const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<{ email: string }>(); <form onSubmit={handleSubmit(save)}> <input {...register('email', { required: 'Email is required' })} /> {errors.email && <p role="alert">{errors.email.message}</p>} <button disabled={isSubmitting}>Subscribe</button> </form> ``` ## Common mistakes - **Adding your own `onChange` after the spread.** A later `onChange` prop replaces the one `register` returned, so RHF stops hearing changes. Pass it as the `onChange` option of `register` instead. - **Calling `preventDefault()` inside `onValid`.** It is harmless but shows a misunderstanding: `handleSubmit` already did it before validating. - **Expecting `onValid` to run with errors.** It runs only when validation passed; failures go to `onInvalid`. - **Registering the same input twice**, for example with `register` and a `Controller`, which makes two registrations fight over one name.

  • How do you run your own change handler on a registered input without breaking React Hook Form?
    Pass it as the `onChange` option of `register`, for example `register('email', { onChange: (e) => track(e) })`. Spreading `register(...)` and then writing an `onChange` prop after it replaces the library's handler, so RHF stops seeing the input change and its value, dirty state and validation go stale.
  • What happens if the onValid callback passed to handleSubmit throws?
    `handleSubmit` does not swallow it. It still finishes its state update — `isSubmitting` back to `false`, `isSubmitted` to `true`, `isSubmitSuccessful` to `false`, `submitCount` incremented — and then rethrows the error. The recommended pattern is a `try/catch` inside `onValid` that calls `setError('root.serverError', ...)` so the failure shows in the form.
  • Why does a type="number" input registered with React Hook Form submit a string?
    A native input's value is always a string, and `register` reads it from the DOM as-is. Add `valueAsNumber: true` to the register options and RHF converts it before validation runs, returning `NaN` when the text is not a number. `valueAsDate` does the same for dates.

saying these in an interview costs you the question

  • register returns a value and setter pair, like useState
  • handleSubmit calls onValid even when validation fails, passing the errors too
  • You must call e.preventDefault() yourself inside the onValid callback
  • A type="number" input arrives in the data as a number automatically
  • Writing onChange after {...register('x')} is a safe way to add behaviour
open as a page

In a React Hook Form signup form, how do you connect a controlled date-picker component that takes value and onChange but exposes no native input?

level: middleimportance: must knowfreq 60%

basics

~10 s

Wrap the date-picker in React Hook Form's Controller (or call useController) with name, control and rules. Its render prop supplies field.value, field.onChange, field.onBlur and field.ref, which you map onto the picker's own props.

open as a page

Why does typing into a React Hook Form input usually not re-render the form component, when the same keystroke in a Formik form does?

level: middleimportance: must knowfreq 62%

basics

~20 s

React Hook Form leaves registered values in the DOM and its own store, and re-renders only for formState you read. Formik keeps values in the form owner's state, so each keystroke re-renders that component and its children.

open as a page

In React Hook Form, what do the useForm options mode and reValidateMode control, and what are their defaults?

level: middleimportance: should knowfreq 50%

basics

~20 s

In React Hook Form, mode sets when fields validate before the first submit (default 'onSubmit'); reValidateMode sets when they validate after a submit (default 'onChange'). By default nothing validates while typing until the user has submitted once.

open as a page

A React Hook Form signup form renders useFieldArray phone rows with key={index}, and after removing a middle row, row-level state sticks to the wrong numbers — why, and what is the fix?

level: seniorimportance: should knowfreq 45%

basics

~20 s

React keeps component state and DOM nodes by key, so with index keys a removal makes the surviving rows inherit the removed row's state. useFieldArray gives every row a stable field.id; use key={field.id}, as the documentation requires.

open as a page

A 200-field React Hook Form form lags on every keystroke after watch() was added in the form component for a live summary — why, and what would you use instead?

level: seniorimportance: should knowfreq 40%

basics

~20 s

watch() in React Hook Form's useForm component subscribes that component to value changes, so every keystroke re-renders all 200 fields. Move the read into a child with useWatch, or call getValues when you only need values at an event.

open as a page