Skip to main content
Technology & EngineeringForms Validation317 lines

Formik

Formik — useFormik, Field, Form, validation with Yup, FieldArray, custom inputs, submission handling, and error display patterns

Quick Summary24 lines
Formik manages form state through controlled components — every input value lives in a centralized state object, and changes flow through Formik's handlers. This model is predictable and easy to debug: you always know the current state of every field. Formik was designed to reduce boilerplate around three pain points — getting values in and out of form state, validation and error messages, and handling submission. It works with plain validation functions or schema libraries like Yup, and exposes both a hook API (`useFormik`) and component API (`<Formik>`, `<Field>`, `<Form>`) depending on your preference.

## Key Points

- **Use `getFieldProps()`** to spread `name`, `value`, `onChange`, and `onBlur` in one call instead of wiring them individually.
- **Always check `touched` before showing errors** — displaying errors on pristine fields frustrates users who have not interacted with them yet.
- **Use `setErrors` for server validation** — this maps backend field errors directly to the Formik error state, keeping the UX consistent.
- **Prefer the component API for simple forms** — `<Field>`, `<Form>`, and `<ErrorMessage>` reduce boilerplate when you do not need fine-grained control.
- **Memoize custom Field components** to avoid re-renders — use `React.memo` and ensure stable references for callbacks.
- **Use `enableReinitialize`** when `initialValues` come from async data (e.g., editing an existing record).
- **Set `validateOnMount: false`** (the default) to avoid showing errors before the user has interacted with the form.
- **Type your form values** with an explicit interface so `errors` and `touched` are fully typed.
- **Mutating `values` directly** — always use `setFieldValue` or `handleChange`. Direct mutation bypasses validation and re-rendering.
- **Deeply nesting validation schemas** without testing — Yup and Formik handle dot-path nesting (`address.city`), but it is easy to misconfigure the schema. Test nested paths explicitly.
- **Forgetting `setSubmitting(false)`** — without this, the submit button stays disabled permanently after an error. Always call it in a `finally` block.
- **Using FieldArray index as the sole key** — when items are removable or reorderable, use a unique identifier (e.g., an `id` field) as the key to prevent input state from drifting.

## Quick Example

```typescript
npm install formik yup
```
skilldb get forms-validation-skills/FormikFull skill: 317 lines
Paste into your CLAUDE.md or agent config

Formik

Core Philosophy

Formik manages form state through controlled components — every input value lives in a centralized state object, and changes flow through Formik's handlers. This model is predictable and easy to debug: you always know the current state of every field. Formik was designed to reduce boilerplate around three pain points — getting values in and out of form state, validation and error messages, and handling submission. It works with plain validation functions or schema libraries like Yup, and exposes both a hook API (useFormik) and component API (<Formik>, <Field>, <Form>) depending on your preference.

Setup

npm install formik yup

Basic form with the hook API:

import { useFormik } from "formik";
import * as Yup from "yup";

interface LoginValues {
  email: string;
  password: string;
}

const validationSchema = Yup.object({
  email: Yup.string().email("Invalid email").required("Required"),
  password: Yup.string().min(8, "At least 8 characters").required("Required"),
});

export function LoginForm() {
  const formik = useFormik<LoginValues>({
    initialValues: { email: "", password: "" },
    validationSchema,
    onSubmit: async (values, { setSubmitting, resetForm }) => {
      await fetch("/api/login", {
        method: "POST",
        body: JSON.stringify(values),
      });
      setSubmitting(false);
      resetForm();
    },
  });

  return (
    <form onSubmit={formik.handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        {...formik.getFieldProps("email")}
      />
      {formik.touched.email && formik.errors.email && (
        <div role="alert">{formik.errors.email}</div>
      )}

      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        {...formik.getFieldProps("password")}
      />
      {formik.touched.password && formik.errors.password && (
        <div role="alert">{formik.errors.password}</div>
      )}

      <button type="submit" disabled={formik.isSubmitting}>Log In</button>
    </form>
  );
}

Key Techniques

Component API with Field and Form

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

const SignupSchema = Yup.object({
  firstName: Yup.string().max(50, "Too long").required("Required"),
  lastName: Yup.string().max(50, "Too long").required("Required"),
  email: Yup.string().email("Invalid email").required("Required"),
  role: Yup.string().oneOf(["admin", "editor", "viewer"]).required("Required"),
});

export function SignupForm() {
  return (
    <Formik
      initialValues={{ firstName: "", lastName: "", email: "", role: "" }}
      validationSchema={SignupSchema}
      onSubmit={(values, { setSubmitting }) => {
        setTimeout(() => {
          console.log(values);
          setSubmitting(false);
        }, 500);
      }}
    >
      {({ isSubmitting }) => (
        <Form>
          <Field name="firstName" placeholder="First Name" />
          <ErrorMessage name="firstName" component="div" className="error" />

          <Field name="lastName" placeholder="Last Name" />
          <ErrorMessage name="lastName" component="div" className="error" />

          <Field name="email" type="email" placeholder="Email" />
          <ErrorMessage name="email" component="div" className="error" />

          <Field as="select" name="role">
            <option value="">Select a role</option>
            <option value="admin">Admin</option>
            <option value="editor">Editor</option>
            <option value="viewer">Viewer</option>
          </Field>
          <ErrorMessage name="role" component="div" className="error" />

          <button type="submit" disabled={isSubmitting}>Submit</button>
        </Form>
      )}
    </Formik>
  );
}

Custom Input Components

import { useField } from "formik";

interface TextInputProps {
  label: string;
  name: string;
  type?: string;
  placeholder?: string;
}

function TextInput({ label, ...props }: TextInputProps) {
  const [field, meta] = useField(props.name);

  return (
    <div>
      <label htmlFor={props.name}>{label}</label>
      <input
        id={props.name}
        {...field}
        {...props}
        className={meta.touched && meta.error ? "input-error" : ""}
      />
      {meta.touched && meta.error && (
        <div className="error" role="alert">{meta.error}</div>
      )}
    </div>
  );
}

// Usage inside a Formik form:
// <TextInput label="Username" name="username" placeholder="Enter username" />

FieldArray for Dynamic Lists

import { Formik, Form, Field, FieldArray, ErrorMessage } from "formik";
import * as Yup from "yup";

interface TeamForm {
  teamName: string;
  members: { name: string; email: string }[];
}

const TeamSchema = Yup.object({
  teamName: Yup.string().required("Required"),
  members: Yup.array()
    .of(
      Yup.object({
        name: Yup.string().required("Name required"),
        email: Yup.string().email("Invalid email").required("Email required"),
      })
    )
    .min(1, "At least one member"),
});

export function TeamBuilder() {
  const initialValues: TeamForm = {
    teamName: "",
    members: [{ name: "", email: "" }],
  };

  return (
    <Formik initialValues={initialValues} validationSchema={TeamSchema} onSubmit={console.log}>
      {({ values }) => (
        <Form>
          <Field name="teamName" placeholder="Team Name" />
          <ErrorMessage name="teamName" component="div" />

          <FieldArray name="members">
            {({ push, remove }) => (
              <div>
                {values.members.map((_, index) => (
                  <div key={index}>
                    <Field name={`members.${index}.name`} placeholder="Name" />
                    <ErrorMessage name={`members.${index}.name`} component="span" />

                    <Field name={`members.${index}.email`} placeholder="Email" />
                    <ErrorMessage name={`members.${index}.email`} component="span" />

                    {values.members.length > 1 && (
                      <button type="button" onClick={() => remove(index)}>Remove</button>
                    )}
                  </div>
                ))}
                <button type="button" onClick={() => push({ name: "", email: "" })}>
                  Add Member
                </button>
              </div>
            )}
          </FieldArray>

          <button type="submit">Create Team</button>
        </Form>
      )}
    </Formik>
  );
}

Server-Side Errors and Submission Helpers

<Formik
  initialValues={{ email: "", password: "" }}
  onSubmit={async (values, { setSubmitting, setErrors, setStatus }) => {
    try {
      const res = await fetch("/api/login", {
        method: "POST",
        body: JSON.stringify(values),
        headers: { "Content-Type": "application/json" },
      });

      if (!res.ok) {
        const body = await res.json();
        if (body.fieldErrors) {
          // Map server errors to fields: { email: "Already taken" }
          setErrors(body.fieldErrors);
        } else {
          // General form-level error
          setStatus({ error: body.message || "Login failed" });
        }
        return;
      }

      setStatus({ success: "Logged in!" });
    } finally {
      setSubmitting(false);
    }
  }}
>
  {({ status }) => (
    <Form>
      {status?.error && <div className="form-error">{status.error}</div>}
      {status?.success && <div className="form-success">{status.success}</div>}
      {/* fields */}
    </Form>
  )}
</Formik>

Custom Validation Function (No Yup)

function validate(values: { email: string; age: string }) {
  const errors: Record<string, string> = {};

  if (!values.email) {
    errors.email = "Required";
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
    errors.email = "Invalid email";
  }

  const age = parseInt(values.age, 10);
  if (isNaN(age)) {
    errors.age = "Must be a number";
  } else if (age < 18) {
    errors.age = "Must be at least 18";
  }

  return errors;
}

// Pass to Formik: <Formik validate={validate} ... />

Best Practices

  • Use getFieldProps() to spread name, value, onChange, and onBlur in one call instead of wiring them individually.
  • Always check touched before showing errors — displaying errors on pristine fields frustrates users who have not interacted with them yet.
  • Use setErrors for server validation — this maps backend field errors directly to the Formik error state, keeping the UX consistent.
  • Prefer the component API for simple forms<Field>, <Form>, and <ErrorMessage> reduce boilerplate when you do not need fine-grained control.
  • Memoize custom Field components to avoid re-renders — use React.memo and ensure stable references for callbacks.
  • Use enableReinitialize when initialValues come from async data (e.g., editing an existing record).
  • Set validateOnMount: false (the default) to avoid showing errors before the user has interacted with the form.
  • Type your form values with an explicit interface so errors and touched are fully typed.

Anti-Patterns

  • Mixing useFormik with <Formik> contextuseFormik does not create a context provider, so <Field> and useField will not work with it. Use <Formik> if you need the component API, or useFormik with raw inputs.
  • Mutating values directly — always use setFieldValue or handleChange. Direct mutation bypasses validation and re-rendering.
  • Deeply nesting validation schemas without testing — Yup and Formik handle dot-path nesting (address.city), but it is easy to misconfigure the schema. Test nested paths explicitly.
  • Forgetting setSubmitting(false) — without this, the submit button stays disabled permanently after an error. Always call it in a finally block.
  • Using FieldArray index as the sole key — when items are removable or reorderable, use a unique identifier (e.g., an id field) as the key to prevent input state from drifting.
  • Running expensive async validation on every keystroke — use validateOnChange: false or debounce async validators to avoid hammering the server.

Install this skill directly: skilldb add forms-validation-skills

Get CLI access →