mrkeyoor.com_
Wed 05 Aug 05:02 UTC
npmWeb Frontendupdated 05 Aug 2026

react-hook-form

react-hook-form manages form state in React by registering inputs as mostly uncontrolled fields, so typing in one input does not re-render the whole form. You get validation (native HTML rules or schema libraries like Zod and Yup through resolvers), error tracking, dirty/touched state, dynamic field arrays, and submit handling from one useForm() hook. It has zero runtime dependencies and only React as a peer dependency.

Verdict

The default React form library in 2026 for good reason: small, fast, and stable across years of 7.x releases. Budget real extra time if your form is mostly controlled UI-kit widgets, and consider skipping it entirely for simple server-action forms.

API stability5/5On major version 7 since 2021 with dozens of minor releases and no breaking rewrite since. The register-spread API introduced in v7 has held steady.
Docs4/5Dedicated docs site with live examples, an interactive form builder, and an FAQ. Advanced TypeScript scenarios around nested field paths and generic components are thinner than the basics.
Maintenance4/5Pushed the day of this review with a steady release cadence, but the project is driven by a small core team around one lead maintainer rather than a company.
Ecosystem5/5About 58 million weekly downloads, official resolvers for every major schema library, DevTools, and integration examples for all the big UI kits.

Use it if

  • You build forms with many fields where per-keystroke re-renders of controlled state become a visible performance problem
  • You want schema-based validation: official resolvers exist for Zod, Yup, AJV, Superstruct, and Joi via @hookform/resolvers
  • Your inputs are mostly native elements (input, select, textarea) where the register() spread pattern is genuinely minimal boilerplate
Skip it if

Setup reality

Install is one package with no dependencies beyond the React peer. The real setup cost arrives later: schema validation requires the separate @hookform/resolvers package plus your schema library; every non-native widget needs a Controller with a render prop; and the TypeScript generics for useForm<FormValues> with deeply nested field paths produce long, confusing errors until you learn the shape. Code you find online mixes v6 and v7 register APIs, and the v7 spread form ({...register('name')}) is the only correct one today.

Patterns

Register fields and handle submitbasic-form

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

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

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register('firstName')} />
      <input {...register('lastName', { required: true })} />
      {errors.lastName && <p>Last name is required.</p>}
      <input type="submit" />
    </form>
  );
}

handleSubmit only calls your callback when validation passes; spread register(), do not pass it as a ref.

Validate with a Zod schemaschema-validation

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

const schema = z.object({
  email: z.string().email(),
  age: z.coerce.number().min(18),
});

const { register, handleSubmit, formState: { errors } } =
  useForm({ resolver: zodResolver(schema) });

@hookform/resolvers is a separate install; use z.coerce for number inputs because DOM values are always strings.

Wire a controlled UI-kit input with Controllercontrolled-component

import { useForm, Controller } from 'react-hook-form';
import Select from 'react-select';

const { control } = useForm();

<Controller
  name="flavor"
  control={control}
  rules={{ required: true }}
  render={({ field }) => <Select {...field} options={options} />}
/>

Any component that does not expose a native input ref needs Controller; register() alone will silently not track it.

Add and remove rows with useFieldArraydynamic-field-array

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

const { control, register } = useForm({
  defaultValues: { items: [{ name: '' }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'items' });

{fields.map((field, index) => (
  <div key={field.id}>
    <input {...register(`items.${index}.name`)} />
    <button type="button" onClick={() => remove(index)}>x</button>
  </div>
))}
<button type="button" onClick={() => append({ name: '' })}>Add</button>

Use field.id as the key, never the array index, or rows lose their values on remove.

React to a field value changingwatch-values

import { useWatch } from 'react-hook-form';

function ShippingFields({ control }) {
  const sameAsBilling = useWatch({ control, name: 'sameAsBilling' });
  if (sameAsBilling) return null;
  return <AddressFields />;
}

useWatch re-renders only the component that calls it; watch() at the form root re-renders the whole form.

Set a value programmatically and reset after submitset-and-reset-values

const { setValue, reset, handleSubmit } = useForm({ defaultValues });

setValue('email', 'a@b.co', { shouldValidate: true, shouldDirty: true });

const onSubmit = async (data) => {
  await save(data);
  reset(data); // marks the form pristine with the saved values
};

setValue does not trigger validation or dirty tracking unless you pass the should* flags.

Load default values from an APIasync-default-values

const { register, formState: { isLoading } } = useForm({
  defaultValues: async () => {
    const res = await fetch('/api/user');
    return res.json();
  },
});

if (isLoading) return <Spinner />;

formState.isLoading is true only while async defaultValues resolve; fields render empty until then.

Show a server-side error after submitserver-error

const { setError, formState: { errors } } = useForm();

const onSubmit = async (data) => {
  const res = await fetch('/api/signup', { method: 'POST', body: JSON.stringify(data) });
  if (res.status === 409) {
    setError('email', { type: 'server', message: 'Email already taken' });
  }
};

{errors.email && <p>{errors.email.message}</p>}

Errors set with setError on a field are cleared automatically the next time that field passes validation.

Validate on blur instead of on submitvalidation-mode

const form = useForm({
  mode: 'onBlur',          // first validation pass
  reValidateMode: 'onChange', // after a field has errored
});

The default mode is onSubmit; mode: 'onChange' validates every keystroke and costs the performance you picked this library for.

Access form methods in deeply nested componentsform-context

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

function Page() {
  const methods = useForm();
  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <NestedInput />
      </form>
    </FormProvider>
  );
}

function NestedInput() {
  const { register } = useFormContext();
  return <input {...register('bio')} />;
}

useFormContext returns null outside a FormProvider; there is no error until you destructure.

Validate one field against anotherdependent-validation

const { register, getValues } = useForm();

<input type="password" {...register('password')} />
<input
  type="password"
  {...register('confirm', {
    validate: (value) =>
      value === getValues('password') || 'Passwords do not match',
  })}
/>

The validate callback also receives all form values as its second argument if you prefer not to call getValues.

Alternatives

PackageRegistryPick it when
formiknpmYou maintain a codebase already built on it; for new projects its development pace has fallen well behind
@tanstack/react-formnpmYou want a headless, framework-agnostic form library with first-class TypeScript inference