Migrating from Formik to React Hook Form

How do you move a React application’s forms from Formik to React Hook Form without a risky big-bang rewrite, without changing the validation rules users rely on, and without losing the accessibility fixes you added over the years? The two libraries share concepts — values, errors, touched and submission state — but differ in architecture: Formik keeps form state in React state and re-renders on every change, React Hook Form registers inputs and subscribes to state changes selectively. This recipe migrates one form at a time: it maps Formik’s API to React Hook Form’s, keeps the existing Yup schema through @hookform/resolvers/yup so rules do not change, reproduces error timing and focus behaviour, and verifies parity with the same tests. Errors keep flowing to users through aria-describedby, aria-invalid and focus, consistent with the site’s Constraint Validation API baseline.

When to Migrate

Migrate for a concrete benefit, not for novelty:

  • Performance — large Formik forms that lag while typing because every change re-renders every field.
  • New work already uses React Hook Form, and maintaining two form libraries costs more than migrating.
  • Server integration — moving to server actions or sharing a Zod schema with the backend, which React Hook Form’s resolvers support directly.
  • Maintenance — Formik’s slow release cadence becomes a risk for React upgrades.

If none apply, improving Formik forms in place, as described in Formik and Yup validation, is usually cheaper. The destination library is covered in React Hook Form validation.

Formik to React Hook Form API mapping A table mapping common Formik APIs to their React Hook Form equivalents. Formik React Hook Form Form state useFormik / Formik useForm() Bind an input getFieldProps(name) register(name) Schema validationSchema: yupSchema resolver: yupResolver(schema) Errors formik.errors.name formState.errors.name?.message Touched formik.touched.name formState.touchedFields.name Server errors setFieldError setError(name, { message }) Submit handleSubmit (formik) handleSubmit(onValid)
Most concepts map one to one; the main shift is from controlled state to registered inputs with selective subscriptions.

Minimal Working Migration of One Form

// BEFORE: Formik
import { useFormik } from "formik";
import { profileSchema } from "./profile-schema";   // Yup

export function ProfileFormOld() {
  const formik = useFormik({
    initialValues: { name: "", email: "" },
    validationSchema: profileSchema,
    validateOnChange: false,
    onSubmit: save,
  });
  const show = (f: "name" | "email") => (formik.touched[f] || formik.submitCount > 0) && formik.errors[f];
  return (
    <form noValidate onSubmit={formik.handleSubmit}>
      <label htmlFor="email">Email address</label>
      <input id="email" {...formik.getFieldProps("email")} aria-invalid={show("email") ? true : undefined} aria-describedby="email-err" />
      <p id="email-err" hidden={!show("email")}>{show("email") || ""}</p>
      {/* …name field… */}
      <button type="submit">Save</button>
    </form>
  );
}
// AFTER: React Hook Form, same Yup schema, same timing and accessibility
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { profileSchema, type Profile } from "./profile-schema";

export function ProfileForm() {
  const {
    register, handleSubmit, setError,
    formState: { errors, isSubmitting },
  } = useForm<Profile>({
    resolver: yupResolver(profileSchema),
    mode: "onBlur",              // = validateOnChange false: first verdict on blur
    reValidateMode: "onChange",  // clear errors live once shown
    shouldFocusError: true,      // focus first invalid field on failed submit
    defaultValues: { name: "", email: "" },
  });

  async function onValid(values: Profile) {
    const res = await save(values);
    if (res.status === 422) {
      for (const [name, message] of Object.entries(res.errors)) setError(name as keyof Profile, { message }, { shouldFocus: true });
    }
  }

  return (
    <form noValidate onSubmit={handleSubmit(onValid)} aria-busy={isSubmitting}>
      <label htmlFor="email">Email address</label>
      <input id="email" type="email" autoComplete="email" {...register("email")}
             aria-invalid={errors.email ? true : undefined} aria-describedby="email-err" />
      <p id="email-err" className="field-error" hidden={!errors.email}>{errors.email?.message}</p>
      {/* …name field… */}
      <button type="submit" aria-disabled={isSubmitting}>Save</button>
    </form>
  );
}

Three settings reproduce Formik behaviour the users are used to. mode: "onBlur" matches validateOnChange: false. reValidateMode: "onChange" clears an error as soon as the field becomes valid, which Formik did on the next blur — a small improvement users welcome. And shouldFocusError: true gives the focus-on-failure that Formik forms needed a custom effect for. React Hook Form only shows errors after the relevant validation event, so the touched || submitCount gate from Formik is no longer needed.

Migrating one form safely A five-step flow for migrating a single form: freeze behaviour with tests, swap the library behind the same schema, reproduce timing and focus, run the same tests, then remove the Formik version. Freeze behaviour tests on the Formik form Swap library useForm + yupResolver Match timing mode onBlur, reValidate onChange Same tests pass messages, aria, focus Remove Formik form one form done
Tests written against the Formik version become the acceptance criteria for the React Hook Form version.

Migration Option Reference

Formik behaviour React Hook Form setting Notes
validateOnChange: false, validateOnBlur: true mode: "onBlur" First verdict on blur
Errors cleared on next blur reValidateMode: "onChange" Cleared live once shown
Custom focus-first-error effect shouldFocusError: true Built in; focuses registered inputs
setFieldError(name, msg) setError(name, { message }) Server errors
<FieldArray> useFieldArray Use its field.id as the React key
<Field> render props Controller For third-party controlled inputs
resetForm() reset() After successful submit
dirty formState.isDirty Unsaved-change warnings

Verification Steps

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { ProfileForm } from "./ProfileForm";            // swap to ProfileFormOld to confirm parity

describe("profile form behaviour (library-agnostic)", () => {
  it("shows the email error on blur, not while typing", async () => {
    render(<ProfileForm />);
    const email = screen.getByLabelText("Email address");
    await userEvent.type(email, "ada@");
    expect(email).not.toHaveAttribute("aria-invalid");
    await userEvent.tab();
    expect(await screen.findByText("Enter an email address like name@example.com.")).toBeVisible();
  });
  it("focuses the first invalid field on submit", async () => {
    render(<ProfileForm />);
    await userEvent.click(screen.getByRole("button", { name: "Save" }));
    expect(await screen.findByLabelText("Full name")).toHaveFocus();
  });
});

Writing the tests against user-visible behaviour — labels, messages, aria-invalid, focus — rather than library internals is what makes them reusable across the migration. The same suite validates both versions.

Edge Cases and Failure Modes

Uncontrolled inputs and default values. React Hook Form registers inputs as uncontrolled; setting value props on registered inputs creates conflicts. Provide initial values with defaultValues and update them with reset() when data loads asynchronously.

Reading values during render. Formik code often reads formik.values.x in render to show or hide dependent fields. In React Hook Form, use watch("x") or useWatch, which subscribe to just that field; reading getValues() in render does not re-render on change and leaves conditional fields stuck.

Third-party controlled components. Date pickers and selects from component libraries that expect value/onChange need Controller rather than register. Forgetting this is the most common source of “the field never validates” after migration.

Field arrays keyed by index. Formik examples often use the array index as the React key. useFieldArray provides a stable id per item; use it, or errors jump between rows after removal.

Yup-specific behaviour. The resolver keeps Yup’s semantics, including optional-by-default fields and casting. If you plan to switch to Zod later, do it as a separate step with parity tests, following Yup vs Zod for form validation.

Moving Field-Level and Async Validators

Formik field-level validate functions have a direct counterpart: the validate option of register, which accepts a function (or an object of named functions) returning true or an error message, and may be async. Carry the debounce, cache and abort logic over unchanged — React Hook Form does not debounce async validators for you — and note one behavioural difference: React Hook Form runs a field’s validate only for that field on its validation events, rather than as part of a form-wide pass, so an async check no longer runs when an unrelated field blurs. That is usually an improvement, but it means a dependent field must be re-validated explicitly with trigger("state") when its dependency changes, the equivalent of Formik’s validateField. Server errors set with setError persist until the field is re-validated, which matches the “clear on change” behaviour Formik forms needed separate state for.

<input id="username" {...register("username", {
  validate: async (value) => (await isUsernameFree(value)) || "That username is taken. Try adding a number.",
})} aria-invalid={errors.username ? true : undefined} aria-describedby="username-err" />

The async recipe in React Hook Form terms is covered in React Hook Form async field validation.

Planning the Migration Across an Application

For an application with dozens of forms, sequence the work so risk stays low and value arrives early. Start with the forms that hurt most — the large, slow ones — because they deliver the performance benefit immediately and exercise the hardest mapping cases (field arrays, controlled components) early. Build shared building blocks first: a TextField component that takes a registered field and renders label, input, message and ARIA wiring, so every migrated form gets accessible markup by construction. Keep Yup through the resolver during the library migration; changing the library and the schema library at the same time doubles the number of possible causes for any regression. Track progress with a simple list of forms and their status, and remove Formik from the bundle only when the list is empty. Teams that also want server actions can migrate straight to Next.js server actions with useActionState for simple forms, where no client form library is needed at all.

A staged migration across an app A timeline showing shared components built first, the largest forms migrated next, remaining forms migrated over several weeks, and Formik removed at the end. Shared components field + summary Large forms checkout + onboarding Remaining forms settings / profile / admin Cleanup remove Formik 0 2 4 6 8 10 12 weeks
Shared field components first, painful forms next, the long tail after, and the dependency removed only when nothing uses it.

Frequently Asked Questions

Can I migrate from Formik to React Hook Form one form at a time?

Yes. The libraries can coexist in one application. Migrate each form independently, keeping its Yup schema through yupResolver, and remove Formik when no forms use it.

How do I keep my Yup schema when moving to React Hook Form?

Install @hookform/resolvers and pass resolver: yupResolver(schema) to useForm. Validation rules and messages stay exactly the same.

How do I reproduce Formik's validate-on-blur behaviour?

Use mode: "onBlur" for the first verdict and reValidateMode: "onChange" so errors clear as soon as the field becomes valid.

Does React Hook Form move focus to the first error?

Yes, with shouldFocusError: true, which is the default. It focuses the first registered field with an error after a failed submit.

← Back to Formik and Yup Validation