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?
answer
- where the value lives
- DOM plus internal store
- formState is a Proxy
- Formik: state in the form owner
basics
~20 sReact 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.
solid answer
~40 sA registered input is **uncontrolled**: on each change RHF writes the new value into its internal store and only notifies subscribers whose state actually changed. The component that called `useForm` subscribes through `formState`, which is a **Proxy** — it re-renders only for the properties it read during render (`errors`, `isDirty`, `isValid`...). If nothing it reads changes, a keystroke costs no React render at all. **Formik 2**, by contrast, holds `values`, `errors` and `touched` in the component that runs `useFormik` or `<Formik>`; `handleChange` updates that state, so the owner and the fields rendered below it re-render per keystroke unless you opt into Formik's `<FastField>`. RHF can still re-render a lot — `watch()` at the root, reading many `formState` flags, or `mode: 'onChange'` producing new errors — but the default costs nothing.
code
tsx · 24 linesimport { useForm, useFormState, type Control } from 'react-hook-form';
type Profile = { displayName: string; bio: string };
function SaveButton({ control }: { control: Control<Profile> }) {
// Isolated subscription: only this button re-renders when these flags change.
const { isDirty, isValid } = useFormState({ control });
return <button disabled={!isDirty || !isValid}>Save</button>;
}
export function ProfileForm({ onSave }: { onSave: (p: Profile) => void }) {
const { register, handleSubmit, control } = useForm<Profile>({
defaultValues: { displayName: '', bio: '' },
mode: 'onTouched',
});
// This component reads no formState, so typing does not re-render it.
return (
<form onSubmit={handleSubmit(onSave)}>
<input {...register('displayName', { required: true })} />
<textarea {...register('bio')} />
<SaveButton control={control} />
</form>
);
}go deeper
Remember the one-line version: RHF keeps values out of React state; Formik keeps them in the form owner's state.
Explain the formState Proxy, the short-circuit trap, and the explicit ways a component opts into re-renders.
Use the profiler to find which subscription re-renders a slow form, then move it into useFormState or useWatch children.
Weigh a controlled model's simplicity against uncontrolled performance when setting one form library for many teams.
## The mechanism in React Hook Form React Hook Form (RHF) registers **uncontrolled** inputs. `register('email')` gives the input a `ref` and native `onChange`/`onBlur` handlers, but no `value` prop, so React never has to re-render to show the character the user typed: the browser already drew it. On each change event RHF: 1. Reads the new value from the event target. 2. Writes it into its internal form-values store. 3. Decides whether this event should validate, based on `mode` and `reValidateMode`. 4. Updates dirty/touched/error state and **notifies only subscribers of what changed**. The last step is the whole performance story. Components subscribe to RHF state in a few explicit ways, and a keystroke re-renders a component only when something it subscribed to moved. ## formState is a Proxy: read what you use The `formState` object returned by `useForm` is wrapped in a JavaScript **Proxy**. While the component renders, the Proxy records which properties it read — `errors`, `isDirty`, `dirtyFields`, `touchedFields`, `isValid`, `isSubmitting` and so on — and RHF later triggers a re-render only when one of **those** properties changes. Two consequences follow: - A form that reads only `errors` and `isSubmitting` does not re-render when the user makes the form dirty. - Reading must happen **during render**. The documented trap is a short-circuit: `disabled={!formState.isDirty || !formState.isValid}` never reads `isValid` while `isDirty` is `false`, so the Proxy may not subscribe to it. Destructure both first: `const { isDirty, isValid } = formState;`. If a child needs form flags without re-rendering the parent, `useFormState({ control })` gives it its own isolated subscription. ## Where Formik differs **Formik 2.x** (the rival this question compares against) takes the opposite route: the component that calls `useFormik`, or the `<Formik>` component, owns `values`, `errors` and `touched`, and its `<Field>` inputs are **controlled** from those values. `handleChange` updates the owner's state, which re-renders the owner and, through context, the fields beneath it. | | React Hook Form 7 | Formik 2 | |---|---|---| | Where a typed value lives | DOM input plus RHF's store | State of the component owning the form | | Input style | Uncontrolled via `register` | Controlled via `<Field>` / `field` props | | Re-render on a keystroke | Only subscribers whose state changed | The owning component and its field subtree | | Opt-in to fewer renders | Default behaviour | `<FastField>` for independent fields | | Opt-in to more renders | `watch()`, `formState` reads, `Controller` | Default behaviour | Neither is wrong. A controlled model makes every value available to render logic at every moment; the uncontrolled model makes large forms cheap by default and asks you to subscribe deliberately. ## What makes React Hook Form re-render anyway - **`watch()` in the `useForm` component** subscribes that component to value changes, so it re-renders on every keystroke of the watched fields (all fields if called without arguments). - **Reading many `formState` flags** at the root, such as `isDirty` or `dirtyFields`, subscribes the root to them. - **`mode: 'onChange'`** validates on every change; when errors appear or clear, `errors` subscribers re-render. - **`Controller` fields** are controlled, so they re-render themselves on each change — but only themselves, not the whole form. ## Judging it in an interview The strongest answer names the mechanism, not a slogan: *values are not React state in RHF, and subscriptions are opt-in and scoped.* A candidate who says RHF "never re-renders" is overstating it, and one who says Formik "is slow" is missing that a small Formik form re-renders cheaply and that the cost only shows as fields multiply.
- A React Hook Form component renders disabled={!formState.isDirty || !formState.isValid} and the button stays disabled after the form becomes valid. Why?`formState` is a Proxy that subscribes only to properties read during render. While `isDirty` is `false`, the `||` short-circuits and `isValid` is never read, so the component may not be subscribed to it. Destructure first — `const { isDirty, isValid } = formState` — so both are read on every render.
- How do you let one child show form flags without re-rendering the whole React Hook Form form?Pass `control` to the child and call `useFormState({ control })` there. It has its own subscription scope, so changes to `isDirty` or `errors` re-render only that child. `useWatch` does the same for values, which keeps `watch()` out of the root.
- Does React Hook Form's approach make a Controller-wrapped field as cheap as a registered one?No. A `Controller` renders a controlled component, so that component re-renders on every change to show the new value. The cost is scoped to the Controller, not the whole form, which is why mixing a few controlled widgets into a large registered form stays cheap.
React Hook Form is a teacher who lets students write on their own sheets and only walks over when a student raises a hand for something the teacher asked to hear about; Formik copies every word onto the classroom board, so the whole class looks up each time.
saying these in an interview costs you the question
- React Hook Form never re-renders anything while the user types
- formState is a plain object snapshot, so reading it has no side effects
- React Hook Form is faster because it debounces setState on each input
- Formik re-renders less because it batches keystrokes until blur
- Calling watch() at the form root is free because it only reads values