DEV_NET_CORE
GET_STARTED
ReactForms, validation, and frontend performance in production

React Hook Form fundamentals, uncontrolled inputs, `Controller`, and form state

Overview

React Hook Form is a form state and validation library for React that is built around registering fields, reading values at submit time, and minimizing rerenders. Its default model works especially well with uncontrolled native inputs, where the browser owns the current input value and React Hook Form tracks the field through refs and event handlers.

This matters because production forms often become performance bottlenecks. A large controlled form can rerender many components on every keystroke if state is lifted too high or subscribed too broadly. React Hook Form helps avoid that by letting fields register with the form control and by exposing targeted APIs for field state, form state, validation, reset, and submission.

React Hook Form is used for login forms, profile forms, checkout flows, admin CRUD screens, search filters, multi-step flows, and large enterprise forms. It can handle simple native inputs with register, controlled third-party components with Controller, and complex validation through built-in rules or schema resolvers.

For interviews, this topic is important because it tests whether a candidate understands form ownership, controlled versus uncontrolled inputs, validation timing, form state subscriptions, accessibility, and how to integrate component libraries without fighting React.

Core Concepts

React Hook Form's Mental Model

React Hook Form starts with useForm.

Code
import { useForm } from "react-hook-form";

type LoginFormValues = {
  email: string;
  password: string;
};

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginFormValues>({
    defaultValues: {
      email: "",
      password: "",
    },
  });

  async function onSubmit(values: LoginFormValues) {
    await login(values);
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>
        Email
        <input
          type="email"
          {...register("email", { required: "Email is required" })}
        />
      </label>
      {errors.email ? <p role="alert">{errors.email.message}</p> : null}

      <label>
        Password
        <input
          type="password"
          {...register("password", { required: "Password is required" })}
        />
      </label>
      {errors.password ? <p role="alert">{errors.password.message}</p> : null}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Signing in..." : "Sign in"}
      </button>
    </form>
  );
}

The main pieces are:

  • register: connects an input to the form.
  • handleSubmit: validates and calls success or error callbacks.
  • formState: exposes form-level state such as errors, dirty state, touched fields, submit state, and validation state.
  • defaultValues: defines the baseline values used for dirty tracking and reset.
  • reset, setValue, getValues, watch, and trigger: imperative helpers for common form workflows.

Uncontrolled Inputs

An uncontrolled input stores its current value in the DOM rather than in React state on every keystroke. React Hook Form uses this pattern by default for native fields.

Code
<input {...register("firstName")} />

This is different from a controlled input:

Code
const [firstName, setFirstName] = useState("");

<input
  value={firstName}
  onChange={(event) => setFirstName(event.target.value)}
/>;

Uncontrolled inputs are useful because:

  • The field can update without forcing the parent component to rerender on every keystroke.
  • The browser keeps native input behavior.
  • Large forms can stay responsive.
  • Form state subscriptions can be scoped more carefully.

Uncontrolled does not mean unmanaged. React Hook Form still tracks the field through name, ref, onChange, and onBlur.

register

register returns props that should be spread onto a native input.

Code
<input
  {...register("age", {
    valueAsNumber: true,
    min: { value: 18, message: "Must be at least 18" },
  })}
/>

The returned props include:

  • name.
  • ref.
  • onChange.
  • onBlur.
  • Native validation-related attributes when applicable.

Rules can include validation and value conversion. For example, valueAsNumber converts the field value before validation and submission. setValueAs can customize conversion.

Common mistake:

Code
<input {...register("email")} value={email} onChange={setEmail} />

This mixes uncontrolled registration with a controlled value without a clear reason. If a field must be controlled, use Controller or useController.

handleSubmit

handleSubmit wraps the form submit event. It runs validation, prevents invalid submission, and calls a valid callback or optional invalid callback.

Code
const onValid = async (values: ProfileFormValues) => {
  await updateProfile(values);
};

const onInvalid = (errors: FieldErrors<ProfileFormValues>) => {
  console.log(errors);
};

<form onSubmit={handleSubmit(onValid, onInvalid)} />;

Important points:

  • The valid callback receives parsed form values.
  • The invalid callback receives validation errors.
  • Async submit handlers are supported.
  • isSubmitting can drive pending UI.
  • Server errors should be mapped back with setError when useful.

defaultValues

defaultValues are the initial values for the form and the baseline for dirty tracking.

Code
const form = useForm<ProfileFormValues>({
  defaultValues: {
    firstName: "",
    lastName: "",
    email: "",
  },
});

Why defaultValues matter:

  • isDirty compares current values against defaults.
  • dirtyFields depends on defaults.
  • reset() returns fields to defaults.
  • Consistent defaults prevent uncontrolled-to-controlled warnings.

For data loaded from an API, use async defaults or call reset after data arrives.

Code
const { reset } = useForm<ProfileFormValues>({
  defaultValues: {
    firstName: "",
    lastName: "",
  },
});

useEffect(() => {
  if (profile) {
    reset(profile);
  }
}, [profile, reset]);

Avoid using undefined as a default value for fields that render input values.

Form State

formState exposes form-level state.

Common properties:

  • errors: validation errors by field name.
  • isDirty: whether any field differs from defaultValues.
  • dirtyFields: fields changed from defaults.
  • touchedFields: fields that received blur.
  • isSubmitting: submit handler is running.
  • isSubmitSuccessful: last submit completed successfully.
  • isSubmitted: the form has been submitted at least once.
  • submitCount: number of submit attempts.
  • isValid: whether the form currently passes validation.
  • isValidating: validation is currently running.
  • validatingFields: fields currently validating.
  • isLoading: async default values are loading.

Example:

Code
const {
  formState: { isDirty, isSubmitting, errors },
} = useForm<ProfileFormValues>();

React Hook Form tracks subscriptions to form state. Read only the parts of formState that the component actually needs. Broad subscriptions can cause unnecessary rerenders.

Field State

Sometimes a component only needs state for one field. Use getFieldState, useController, or useFormState instead of subscribing a large parent to the entire form.

Code
const fieldState = getFieldState("email");

if (fieldState.invalid) {
  console.log(fieldState.error?.message);
}

Field state commonly includes:

  • invalid.
  • isDirty.
  • isTouched.
  • isValidating.
  • error.

This is useful for reusable input components that need their own error and touched state.

Controller

Controller is used when a field is controlled by a component that does not expose a normal native input ref and event shape.

Common examples:

  • UI library select components.
  • Date pickers.
  • Autocomplete components.
  • Rich text editors.
  • Masked inputs.
  • Custom toggles.

Example:

Code
import { Controller, useForm } from "react-hook-form";

type FormValues = {
  startDate: Date | null;
};

function EventForm() {
  const { control, handleSubmit } = useForm<FormValues>({
    defaultValues: {
      startDate: null,
    },
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Controller
        control={control}
        name="startDate"
        render={({ field, fieldState }) => (
          <>
            <DatePicker
              selected={field.value}
              onChange={field.onChange}
              onBlur={field.onBlur}
              ref={field.ref}
            />
            {fieldState.error ? (
              <p role="alert">{fieldState.error.message}</p>
            ) : null}
          </>
        )}
      />
    </form>
  );
}

The render function receives:

  • field: name, value, onChange, onBlur, ref, and sometimes disabled.
  • fieldState: error, touched, dirty, validating, and invalid state for that field.
  • formState: broader form state.

Avoiding Double Registration

Do not use register and Controller on the same field.

Bad:

Code
<Controller
  name="country"
  control={control}
  render={({ field }) => (
    <Select {...field} {...register("country")} />
  )}
/>

The field is already registered by Controller. Double registration can cause conflicting event handling, incorrect state, or confusing bugs.

Mapping Controlled Components

Third-party inputs often do not use the same event signature as native inputs. Controller lets you adapt them.

Code
<Controller
  control={control}
  name="price"
  render={({ field }) => (
    <CurrencyInput
      value={field.value}
      onValueChange={(value) => field.onChange(value)}
      onBlur={field.onBlur}
      inputRef={field.ref}
    />
  )}
/>

The important part is to pass the final field value to field.onChange. Do not pass a complex event object unless the component is designed that way.

watch, getValues, and setValue

watch subscribes to values and can rerender the component when watched values change.

Code
const plan = watch("plan");

Use it for conditional UI:

Code
{plan === "business" ? <BusinessFields /> : null}

getValues reads current values without creating a render subscription.

Code
const currentValues = getValues();

setValue updates a field imperatively and can optionally update validation, dirty, and touched state.

Code
setValue("email", normalizedEmail, {
  shouldValidate: true,
  shouldDirty: true,
  shouldTouch: true,
});

Use these intentionally. Too much imperative form manipulation can make the form harder to reason about.

reset and resetField

reset replaces form values and can preserve selected state.

Code
reset(serverValues);

After a successful save, reset to the saved values so dirty tracking reflects changes after the save point:

Code
await saveProfile(values);
reset(values);

resetField resets one field.

Code
resetField("email");

Common mistake: saving successfully but not resetting defaults. The form still appears dirty even though the current values match the saved server state.

Server Errors with setError

Server validation errors should be mapped into form errors when possible.

Code
try {
  await updateProfile(values);
} catch (error) {
  if (isValidationError(error)) {
    for (const fieldError of error.fields) {
      setError(fieldError.name as keyof ProfileFormValues, {
        type: "server",
        message: fieldError.message,
      });
    }

    return;
  }

  setError("root.server", {
    type: "server",
    message: "Could not save profile. Try again.",
  });
}

Field-specific errors belong near the field. Form-level errors belong near the submit area or top of the form.

Accessibility

React Hook Form gives state and handlers, but the markup still needs to be accessible.

Good practices:

  • Use real <form> and <button type="submit">.
  • Use <label> or htmlFor for fields.
  • Use aria-invalid when a field has an error.
  • Connect errors with aria-describedby.
  • Use role="alert" for important validation messages.
  • Keep keyboard behavior intact.
  • Focus the first invalid field when appropriate.

Example:

Code
<input
  id="email"
  aria-invalid={Boolean(errors.email)}
  aria-describedby={errors.email ? "email-error" : undefined}
  {...register("email", { required: "Email is required" })}
/>
{errors.email ? (
  <p id="email-error" role="alert">
    {errors.email.message}
  </p>
) : null}

Performance

React Hook Form's performance advantage comes from avoiding unnecessary rerenders, but it is still possible to lose that benefit.

Performance-friendly practices:

  • Prefer register for native inputs.
  • Use Controller only when a component must be controlled.
  • Subscribe to the smallest needed state.
  • Keep reusable field components scoped.
  • Avoid watching the whole form unless needed.
  • Avoid storing every field value again in React state.
  • Use defaultValues consistently.
  • Split very large forms into sections.

Do not turn a React Hook Form into a fully controlled form unless there is a clear need.

Common Mistakes

Common mistakes include:

  • Mixing value and onChange state with register accidentally.
  • Double-registering a field with both register and Controller.
  • Forgetting defaultValues, causing dirty tracking problems.
  • Reading broad formState in a top-level component and causing extra rerenders.
  • Using watch() for the whole form when only one field is needed.
  • Not forwarding ref or onBlur from Controller.
  • Not resetting defaults after a successful save.
  • Treating client validation as a replacement for server validation.
  • Showing errors without accessible labels or descriptions.

Best Practices

Best practices include:

  • Use uncontrolled inputs with register for native fields.
  • Use Controller for controlled third-party components.
  • Provide complete defaultValues.
  • Keep field components focused and reusable.
  • Subscribe only to the form state you need.
  • Map server validation errors with setError.
  • Use isSubmitting to prevent duplicate submits.
  • Reset after successful saves when current values become the new baseline.
  • Use accessible labels, errors, and focus behavior.
  • Keep server authorization and validation authoritative.

Interview Practice

PreviousPreventing duplicate requests, canceling stale requests, and avoiding race conditionsNext UpReducing state complexity and avoiding unnecessary rerenders in form-heavy pages