Handling Form State and Validation in React

Forms are where users interact most directly with our applications, and validating that data is a core responsibility. React alone gives us enough to manage this: we can track input values, validate them, and handle submission entirely with built-in hooks.

Using the useState hook, we track three pieces of state: formValues for user input, formErrors for validation messages, and isSubmitting as a boolean that becomes true only when the form has no errors.

const submitForm = () => {
    console.log(formValues);
  };

 const handleChange = (e) => {
    const { name, value } = e.target;
    setFormValues({ ...formValues, [name]: value });
  };

const handleSubmit = (e) => {
    e.preventDefault();
    setFormErrors(validate(formValues));
    setIsSubmitting(true);
  };

const validate = (values) => {
    let errors = {};
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i;
    if (!values.email) {
      errors.email = "Cannot be blank";
    } else if (!regex.test(values.email)) {
      errors.email = "Invalid email format";
    }
    if (!values.password) {
      errors.password = "Cannot be blank";
    } else if (values.password.length < 4) {
      errors.password = "Password must be more than 4 characters";
    }
    return errors;
  };

useEffect(() => {
    if (Object.keys(formErrors).length === 0 && isSubmitting) {
      submitForm();
    }
  }, [formErrors]);

Four handlers plus a useEffect drive the form logic:

  • handleChange syncs input values with the formValues state as the user types.
  • validate takes the current formValues, runs checks on email and password, and returns an errors object populated with any failures.
  • handleSubmit populates formErrors via setFormErrors(validate(formValues)) on submission.
  • useEffect watches the formErrors object; when it is empty and isSubmitting is true, the submitForm() helper runs.
return (
    <div className="container">
      <h1>Sign in to continue</h1>
      {Object.keys(formErrors).length === 0 && isSubmitting && (
        <span className="success-msg">Signed in successfully</span>
      )}
      <form onSubmit={handleSubmit} noValidate>
        <div className="form-row">
          <label htmlFor="email">Email</label>
          <input
            type="email"
            name="email"
            id="email"
            value={formValues.email}
            onChange={handleChange}
            className={formErrors.email && "input-error"}
          />
          {formErrors.email && (
            <span className="error">{formErrors.email}</span>
          )}
        </div>
        <div className="form-row">
          <label htmlFor="password">Password</label>
          <input
            type="password"
            name="password"
            id="password"
            value={formValues.password}
            onChange={handleChange}
            className={formErrors.password && "input-error"}
          />
          {formErrors.password && (
            <span className="error">{formErrors.password}</span>
          )}
        </div>
        <button type="submit">Sign In</button>
      </form>
    </div>
  );

In the JSX, inputs are controlled: their value comes from formValues and onChange calls handleChange. Error styling applies conditionally per field, and error texts render below each input when present. When the errors object is empty and isSubmitting is true, we display a success message.

This approach works, but a dedicated form library can remove much of the repetitive wiring.

What Formik and Yup Bring

Per the Formik documentation, it is “a small library that helps you with the 3 most annoying parts in handling forms: getting values in and out of form state, validation and error messages, and handling form submission.” Formik is incremental: you can use it with plain HTML inputs and your own validation, or go further with its custom components. Yup, a JavaScript object schema validator, pairs with Formik to define reusable validation rules declaratively.

const SignUpSchema = Yup.object().shape({
  firstName: Yup.string()
    .min(2, "Too Short!")
    .max(50, "Too Long!")
    .required("Firstname is required"),

  lastName: Yup.string()
    .min(2, "Too Short!")
    .max(50, "Too Long!")
    .required("Lastname is required"),

  phoneNumber: Yup.string()
    .required("Phone number is required")
    .matches(
/^([0]{1}|\+?[234]{3})([7-9]{1})([0|1]{1})([\d]{1})([\d]{7})$/g,
      "Invalid phone number"
    ),

  email: Yup.string().email().required("Email is required"),

  password: Yup.string()
    .required("Password is required")
    .min(6, "Password is too short - should be 6 chars minimum"),
});

Formik with HTML Inputs and Custom Validation

First, install Formik and import it:

npm i formik
import { Formik } from "formik";

Before building the component, define an initialValues object and a validate function outside the component for readability. The keys in initialValues must match the name attributes of the input fields Formik should track.

const initialValues = {
  email: "",
  password: ""
};
const validate = (values) => {
  let errors = {};
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i;
  if (!values.email) {
    errors.email = "Email is required";
  } else if (!regex.test(values.email)) {
    errors.email = "Invalid Email";
  }
  if (!values.password) {
    errors.password = "Password is required";
  } else if (values.password.length < 4) {
    errors.password = "Password too short";
  }
  return errors;
};
const submitForm = (values) => {
  console.log(values);
};

Pass both, along with an onSubmit callback, into the Formik component’s props. The onSubmit function only fires when validation passes. Inside the render-prop pattern, Formik exposes several useful props:

  • values — current user input.
  • handleChange — the change event handler for inputs.
  • handleSubmit — the form submission handler.
  • errors — validation errors keyed by field name.
  • touched — boolean flags per field indicating whether the user has focused and left it.
  • handleBlur — the blur handler; without it, field errors only appear after a submit attempt, not on blur.
  • isValidtrue when errors is empty.
  • dirtytrue once any field has changed; useful for disabling the submit button on initial load.
<button
  type="submit"
  className={!(dirty && isValid) ? "disabled-btn" : ""}
  disabled={!(dirty && isValid)}>
      Sign In
</button>

Error messages render conditionally beneath each input, but only when the field has been touched and has an error. The submit button can also be disabled based on isValid and dirty.

Formik Components and Yup Validation

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

Yup lets us replace hand-written validation rules with a schema. Define an object schema with Yup's object() function, chain shape definitions and per-field rules, and pass the schema to Formik via its dedicated validationSchema prop. This prop transforms Yup errors into the shape Formik's errors and touched objects expect. The schema properties must again correspond to input field names.

const SignInSchema = Yup.object().shape({
  email: Yup.string().email().required("Email is required"),

  password: Yup.string()
    .required("Password is required")
    .min(4, "Password is too short - should be 4 chars minimum"),
});
const SignInForm = () => {
  return (
    <Formik
      initialValues={initialValues}
      validationSchema={signInSchema}
      onSubmit={(values) => {
        console.log(values);
      }}
    >
      {(formik) => {
        const { errors, touched, isValid, dirty } = formik;
        return (
          <div className="container">
            <h1>Sign in to continue</h1>
            <Form>
              <div className="form-row">
                <label htmlFor="email">Email</label>
                <Field
                  type="email"
                  name="email"
                  id="email"
                  className={errors.email && touched.email ? 
                  "input-error" : null}
                />
                <ErrorMessage name="email" component="span" className="error" />
              </div>

              <div className="form-row">
                <label htmlFor="password">Password</label>
                <Field
                  type="password"
                  name="password"
                  id="password"
                  className={errors.password && touched.password ? 
                  "input-error" : null}
                />
                <ErrorMessage
                  name="password"
                  component="span"
                  className="error"
                />
              </div>

              <button
                type="submit"
                className={!(dirty && isValid) ? "disabled-btn" : ""}
                disabled={!(dirty && isValid)}
              >
                Sign In
              </button>
            </Form>
          </div>
        );
      }}
    </Formik>
  );
};

Formik’s custom components reduce boilerplate further:

  • Formik — the root wrapper required for the others to function.
  • Form — a wrapper around the HTML <form> that automatically wires onSubmit.
  • Field — internally connects onChange, onBlur, and value to Formik's corresponding handlers and state, using the name prop for the association. By default it renders an input; the as prop can render other elements like a textarea instead.
  • ErrorMessage — renders the error message for the field named in its name prop, displaying only when that field has been visited and an error exists. By default it renders a string unless the component prop specifies otherwise.

With Yup handling the schema and Formik's components managing state, change handlers, and error display, the validation logic stays declarative and the component code stays concise.

Why Formik Remains a Practical Choice

From the user's perspective, form validation is invisible — they only notice when something goes wrong. The real burden falls on developers, who need tooling that stays out of the way while handling the inevitable complexity of forms.

Formik earns its place because it works incrementally. You can start with minimal setup and add features only as your requirements grow. That flexibility matters, especially when you're retrofitting validation onto an existing form rather than building greenfield.

Pairing Formik With Yup

Yup is a natural companion. It provides a declarative schema API that maps cleanly onto Formik's validation flow. Instead of writing imperative checks scattered across your component, you describe the rules once and let the schema handle the rest.

This pairing gives you:

  • A clear separation between validation logic and UI state.
  • Reusable schemas that can be shared across forms or even used outside React entirely.
  • A consistent error shape that simplifies rendering field-level messages.

Getting Started With the Documentation

Whether you're evaluating Formik for the first time or deepening your existing setup, the official resources are the fastest route to clarity.

  • Formik Docs — covers the core API, validation strategies, and common patterns.
  • Yup Docs — details schema creation, type coercion, and custom validators.
  • Validation with Yup — a practical walkthrough of schema-driven validation.

For those exploring related territory, the archives include pieces on building forms with Ionic and React, migrating away from Gatsby, and practical SEO hygiene.