Type something to search...
Formik vs React Hook Form: A Side-by-Side Comparison

Formik vs React Hook Form: A Side-by-Side Comparison

For years, Formik was the default answer to "how do I build forms in React?" It took the tedious parts of controlled forms, like tracking values, touched fields, errors, and submission state, and packaged them into one component. Then React Hook Form arrived with a different idea: let the DOM hold input values, subscribe only to what you need, and avoid re-rendering the whole form on every keystroke.

Both libraries still show up in production codebases, tutorials, and job descriptions. If you are starting a new project, maintaining a Formik app, or deciding whether a migration is worth it, you need to know how they actually differ in code, not just in benchmark charts.

This post builds the same form in both libraries, then compares them on rendering behavior, validation, dynamic field arrays, third-party inputs, server errors, TypeScript, and maintenance. It finishes with a practical recommendation and a migration map.

The Core Difference in One Sentence

Formik stores every value in React state and re-renders the form when anything changes. React Hook Form registers inputs, lets the DOM hold their values, and only re-renders components that subscribe to specific state.

Everything else in this comparison follows from that design choice. If the terms are unfamiliar, the post on controlled vs uncontrolled components explains the underlying models.

Installation

# Formik with Yup, its traditional validation partner
npm install formik yup

# React Hook Form with Zod
npm install react-hook-form zod @hookform/resolvers

Formik works with Yup out of the box through validationSchema. React Hook Form uses resolvers for Zod, Yup, Valibot, and others. You can use Yup with React Hook Form or Zod with Formik as well; the pairings above are just the most common.

The Same Form in Both Libraries

The example is a registration form with a name, an email, a password, a role select, and a terms checkbox.

Formik Version

// RegisterFormik.tsx
import { Formik, Form, Field, ErrorMessage } from "formik";
import * as Yup from "yup";

interface RegisterValues {
  name: string;
  email: string;
  password: string;
  role: "developer" | "designer" | "manager";
  acceptTerms: boolean;
}

const schema = Yup.object({
  name: Yup.string().trim().required("Enter your name"),
  email: Yup.string().email("Enter a valid email").required("Enter your email"),
  password: Yup.string()
    .min(8, "Use at least 8 characters")
    .required("Enter a password"),
  role: Yup.string()
    .oneOf(["developer", "designer", "manager"])
    .required("Pick a role"),
  acceptTerms: Yup.boolean().oneOf([true], "You must accept the terms"),
});

const initialValues: RegisterValues = {
  name: "",
  email: "",
  password: "",
  role: "developer",
  acceptTerms: false,
};

async function registerUser(values: RegisterValues) {
  const res = await fetch("/api/register", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(values),
  });
  if (!res.ok) throw new Error("Registration failed");
}

export function RegisterFormik() {
  return (
    <Formik
      initialValues={initialValues}
      validationSchema={schema}
      onSubmit={async (values, { resetForm }) => {
        await registerUser(values);
        resetForm();
      }}
    >
      {({ isSubmitting }) => (
        <Form noValidate>
          <label htmlFor="name">Name</label>
          <Field id="name" name="name" />
          <ErrorMessage name="name" component="p" className="error" />

          <label htmlFor="email">Email</label>
          <Field id="email" name="email" type="email" />
          <ErrorMessage name="email" component="p" className="error" />

          <label htmlFor="password">Password</label>
          <Field id="password" name="password" type="password" />
          <ErrorMessage name="password" component="p" className="error" />

          <label htmlFor="role">Role</label>
          <Field id="role" name="role" as="select">
            <option value="developer">Developer</option>
            <option value="designer">Designer</option>
            <option value="manager">Manager</option>
          </Field>

          <label>
            <Field type="checkbox" name="acceptTerms" />I accept the terms
          </label>
          <ErrorMessage name="acceptTerms" component="p" className="error" />

          <button type="submit" disabled={isSubmitting}>
            {isSubmitting ? "Creating account..." : "Create account"}
          </button>
        </Form>
      )}
    </Formik>
  );
}

Formik's components do a lot of wiring for you. Field connects value, onChange, and onBlur by name, ErrorMessage only renders when the field has been touched and has an error, and isSubmitting is set while the onSubmit promise is pending.

React Hook Form Version

// RegisterRHF.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  name: z.string().trim().min(1, "Enter your name"),
  email: z.email("Enter a valid email"),
  password: z.string().min(8, "Use at least 8 characters"),
  role: z.enum(["developer", "designer", "manager"]),
  acceptTerms: z.boolean().refine((v) => v, {
    message: "You must accept the terms",
  }),
});

type RegisterValues = z.infer<typeof schema>;

async function registerUser(values: RegisterValues) {
  const res = await fetch("/api/register", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(values),
  });
  if (!res.ok) throw new Error("Registration failed");
}

export function RegisterRHF() {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<RegisterValues>({
    resolver: zodResolver(schema),
    mode: "onTouched",
    defaultValues: {
      name: "",
      email: "",
      password: "",
      role: "developer",
      acceptTerms: false,
    },
  });

  async function onSubmit(values: RegisterValues) {
    await registerUser(values);
    reset();
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="name">Name</label>
      <input id="name" {...register("name")} />
      {errors.name && <p className="error">{errors.name.message}</p>}

      <label htmlFor="email">Email</label>
      <input id="email" type="email" {...register("email")} />
      {errors.email && <p className="error">{errors.email.message}</p>}

      <label htmlFor="password">Password</label>
      <input id="password" type="password" {...register("password")} />
      {errors.password && <p className="error">{errors.password.message}</p>}

      <label htmlFor="role">Role</label>
      <select id="role" {...register("role")}>
        <option value="developer">Developer</option>
        <option value="designer">Designer</option>
        <option value="manager">Manager</option>
      </select>

      <label>
        <input type="checkbox" {...register("acceptTerms")} />I accept the terms
      </label>
      {errors.acceptTerms && (
        <p className="error">{errors.acceptTerms.message}</p>
      )}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating account..." : "Create account"}
      </button>
    </form>
  );
}

The amount of code is similar. The differences are in style:

  • Formik uses components (Formik, Form, Field, ErrorMessage) and a render function. It also offers a useFormik hook, but then you lose Field and ErrorMessage unless you also add a provider.
  • React Hook Form uses a hook and plain native elements. register returns name, ref, onChange, and onBlur to spread onto the input.
  • With Zod, the TypeScript type comes from the schema with z.infer, so values and validation cannot drift apart. With Formik and Yup, you either write the interface by hand as above or use Yup's InferType.

Re-Rendering Behavior

This is the difference people usually cite, so it is worth understanding precisely.

In the Formik version, every keystroke in any field updates Formik's state, which re-renders the Formik component and everything inside its render function. With five fields, that is cheap. With a hundred fields, rich custom inputs, or expensive child components, typing can start to lag. Formik's FastField component reduces this by skipping re-renders unless that field's own state changes, but it has caveats with fields that depend on each other.

In the React Hook Form version, typing does not re-render RegisterRHF at all. Values live in the DOM and in React Hook Form's internal store. The component only re-renders when something it reads from formState changes, such as an error appearing or isSubmitting flipping. formState is a proxy, so React Hook Form tracks which properties you actually read and only re-renders for those.

When you need a live value, you subscribe explicitly:

// RegisterRHF.tsx (same file, so RegisterValues is in scope)
import { useWatch, type Control } from "react-hook-form";

function PasswordStrength({ control }: { control: Control<RegisterValues> }) {
  const password = useWatch({ control, name: "password" });
  const strength =
    password.length >= 12 ? "Strong" : password.length >= 8 ? "OK" : "Weak";
  return <p aria-live="polite">Strength: {strength}</p>;
}

Only PasswordStrength re-renders as the user types the password. The rest of the form stays put. Pass control from useForm as a prop, or use useFormContext inside a FormProvider.

For small forms, neither approach is noticeably faster to the user. For large or complex forms, React Hook Form's model avoids a class of performance problems entirely.

Validation

Both libraries support schema validation, field-level validation, and validation timing options.

AspectFormikReact Hook Form
Schema validationvalidationSchema (Yup built in)resolver (Zod, Yup, Valibot, and more)
Custom functionvalidate prop returning an errors objectvalidate in register options or a custom resolver
Built-in rulesNo, use a schema or validaterequired, min, max, minLength, pattern in register
When it runsvalidateOnChange and validateOnBlur (both on by default)mode: onSubmit (default), onBlur, onChange, onTouched, all
Error visibilityYou check touched and errors togetherErrors appear based on mode, no separate touched check needed

A practical difference: Formik validates the entire form on every change by default. With a large Yup schema, that is a full schema run per keystroke. React Hook Form validates on submit by default and then re-validates changed fields, which does less work.

React Hook Form also works without a schema for simple cases:

<input
  {...register("username", {
    required: "Choose a username",
    minLength: { value: 3, message: "At least 3 characters" },
    pattern: { value: /^[a-z0-9_]+$/, message: "Lowercase letters, numbers, and _ only" },
  })}
/>

For more on choosing validation timing and patterns, see client-side form validation patterns in React.

Dynamic Field Arrays

Both libraries support lists of fields that users can add to and remove from.

Formik: FieldArray

import { Formik, Form, Field, FieldArray } from "formik";

interface Values {
  emails: { address: string }[];
}

export function EmailsFormik() {
  return (
    <Formik<Values>
      initialValues={{ emails: [{ address: "" }] }}
      onSubmit={(values) => console.log(values)}
    >
      {({ values }) => (
        <Form>
          <FieldArray name="emails">
            {({ push, remove }) => (
              <>
                {values.emails.map((_, index) => (
                  <div key={index}>
                    <Field
                      name={`emails.${index}.address`}
                      aria-label={`Email ${index + 1}`}
                    />
                    <button type="button" onClick={() => remove(index)}>
                      Remove
                    </button>
                  </div>
                ))}
                <button type="button" onClick={() => push({ address: "" })}>
                  Add email
                </button>
              </>
            )}
          </FieldArray>
          <button type="submit">Save</button>
        </Form>
      )}
    </Formik>
  );
}

React Hook Form: useFieldArray

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

interface Values {
  emails: { address: string }[];
}

export function EmailsRHF() {
  const { control, register, handleSubmit } = useForm<Values>({
    defaultValues: { emails: [{ address: "" }] },
  });
  const { fields, append, remove } = useFieldArray({ control, name: "emails" });

  return (
    <form onSubmit={handleSubmit((values) => console.log(values))}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input
            {...register(`emails.${index}.address`)}
            aria-label={`Email ${index + 1}`}
          />
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}
      <button type="button" onClick={() => append({ address: "" })}>
        Add email
      </button>
      <button type="submit">Save</button>
    </form>
  );
}

A subtle but important difference: useFieldArray gives every row a stable generated field.id to use as a key. The Formik example uses the index as the key, which is common in Formik code but causes problems when removing rows from the middle of a list, because React reuses DOM nodes for the wrong items. Formik leaves stable ids up to you. The reasons are explained in why keys matter when rendering lists in React.

Third-Party and Custom Inputs

Many UI libraries ship inputs that do not expose a native element or ref, such as date pickers, comboboxes, and sliders. These need to be controlled.

In Formik, everything is already controlled, so you connect them with setFieldValue:

import { useFormikContext } from "formik";
import { DatePicker } from "./DatePicker"; // any controlled component

export function StartDateField() {
  const { values, setFieldValue, setFieldTouched } = useFormikContext<{
    startDate: Date | null;
  }>();

  return (
    <DatePicker
      value={values.startDate}
      onChange={(date: Date | null) => setFieldValue("startDate", date)}
      onBlur={() => setFieldTouched("startDate", true)}
    />
  );
}

In React Hook Form, you use Controller (or the useController hook), which bridges the controlled component to the form's store:

import { Controller, type Control } from "react-hook-form";
import { DatePicker } from "./DatePicker";

export function StartDateField({
  control,
}: {
  control: Control<{ startDate: Date | null }>;
}) {
  return (
    <Controller
      control={control}
      name="startDate"
      render={({ field }) => (
        <DatePicker
          ref={field.ref}
          value={field.value}
          onChange={field.onChange}
          onBlur={field.onBlur}
        />
      )}
    />
  );
}

Passing field.ref lets React Hook Form focus the picker when it has an error, if the component forwards the ref to a focusable element. This is one area where Formik feels slightly more natural, because there is no separate concept for controlled fields.

Handling Server Errors

After submitting, the server may reject a value, like an email that is already registered. Both libraries let you put that error on the field.

// Formik: inside onSubmit(values, helpers)
helpers.setFieldError("email", "This email is already registered");
// or set several at once
helpers.setErrors({ email: "This email is already registered" });
// React Hook Form: inside onSubmit, using setError from useForm
setError("email", { type: "server", message: "This email is already registered" });
setError("root.serverError", { type: "500", message: "Something went wrong" });

React Hook Form's root errors are handy for form-level messages that do not belong to any field. They are not cleared by re-validation of fields, and they are reset on the next submit.

TypeScript Support

Both libraries are written in TypeScript, but the depth differs.

  • React Hook Form types field names as string literal paths. register("emial") is a compile error, and errors.email?.message is typed. With a Zod resolver, the form type and validation are derived from one source.
  • Formik types values, errors, and touched from your initialValues type, but field names in Field, ErrorMessage, and setFieldValue are plain strings, so typos are only caught at runtime.

Maintenance and Ecosystem

This is an important practical factor. Formik's last release was in 2024, and the project has seen very little maintenance activity in recent years, with many open issues and pull requests. It still works with current React versions in typical use, but you should not expect new features or quick fixes.

React Hook Form is actively maintained, receives regular releases, and has a large ecosystem: resolvers for every popular schema library, DevTools, and first-class support in component libraries like shadcn/ui, whose form components are built on it.

Side-by-Side Summary

AspectFormikReact Hook Form
Input modelControlledUncontrolled by default, Controller for controlled
Re-renders while typingWhole formOnly subscribed components
API styleComponents and render props, plus useFormikHooks
Schema validationYup built in, others via adaptersResolvers for Zod, Yup, Valibot, and more
Field name type safetyStringsTyped paths
Field arraysFieldArrayuseFieldArray with stable ids
Custom controlled inputsNatural via setFieldValueController or useController
MaintenanceMinimalActive

Migrating From Formik to React Hook Form

You do not need to migrate working forms just because a newer library exists. But if you are touching a form anyway, or performance is a problem, the concepts map closely:

FormikReact Hook Form
initialValuesdefaultValues
validationSchema with Yupresolver: yupResolver(schema)
Field name="email"input with register("email")
ErrorMessage name="email"errors.email?.message
values.email in renderuseWatch({ name: "email" })
setFieldValuesetValue
setFieldError / setErrorssetError
resetFormreset
isSubmittingformState.isSubmitting
FieldArrayuseFieldArray
useFormikContextuseFormContext with FormProvider

You can keep your Yup schemas during migration by using yupResolver from @hookform/resolvers/yup, then switch to Zod later if you want schema-inferred types. Migrate one form at a time; the two libraries can coexist in the same app without conflict.

Which Should You Choose?

For new projects, choose React Hook Form. It is actively maintained, performs well at any form size, has stronger TypeScript support, and is the form layer most modern component libraries assume.

For existing Formik apps, keep Formik where forms are small and stable. Migrate forms that are slow, frequently changed, or need features that Formik lacks, and write new forms with React Hook Form. Whichever you use, building in accessibility from the start matters more than the library choice; building accessible forms with React Hook Form and Zod walks through the patterns.

Frequently Asked Questions (FAQ) About Formik vs React Hook Form

Formik is not officially deprecated, but it has received very little maintenance in recent years and its last release was in 2024. It continues to work for common use cases, but new projects are better served by an actively maintained library like React Hook Form.

For large or complex forms, yes, because typing does not re-render the whole form. For small forms with a handful of fields, users will not notice a difference. Performance only becomes a deciding factor when forms grow or contain expensive components.

Yes. Formik expects a Yup-like schema for validationSchema, but the zod-formik-adapter package converts a Zod schema with toFormikValidationSchema. You can also call Zod yourself in Formik's validate function and return an errors object.

Yes. Install @hookform/resolvers and pass yupResolver(schema) as the resolver option. This makes migrating from Formik easier because you can keep existing schemas.

Yes. Use the Controller component or the useController hook for components that need value and onChange, such as date pickers, selects from UI kits, and sliders. Native inputs and components that forward refs can use register directly.

React Hook Form. Field names are typed paths, so typos fail to compile, and with a Zod resolver the form type is inferred from the schema. Formik types values and errors but treats field names as plain strings.

Conclusion

Formik and React Hook Form solve the same problem with opposite models. Formik keeps every value in React state and wires fields through components, which is easy to understand but re-renders the whole form as the user types. React Hook Form leaves values in the DOM, subscribes only where you ask, and gives you typed field paths and stable field array ids.

For new work, React Hook Form is the stronger default thanks to its performance model, TypeScript support, and active maintenance. For existing Formik code, migrate gradually using the mapping table above, starting with the forms that are slowest or change most often.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading