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.
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.
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.
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.
Related Guides
- Formik and Yup Validation — improving Formik forms in place.
- React Hook Form Validation — the destination library.
- Integrating Zod Resolver with React Hook Form — a later schema migration.
- Validating React Hook Form Field Arrays — migrating FieldArray forms.