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.
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.
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 avoidvalidateOnChangeon the whole schema. - Server errors cleared by the next validation.
setErrorsfrom 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, sovalues.agestays a string in Formik state while the validated value is a number. Cast explicitly inonSubmitwithprofileSchema.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.
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.
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.
Related Guides
- Formik Field-Level Validation — per-field rules and async checks.
- Migrating from Formik to React Hook Form — an incremental migration path.
- React Hook Form Validation — the most common migration target.
- Yup vs Zod for Form Validation — the schema side of a migration.
← Back to Framework Integration Patterns