Formik vs Plain React for Forms – Worth it?

I typically use plain React for forms. But I just converted a React form to use Formik.

Results (sizes minified):

  • Plain React: 130 lines of code, 46K
  • Formik: 105 lines of code, 58K
  • Formik + Yup (validation): 104 lines of code, 74K

Formik reduced the code by ~25%. Nice! đź‘Ť Adding Yup (optional) cleaned up the validation code too.

Formik’s core benefits:

  • Write less code per form
  • Enforces a clear, opinionated, proven, and well-documented form management approach.

But, there are tradeoffs.

The notable cost is bundle size. It’s an extra 12K minified to use Formik. It’s an extra 28K minified if you use Formik with Yup for validation. React-dom is 32K minified. So that’s a big size bump.

Worth the cost? Depends how many forms you have.

Formik reduced the size of a single component containing a simple form by 2.4K. So, once your app has ~5 forms, Formik “pays for itself”. After around 5 forms, it actually reduces the bundle size. If you use Yup, you need around a dozen forms before Formik + Yup provides a net reduction in bundle size.

Summary: For small and simple projects, I’d avoid the overhead, especially if bundle size matters. But for larger internal apps with large teams, Formik’s enforced, documented patterns and reduced custom coding pay off and actually *reduces* the app’s total bundle size. 🔥

Here’s the Plain React Form:

import React, { useState } from "react";
import { saveShippingAddress } from "./services/shippingService";
import { useCart } from "./cartContext";

// Declaring outside component to avoid recreation on each render
const emptyAddress = {
  city: "",
  country: "",
};

const STATUS = {
  IDLE: "IDLE",
  SUBMITTED: "SUBMITTED",
  SUBMITTING: "SUBMITTING",
  COMPLETED: "COMPLETED",
};

export default function Checkout(props) {
  const { dispatch } = useCart();
  const [status, setStatus] = useState(STATUS.IDLE);
  const [saveError, setSaveError] = useState(null);
  const [touched, setTouched] = useState({});
  const [address, setAddress] = useState(emptyAddress);

  // Derived state
  const errors = getErrors(address);
  const isValid = Object.keys(errors).length === 0;

  function getErrors(address) {
    const result = {};
    if (!address.city) result.city = "City is required.";
    if (!address.country) result.country = "Country is required.";
    return result;
  }

  function handleChange(e) {
    e.persist();
    setAddress((curAddress) => {
      return {
        ...curAddress,
        [e.target.id]: e.target.value,
      };
    });
  }

  function handleBlur(e) {
    setTouched({ ...touched, [e.target.id]: true });
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus(STATUS.SUBMITTING);
    if (isValid) {
      try {
        await saveShippingAddress(address);
        dispatch({ type: "empty" });
        setStatus(STATUS.COMPLETED);
      } catch (err) {
        setSaveError(err);
      }
    } else {
      setStatus(STATUS.SUBMITTED);
    }
  }

  if (saveError) throw saveError;
  if (status === STATUS.COMPLETED) return <h1>Thanks for shopping!</h1>;

  return (
    <>
      <h1>Shipping Info</h1>
      {!isValid && status === STATUS.SUBMITTED && (
        <div role="alert">
          <p>Please fix the following errors:</p>
          <ul>
            {Object.keys(errors).map((key) => {
              return <li key={key}>{errors[key]}</li>;
            })}
          </ul>
        </div>
      )}
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="city">City</label>
          <br />
          <input
            id="city"
            type="text"
            value={address.city}
            onBlur={handleBlur}
            onChange={handleChange}
          />

          <p role="alert">
            {(touched.city || status === STATUS.SUBMITTED) && errors.city}
          </p>
        </div>

        <div>
          <label htmlFor="country">Country</label>
          <br />
          <select
            id="country"
            value={address.country}
            onBlur={handleBlur}
            onChange={handleChange}
          >
            <option value="">Select Country</option>
            <option value="China">China</option>
            <option value="India">India</option>
            <option value="United Kingodom">United Kingdom</option>
            <option value="USA">USA</option>
          </select>
          <p role="alert">
            {(touched.country || status === STATUS.SUBMITTED) && errors.country}
          </p>
        </div>

        <div>
          <input
            type="submit"
            className="btn btn-primary"
            value="Save Shipping Info"
            disabled={status === STATUS.SUBMITTING}
          />
        </div>
      </form>
    </>
  );
}

Here’s the same form, using Formik instead:

import React, { useState } from "react";
import { saveShippingAddress } from "./services/shippingService";
import { useCart } from "./cartContext";
import { Formik, Field, ErrorMessage, Form } from "formik";
import * as Yup from "yup";

// Declaring outside component to avoid recreation on each render
const emptyAddress = {
  city: "",
  country: "",
};

const STATUS = {
  IDLE: "IDLE",
  SUBMITTED: "SUBMITTED",
  SUBMITTING: "SUBMITTING",
  COMPLETED: "COMPLETED",
};

const checkoutSchema = Yup.object().shape({
  city: Yup.string().required("City is required."),
  country: Yup.string().required("Country is required"),
});

export default function Checkout() {
  const { dispatch } = useCart();
  const [saveError, setSaveError] = useState(null);

  async function handleSubmit(address, formikProps) {
    const { setStatus, setSubmitting } = formikProps;
    try {
      await saveShippingAddress(address);
      dispatch({ type: "empty" });
      setSubmitting(false);
      setStatus(STATUS.COMPLETED);
    } catch (err) {
      setSaveError(err);
    }
  }

  return (
    <Formik
      initialValues={emptyAddress}
      validationSchema={checkoutSchema}
      onSubmit={handleSubmit}
    >
      {({
        errors,
        isValid,
        status = STATUS.IDLE,
        /* and other goodies */
      }) => {
        if (saveError) throw saveError;
        if (status === STATUS.COMPLETED) return <h1>Thanks for shopping!</h1>;

        return (
          <Form>
            <h1>Shipping Info</h1>
            {!isValid && status === STATUS.SUBMITTED && (
              <div role="alert">
                <p>Please fix the following errors:</p>
                <ul>
                  {Object.keys(errors).map((key) => {
                    return <li key={key}>{errors[key]}</li>;
                  })}
                </ul>
              </div>
            )}

            <div>
              <label htmlFor="city">City</label>
              <br />
              <Field type="text" name="city" />
              <ErrorMessage role="alert" name="city" component="p" />
            </div>

            <div>
              <label htmlFor="country">Country</label>
              <br />
              <Field as="select" name="country">
                <option value="">Select Country</option>
                <option value="China">China</option>
                <option value="India">India</option>
                <option value="United Kingdom">United Kingdom</option>
                <option value="USA">USA</option>
              </Field>
              <ErrorMessage role="alert" name="country" component="p" />
            </div>

            <div>
              <input
                type="submit"
                className="btn btn-primary"
                value="Save Shipping Info"
                disabled={status === STATUS.SUBMITTING}
              />
            </div>
          </Form>
        );
      }}
    </Formik>
  );
}