Formik and Yup Validation

Formik was, for several years, the default way to build forms in React, and Yup was its natural partner: Formik’s validationSchema prop accepts a Yup object and turns its errors into per-field messages, while Formik tracks values, touched fields and submission state. A great many production React applications still run on this pair. Formik itself has seen little active development recently, and newer libraries such as React Hook Form and TanStack Form have taken over new projects, but teams maintaining Formik forms need to validate them well today — accessibly, without re-render storms, with async checks that do not race, and with a clear path to migrate when the time comes. This topic covers Formik and Yup validation as it should be done in 2026, connecting Formik’s model to the native Constraint Validation API wherever that improves focus and announcements.

Formik’s model is simple to describe: values live in form-level state, a validation function (usually the Yup schema) turns values into an errors map, a touched map records which fields the user has visited, and rendering combines the two. Almost every Formik validation bug comes from ignoring one of those pieces — rendering errors without touched, forgetting that validation replaces the whole errors map, or treating the errors map as the only source of truth for server responses. Keeping the model in mind makes the fixes below straightforward.

The practical problem this topic addresses is inherited code: Formik forms that show errors before the user types, never move focus on submit, announce nothing to screen readers, and re-run every validator on every keystroke.

How Formik runs Yup validation Field changes update Formik's values, Formik runs the Yup validationSchema, collects errors into a map keyed by field name, and the component renders errors only for touched fields. Field change / blur handleChange, handleBlur values updated Formik state validationSchem a Yup validate, abortEarly false errors map { field: message } Render touched[field] && errors[field]
Formik validates the whole schema on change and blur by default; the touched map decides which errors are shown.

Prerequisites for Formik and Yup

Requirement Minimum version Why it is needed
React 18+ Formik 2 works with React 18 and 19
Formik 2.4+ useFormik, <Formik>, <Field>, validationSchema
Yup 1.x Schema validation with TypeScript inference
TypeScript 5.0+ Typed values and errors
Testing Library 14+ Component tests with user events
A migration plan (optional) For moving to React Hook Form or TanStack Form later

Formik Validation API Reference

API Type Purpose Notes
validationSchema Yup object schema Form-level validation Formik runs it with abortEarly: false
validate (values) => errors | Promise<errors> Custom form-level validation Alternative to a schema
<Field validate={fn}> field-level function Per-field rules Runs alongside the schema
errors / touched maps keyed by field What is wrong / what was visited Show touched[f] && errors[f]
validateOnChange / validateOnBlur booleans When validation runs Both default to true
setFieldError / setErrors functions Server errors into Formik state Cleared by the next validation run
isSubmitting / isValidating booleans Pending UI Drive aria-busy
submitCount number Whether a submit was attempted Reveal errors on untouched fields

Step-by-Step Implementation

1. Define the Yup schema with user-ready messages

import * as yup from "yup";

export const profileSchema = yup.object({
  name: yup.string().trim().required("Enter your name."),
  email: yup.string().trim().email("Enter an email address like name@example.com.").required("Enter your email address."),
  age: yup.number().typeError("Age must be a number.").integer("Age must be a whole number.").min(16, "You must be 16 or older.").required("Enter your age."),
});
export type Profile = yup.InferType<typeof profileSchema>;

Yup fields are optional unless .required() is called, and default messages interpolate the path (“email must be a valid email”). Set explicit messages on every rule, as described in Yup vs Zod for form validation.

2. Render accessible fields from Formik state

import { useFormik } from "formik";

export function ProfileForm() {
  const formik = useFormik({
    initialValues: { name: "", email: "", age: "" },
    validationSchema: profileSchema,
    validateOnChange: false,              // first verdict on blur, not per keystroke
    onSubmit: async (values, helpers) => {
      const res = await fetch("/api/profile", { method: "POST", body: JSON.stringify(values) });
      if (res.status === 422) helpers.setErrors((await res.json()).errors);
    },
  });

  const show = (f: keyof typeof formik.values) =>
    (formik.touched[f] || formik.submitCount > 0) && formik.errors[f];

  return (
    <form noValidate onSubmit={formik.handleSubmit} aria-busy={formik.isSubmitting}>
      {(["name", "email", "age"] as const).map((f) => (
        <div className="field" key={f}>
          <label htmlFor={f}>{{ name: "Full name", email: "Email address", age: "Age" }[f]}</label>
          <input id={f} name={f} {...formik.getFieldProps(f)}
                 inputMode={f === "age" ? "numeric" : undefined}
                 aria-invalid={show(f) ? true : undefined}
                 aria-describedby={`${f}-err`} />
          <p id={`${f}-err`} className="field-error" hidden={!show(f)}>{show(f) || ""}</p>
        </div>
      ))}
      <button type="submit" aria-disabled={formik.isSubmitting}>Save</button>
    </form>
  );
}

The submitCount > 0 condition is what makes a submit on an untouched form reveal every error — Formik marks all fields touched on submit, but checking submitCount explicitly makes the intent obvious. validateOnChange: false stops the whole Yup schema running on every keystroke, which both avoids premature errors and removes the most common Formik performance problem.

3. Move focus to the first error on submit

Formik does not move focus after a failed submit. Add it, so keyboard and screen reader users land on the problem:

import { useEffect, useRef } from "react";

function useFocusFirstError(formik: ReturnType<typeof useFormik>, formRef: React.RefObject<HTMLFormElement>) {
  const lastCount = useRef(0);
  useEffect(() => {
    if (formik.submitCount === lastCount.current || formik.isValid) return;
    lastCount.current = formik.submitCount;
    const first = formRef.current?.querySelector<HTMLElement>('[aria-invalid="true"]');
    first?.focus();
  }, [formik.submitCount, formik.isValid, formik.errors]);
}

Focusing the first element with aria-invalid="true" in document order mirrors what reportValidity() does natively, and keeps focus behaviour tied to what the markup shows. For forms with several errors, add an accessible error summary and focus that instead.

4. Add field-level rules and async checks

Field-level validate functions run alongside the schema and are the natural home for rules that need their own timing, such as an availability check. The recipe, including cancellation of stale requests, is Formik field-level validation.

Failed submit in a Formik form The user submits, Formik marks all fields touched and validates the schema, errors are rendered with aria-invalid, and an effect focuses the first invalid field. User Formik Yup schema Focus effect submit validate(values, abortEarly false) errors { email, age } re-render with aria-invalid focus first [aria-invalid="true"]
Formik computes errors and touched state; an effect keyed on submitCount supplies the focus move Formik lacks.

State Management and Edge Cases

Formik keeps values, errors and touched state in React state and re-renders the whole form on every change. That shapes several edge cases.

  • Re-render cost. Every keystroke re-renders every field. For long forms, wrap fields in <FastField> or memoised components, and avoid validateOnChange on the whole schema.
  • Server errors cleared by the next validation. setErrors from a server response is replaced the next time Formik validates. Either disable automatic validation for that field until it changes, or store server errors separately and merge them in rendering.
  • Resetting after success. resetForm() clears values, errors and touched state together; call it after a successful save so the next edit starts from a clean slate rather than showing stale touched errors.
  • Numbers from inputs. Inputs produce strings; Yup’s number() casts them, so values.age stays a string in Formik state while the validated value is a number. Cast explicitly in onSubmit with profileSchema.cast(values).
  • Async validators racing. Formik runs validation on every change or blur; a slow async validator for an old value can resolve after a newer one. Guard with value comparison or AbortController.
Formik defaults versus accessible configuration Two columns comparing Formik's default validation behaviour with a configuration tuned for accessibility and performance. Formik defaults • validateOnChange and validateOnBlur both true • errors rendered without aria attributes ✗ no focus movement after failed submit ✗ whole schema runs on every keystroke Accessible configuration • validateOnChange false, blur for first verdict • aria-invalid + aria-describedby per field ✓ focus first invalid field on submit ✓ FastField or memo for long forms
A few settings and one effect turn default Formik behaviour into accessible, well-timed validation.

Accessibility Compliance With Formik

Formik is headless: it produces error strings and leaves markup entirely to you, so accessibility is exactly as good as the JSX. The requirements are the same as elsewhere on this site. Every field needs a programmatic label (<label htmlFor>). Every error needs to be text linked with aria-describedby, with aria-invalid set while it is shown (WCAG 3.3.1). Errors should explain the fix (3.3.3), which is a property of the Yup messages. Focus must move to the problem after a failed submit, which Formik does not do on its own. And asynchronous states — validating, submitting — should be exposed with aria-busy and a polite status message rather than only a spinner. Formik’s <ErrorMessage> component renders the text but adds no ARIA; wrap it or render errors yourself as above.

Common Gotchas and Debugging

Errors before interaction. Rendering formik.errors.email without checking touched shows “Enter your email address” on page load.

// Before
{formik.errors.email && <p>{formik.errors.email}</p>}
// After
{(formik.touched.email || formik.submitCount > 0) && formik.errors.email && <p id="email-err">{formik.errors.email}</p>}

Missing .required() in Yup. An optional Yup string accepts empty input, so a field that looks required passes. Audit every field.

abortEarly assumptions. Formik passes abortEarly: false to Yup, so all errors are collected — but only the first message per field is kept in errors. If you need several messages per field, use a custom validate.

Stale isValid on first render. isValid is true before any validation has run, because the errors map starts empty. Do not use it to decide whether the form is complete; run validateForm() or rely on the submit flow.

Mutating values in validate. A custom validate function that trims or normalises values in place changes Formik’s state behind its back. Return errors only; normalise in onSubmit or through Yup transforms.

Blocking the submit button when invalid. disabled={!formik.isValid} hides why the form cannot be submitted and removes the button from keyboard focus. Keep the button enabled and let submission reveal the errors.

Nested Values and Field Arrays

Formik supports nested values with dotted names (address.city) and arrays with <FieldArray>, and Yup mirrors them with nested object() and array().of() schemas. The error and touched maps follow the same shape, so errors.address?.city and touched.items?.[2]?.qty are how you reach them — and forgetting the optional chaining is a common runtime crash on first render, when those nested objects do not exist yet. Use Formik’s getIn(errors, name) and getIn(touched, name) helpers with the field’s dotted name instead, which handle missing branches and work for any depth. For arrays of rows, give each row a stable id in its data and use it as the React key; using the array index as the key makes errors and focus jump between rows when one is removed, the same identity problem discussed in dynamic and repeating fields.

import { getIn } from "formik";

function FieldError({ formik, name }: { formik: FormikProps<Values>; name: string }) {
  const show = (getIn(formik.touched, name) || formik.submitCount > 0) && getIn(formik.errors, name);
  return <p id={`${name}-err`} className="field-error" hidden={!show}>{show || ""}</p>;
}

Performance Tuning Formik Validation

Formik’s main weakness is re-rendering: every change updates form-level state, and every field that reads that state re-renders. On a form with dozens of fields and a Yup schema validated on every keystroke, typing can lag noticeably on mid-range phones. Four changes usually fix it. Turn off validateOnChange and validate on blur, which removes schema runs from the typing path. Use <FastField> for fields that do not depend on other fields’ values, so they only re-render when their own slice changes. Split very large forms into sections with their own Formik instances where the data allows it. And keep expensive validators — regexes over long text, lookups — out of the form-level schema, running them as field-level validators with their own debounce. Measure with the React Profiler before and after; if a single keystroke still re-renders every field, the form is a good candidate for migration to a library with field-level subscriptions.

Keeping Server Errors Visible Until the Field Changes

The most confusing Formik behaviour for users is a server error that vanishes on its own: the API says “email already registered”, setFieldError shows it, and then the next blur of any field re-runs validation and replaces the whole errors object, removing the server message even though nothing about the email changed. Store server errors in their own state, keyed by field, and clear each one only when that field’s value changes. Render the server error when present and the Yup error otherwise, so both sources reach the user through the same markup and ARIA wiring. The same separation is recommended for every framework in mapping server field errors to form inputs.

const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
const errorFor = (f: string) => serverErrors[f] ?? getIn(formik.errors, f);

// in onChange wrapper: clear that field's server error when its value changes
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setServerErrors(({ [e.target.name]: _, ...rest }) => rest);
  formik.handleChange(e);
};

Validating With Native Constraints Alongside Yup

Formik forms usually carry noValidate and rely entirely on Yup, but native attributes still have value: type="email", inputMode and autocomplete shape the keyboard and autofill, and required exposes the requirement to assistive technology without any ARIA. Keep them on the inputs even though Yup does the validating; they do not conflict when the form has noValidate, and they make the no-JavaScript and screen-reader experience better. Just make sure the attributes agree with the schema — a field marked required in HTML but optional in Yup confuses everyone.

Error Summaries With Formik

On longer Formik forms, pair inline errors with an error summary rendered from the same errors object after a failed submit. Build the list in field order (the order of initialValues keys, or of the fields in the JSX), render each message as a link to the field’s id, and focus the summary instead of the first field when there is more than one error. Because Formik’s errors map already holds the messages, the summary needs no extra validation logic — only rendering and focus, as shown in building an accessible error summary.

When to Migrate Away From Formik

Formik still works, and rewriting stable forms purely for fashion is rarely worth it. Migrate when there is a concrete reason: performance problems in large forms that FastField cannot fix, a move to server actions or React Server Components that Formik does not fit, a desire to share a Zod schema with the server, or new features that the rest of the codebase already builds with React Hook Form. The migration can be incremental — one form at a time, with the Yup schema temporarily wrapped as a resolver — as described in migrating from Formik to React Hook Form.

Formik compared with newer React form libraries A table comparing Formik with React Hook Form and TanStack Form on re-render behaviour, schema support, async validation and maintenance. Formik 2 React Hook Form TanStack Form Re-renders per keystroke whole form ✓ field only ✓ field only Schema integration Yup native resolvers Standard Schema Async field validation manual race guard ✓ built in ✓ debounced Active development slow ✓ Yes ✓ Yes
Formik remains serviceable; newer libraries re-render less and integrate more closely with modern schema and server patterns.

Testing Formik Forms

Formik’s asynchronous validation means tests must wait for errors to appear rather than asserting synchronously after an event. With Testing Library, use findBy* queries or waitFor, drive the form with userEvent so blur and change events fire as in a browser, and assert on user-visible outcomes: the message text, aria-invalid, and focus after submit. Test the Yup schema separately with table-driven unit tests — it is a pure function — so component tests can focus on wiring. The broader strategy is in unit testing validation logic.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import { ProfileForm } from "./ProfileForm";

it("focuses the first invalid field after a failed submit", async () => {
  render(<ProfileForm />);
  await userEvent.click(screen.getByRole("button", { name: "Save" }));
  const name = await screen.findByLabelText("Full name");
  await screen.findByText("Enter your name.");
  expect(name).toHaveAttribute("aria-invalid", "true");
  expect(name).toHaveFocus();
});

Browser Compatibility Matrix

Feature Chromium Firefox Safari Notes
Formik 2 + React 18/19 Yes Yes Yes Library is framework-level, not browser-level
noValidate on the form Yes Yes Yes Prevents native bubbles competing with Formik
aria-invalid / aria-describedby Yes Yes Yes Screen reader support universal
inputMode for numeric fields Yes Yes Yes Mobile keyboards

Frequently Asked Questions

Is Formik still a good choice for form validation?

It still works and many applications rely on it, but it sees little active development and re-renders the whole form on every change. New projects usually choose React Hook Form or TanStack Form; existing Formik forms can be kept and improved.

How do I stop Formik showing errors before the user types?

Render an error only when the field is touched or the form has been submitted, for example (touched.email || submitCount > 0) && errors.email, and consider validateOnChange: false.

Does Formik move focus to the first error?

No. Add an effect keyed on submitCount that focuses the first element with aria-invalid="true" after a failed submit, or focus an error summary when there are several errors.

How do I show server-side errors in a Formik form?

Call setErrors or setFieldError in onSubmit with the server's field errors. Be aware that the next validation run replaces them, so keep server errors separately if they must persist until the field changes.

← Back to Framework Integration Patterns

Explore This Section