Forms sit at the center of most user interactions, and they are hard to get right: validation, submission, error display, and state tracking all have to work together. React's controlled components can handle this, but the boilerplate grows quickly once an application contains more than a handful of forms. Form libraries exist to absorb that work, offering validation and state management out of the box.

This comparison looks at SurveyJS, Formik, React Hook Form, React Final Form, and Unform, judged against the criteria below. A basic familiarity with React and Yup, the JavaScript object schema validator, is assumed.

What To Compare

  • Implementation. The APIs each library exposes, the effort required to integrate it into an app, and how it handles validation, submission and overall developer experience.
  • Usage with custom components. How readily a library can wrap inputs from UI kits such as Material UI.
  • Dependent fields. Rendering a field B whose behavior follows the value of a field A.
  • Learning curve. Time to first working form, plus the availability of documentation and examples.
  • Bundle size. The performance tradeoff each library introduces.

Multi-step forms are handled separately at the end.

SurveyJS

SurveyJS Form Library is an open-source client-side component that renders dynamic JSON-driven forms. Forms are described by JSON schemas that also define style, contents, layout, and behavior in response to user interaction — submission, validation, error messages and so on. Communication with the server happens through those same JSON objects. The library has native React support, is free to use, and ships under the MIT license. The wider SurveyJS product family adds a self-hosted JSON form builder with drag-and-drop UI, a CSS theme editor, and a GUI for conditional logic and form branching.

Capabilities

Installation goes through the survey-react-ui package, which pulls in survey-core as a dependency.

npm install survey-react-ui --save

The Form Library for React is split across two npm packages: survey-core holds platform-independent code, while survey-react-ui holds the rendering code. Running npm install survey-react-ui --save installs survey-react-ui and survey-core automatically. A dedicated guide covers integration with third-party components and shows how to wire the React Color component into a basic SurveyJS form.

import 'survey-core/defaultV2.min.css';
import { Model } from 'survey-core';
import { Survey } from 'survey-react-ui';

const surveyJson = {
  elements: [{
    name: "FirstName",
    title: "Enter your first name:",
    type: "text"
  }, {
    name: "LastName",
    title: "Enter your last name:",
    type: "text"
  }]
};

function App() {
  const survey = new Model(surveyJson);

  return <Survey model={survey} />;
}

export default App;

Styling starts with an import in the component that renders the form: import 'survey-core/defaultV2.min.css'; applies the Default theme, and a different predefined or custom theme can be substituted.

A model then describes the form's layout and contents; models are specified by JSON schemas. SurveyJS offers a full-featured JSON form builder demo for generating these schemas. In the example, the schema declares two textual questions, each with a title and a name: titles are shown to respondents, names identify questions in code. Passing the schema to the Model constructor instantiates it.

Rendering requires importing the Survey component, adding it to the template, and passing the model instance to its model attribute.

View Full Source Code on GitHub

Formik

Formik is deliberately flexible: it works with native HTML elements or its own components, and validation rules can be hand-written or delegated to a third-party solution like Yup. You decide how much of the library to adopt. What it removes is the repetitive work — tracking values, errors and visited fields, orchestrating validation, and handling submission — which means less time wiring up form state, onChange and onBlur.

Installation is a single package install.

npm i formik
yarn add formik

Implementation

Formik tracks form state and exposes it, along with reusable methods and handlers (handleChange, handleBlur, handleSubmit), to the form through props; the API documentation lists the full set. Beyond native inputs, Formik provides a Field component for declaring inputs and an ErrorMessage component for displaying per-field errors.

import { Form, Field, ErrorMessage, withFormik } from "formik";

const App = ({ values }) => (
  <div className={styles.container}>
    <Head>
      <title>Formik Form</title>
    </Head>
    <Form>
      <div className={styles.formRow}>
        <label htmlFor="email">Email</label>
        <Field type="email" name="email" id="email" />
        <ErrorMessage name="email" component="span" className={styles.error} />
      </div>
      <div className={styles.formRow}>
        <label htmlFor="email">Select a color to continue</label>
        <Field component="select" name="select">
          <option value="" label="Select a color" />
          <option value="red" label="red" />
          <option value="blue" label="blue" />
          <option value="green" label="green" />
        </Field>
        <ErrorMessage name="select" component="span" className={styles.error} />
      </div>
      <div className={styles.formRow}>
        <label htmlFor="checkbox">
          <Field type="checkbox" name="checkbox" checked={values.checkbox} />
          Accept Terms & Conditions
        </label>
        <ErrorMessage
          name="checkbox"
          component="span"
          className={styles.error}
        />
      </div>
      <div role="group" aria-labelledby="my-radio-group">
        <label>
          <Field type="radio" name="radio" value="Option 1" />
          One
        </label>
        <label>
          <Field type="radio" name="radio" value="Option 2" />
          Two
        </label>
        <ErrorMessage name="radio" component="span" className={styles.error} />
      </div>
      <button type="submit" className={"disabled-btn"}>
        Sign In
      </button>
    </Form>
  </div>
);

The example form covers four input types: email, select, checkbox, and radio. Form wraps an HTML <form> element and hooks into Formik's handleSubmit and handleReset.

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

const App = ({ values }) => (
  <div className={styles.container}>
    <Head>
      <title>Formik Form</title>
    </Head>
    <Form>
      //form stuffs here
      <button type="submit" className={"disabled-btn"}>
        Sign In
      </button>
    </Form>
  </div>
);

const FormikApp = withFormik({
  mapPropsToValues: ({ email, select, checkbox, radio }) => {
    return {
      email: email || "",
      select: select || "",
      checkbox: checkbox || false,
      radio: radio || "",
    };
  },
  validationSchema: Yup.object().shape({
    select: Yup.string().required("Color is required!"),
    email: Yup.string().email().required("Email is required"),
    checkbox: Yup.bool().oneOf([true], "Checkbox is required"),
    radio: Yup.string().required("Radio is required!"),
  }),
  handleSubmit: (values) => {
    alert(JSON.stringify(values));
  },
})(App);

export default FormikApp;

withFormik is a HOC that injects Formik context into the wrapped component; the options object passed to it defines that context's behavior. Because Formik pairs with Yup, validation rules stay declarative: a schema is passed to validationSchema. mapPropsToValues moves updated field state into the App component as props.values, and handleSubmit takes care of submission.

Custom Components And Dependent Fields

Integrating a UI library is straightforward, as this basic form built around Material UI's TextField shows.

import TextField from "@material-ui/core/TextField";
import * as Yup from "yup";
import { Formik, Form, Field, ErrorMessage } from "formik";

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

export default function SignIn() {
  const classes = useStyles();
  return (
    <Container component="main" maxWidth="xs">
      <div className={classes.paper}>
        <Formik
          initialValues={initialValues}
          validationSchema={SignInSchema}
          onSubmit={(values) => {
            alert(JSON.stringify(values));
          }}
        >
          {({
            errors,
            values,
            handleChange,
            handleBlur,
            handleSubmit,
            touched,
          }) => (
            <Form className={classes.form} onSubmit={handleSubmit}>
              <Field
                as={TextField}
                variant="outlined"
                margin="normal"
                fullWidth
                id="email"
                label="Email Address"
                name="email"
                helperText={<ErrorMessage name="email" />}
              />
              <button type="submit">submit</button>
            </Form>
          )}
        </Formik>
      </div>
    </Container>
  );
}

Field connects inputs to Formik on its own, injecting onChange, onBlur, name, and value into the TextField component, and doing the same for any custom component. The TextField reaches Field through the latter's as prop. Material UI's TextField can also be used directly, an approach covered by an official example.

Dependent fields rely on the render props' values object: the value to track is read from it, and the UI follows. In the snippet, the remember checkbox is tracked and a message rendered according to its state.

 <Field
    name="remember"
    type="checkbox"
    as={Checkbox}
    Label={{ label: "You must accept our terms!!!" }}
      helperText={<ErrorMessage name="remember" />}
    />
    
{values.remember && (
  <p>
    Thank you for accepting our terms. You can now submit the
    form
  </p>
)}

Learning Curve And Bundle Size

The documentation is concise and covers common scenarios, including use with third-party UI libraries, and the community supplies further resources. Formik weighs 44.4kb minified and 13.1kb gzipped.

Formik bundle size
Formik bundle size. (Large preview)

React Hook Form

React Hook Form (RHF) is a lightweight, zero-dependency form library built for React.

npm i react-hook-form
yarn add react-hook-form

Forms are driven by the useForm hook. RHF ships no custom Field component, so the markup uses native HTML inputs.

import { useForm } from "react-hook-form";

const validationSchema = Yup.object().shape({
  select: Yup.string().required("Color is required!"),
  email: Yup.string().email().required("Email is required"),
  checkbox: Yup.bool().oneOf([true], "Checkbox is required"),
  radio: Yup.string().required("Radio is required!"),
});

const onSubmit = (values) => {
  alert(JSON.stringify(values));
};

const App = () => {
  const { errors, register, handleSubmit } = useForm({
    resolver: yupResolver(validationSchema),
  });
  return (
    <div className="container">
      <form onSubmit={handleSubmit(onSubmit)}>
        //email field
        <div className="form-row">
          <label htmlFor="email">Email</label>
          <input type="email" name="email" id="email" ref={register} />
          {errors.email && <p className="error"> {errors.email.message} </p>}
        </div>

        //select field
        <div className="form-row">
          <label htmlFor="email">Select a color to continue</label>
          <select name="select" ref={register}>
            <option value="" label="Select a color" />
            <option value="red" label="red" />
            <option value="blue" label="blue" />
            <option value="green" label="green" />
          </select>
          {errors.select && <p className="error"> {errors.select.message} </p>}
        </div>

        //checkbox field
        <div className="form-row">
          <label htmlFor="checkbox">
            <input type="checkbox" name="checkbox" ref={register} />
            Accept Terms & Conditions
          </label>
          {errors.checkbox && (
            <p className="error"> {errors.checkbox.message} </p>
          )}
        </div>

        //radio field
        <div>
          <label>
            <input type="radio" name="radio" value="Option 1" ref={register} />
            One
          </label>
          <label>
            <input type="radio" name="radio" value="Option 2" ref={register} />
            Two
          </label>
          {errors.radio && <p className="error"> {errors.radio.message} </p>}
        </div>
        <button type="submit">Sign In</button>
      </form>
    </div>
  );
};

Yup and other validation schemas are supported through the separate @hookform/resolvers package.

npm i @hookform/resolvers

Yup becomes the validator once yupResolver is passed to the resolver property of the useForm configuration object. The hook also exposes an errors object plus the handleSubmit and register methods, among others.

register wires an input to RHF through its ref prop; passing it as ref on every field RHF should watch keeps the form performant and avoids unnecessary re-renders. handleSubmit runs its handler only when the form has no errors, and errors holds the per-field error state.

Custom components and dependent fields

External UI libraries integrate cleanly when the component exposes a ref. Material-UI and Reactstrap TextField both expose inputRef, so register can be passed straight to it.

import TextField from "@material-ui/core/TextField";

export default function SignIn() {
  return (
    <Container component="main" maxWidth="xs">
      <div className={classes.paper}>
        <form className={classes.form}>
          <TextField
            inputRef={register}
            id="email"
            label="Email Address"
            name="email"
            error={!!errors.email}
            helperText={errors?.email?.message}
          />
          <Button type="submit">Sign In</Button>
        </form>
      </div>
    </Container>
  );
}

Where no inputRef is exposed, RHF's Controller component takes over.

import { useForm, Controller } from "react-hook-form";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import Checkbox from "@material-ui/core/Checkbox";

export default function SignIn() {
  const { control } = useForm()

  return (
    <Container component="main" maxWidth="xs">
      <div className={classes.paper}>
        <form>
          <FormControlLabel
            control={
              <Controller
                control={control}
                name="remember"
                color="primary"
                render={(props) => (
                  <Checkbox
                    checked={props.value}
                    onChange={(e) => props.onChange(e.target.checked)}
                  />
                )}
              />
            }
            label="Remember me"
          />
          <Button type="submit"> Sign In </Button>
        </form>
      </div>
    </Container>
  );
}

Controller, imported from RHF and paired with the control object from useForm, wraps a custom component and propagates props down to it. Its render prop returns a React element and supplies onChange, onBlur, name, ref and value to the wrapped component.

For fields rendered conditionally on another field's value, the watch API tracks an input's current value.

export default function SignIn() {
  const { watch } = useForm()
  const terms = watch("remember");

  return (
    <Container component="main" maxWidth="xs">
      <div className={classes.paper}>
        <form>
            //other fields above
          {terms && <p>Thank you for accepting our terms. You can now submit the form</p>}
          <Button type="submit"> Sign In </Button>
        </form>
      </div>
    </Container>
  );
}

RHF is 26.4kb minified and 9.1kb gzipped.

React Hook Form bundle size
React Hook Form bundle size. (Large preview)

React Final Form

Final Form is framework-agnostic; React Final Form (RFF) is Erik Rasmussen's React wrapper for it.

npm i final-form react-final-form
yarn add final-form react-final-form

RFF does not support object-schema validation such as Yup out of the box, so validation is written by hand.

import { Form, Field } from "react-final-form";

export default function App() {
  return (
    <div className={styles.container}>
      <Head>
        <title>React Final Form</title>
      </Head>
      <Form
        onSubmit={onSubmit}
        validate={validate}
        render={({ handleSubmit }) => (
          <form onSubmit={handleSubmit}>

            //email field
            <Field name="email">
              {({ input, meta }) => (
                <div className={styles.formRow}>
                  <label>Email</label>
                  <input {...input} type="email" placeholder="Email" />
                </div>
              )}
            </Field>

            //select field
            <Field name="select" component="select">
              {({ input, meta }) => (
                <div className={styles.formRow}>
                  <label htmlFor="select">Select a color to continue</label>
                  <select {...input}>
                    <option value="" label="Select a color" />
                    <option value="red" label="red" />
                    <option value="blue" label="blue" />
                    <option value="green" label="green" />
                  </select>
                </div>
              )}
            </Field>

            //checkbox field
            <Field name="checkbox">
              {({ input, meta }) => (
                <div className={styles.formRow}>
                  <label>
                    <input {...input} name="checkbox" type="checkbox" />
                    Accept Terms & Conditions
                  </label>
                </div>
              )}
            </Field>

            //radio field
            <div className={styles.formRow}>
2              <Field
                name="radio"
                component="input"
                type="radio"
                value="Option 1"
              >
                {({ input, meta }) => (
                  <div>
                    <label>
                      One
                      <input {...input} type="radio" value="Option 1" />
                    </label>
                  </div>
                )}
              </Field>
              <Field
                name="radio"
                component="input"
                type="radio"
                value="Option 2"
              >
                {({ input, meta }) => (
                  <div>
                    <label>
                      Two
                      <input {...input} type="radio" value="Option 2" />
                    </label>
                  </div>
                )}
              </Field>
            </div>
            <button type="submit" className={"disabled-btn"}>
              Sign In
            </button>
          </form>
        )}
      />
    </div>
  );
}

The Form component wraps the form and manages its state. Its main props are onSubmit, validate and render. Inputs are rendered inside render, which exposes the FormState object carrying handleSubmit and other form-state properties.

RFF provides its own Field component, similar to Formik's. It registers the input, subscribes to that input's state, and injects field state plus the onBlur, onChange and onFocus callbacks through a render prop.

const onSubmit = (values) => {
  alert(JSON.stringify(values));
};

const validate = (values) => {
  const errors = {};
  if (!values.email) {
    errors.email = "Email is Required";
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
    errors.email = "Invalid emaill address";
  }
  if (!values.checkbox) {
    errors.checkbox = "You must accept our terms";
  }
  if (!values.select) {
    errors.select = "Select is required";
  }
  if (!values.radio) {
    errors.radio = "You must accept our terms";
  }
  return errors;
};

export default function App() {
  return (
    <div className={styles.container}>
      <Form
        onSubmit={onSubmit}
        validate={validate}
        render={({ handleSubmit }) => (
          <form onSubmit={handleSubmit}>
            <Field name="email">
              {({ input, meta }) => (
                <div className={styles.formRow}>
                  <label>Email</label>
                  <input {...input} type="email" placeholder="Email" />
                  {meta.error && meta.touched && (
                    <span className={styles.error}>{meta.error}</span>
                  )}
                </div>
              )}
            </Field>
            <Field name="select" component="select">
              {({ input, meta }) => (
                <div className={styles.formRow}>
                  <label htmlFor="select">Select a color to continue</label>
                  <select {...input}>
                    <option value="" label="Select a color" />
                    <option value="red" label="red" />
                    <option value="blue" label="blue" />
                    <option value="green" label="green" />
                  </select>
                  {meta.error && meta.touched && (
                    <span className={styles.error} style={{ display: "block" }}>
                      {meta.error}
                    </span>
                  )}
                </div>
              )}
            </Field>
            <Field name="checkbox">
              {({ input, meta }) => (
                <div className={styles.formRow}>
                  <label>
                    <input {...input} name="checkbox" type="checkbox" />
                    Accept Terms & Conditions
                  </label>
                  {meta.error && meta.touched && (
                    <span className={styles.error}>{meta.error}</span>
                  )}
                </div>
              )}
            </Field>
            <div className={styles.formRow}>
              <Field
                name="radio"
                component="input"
                type="radio"
                value="Option 1"
              >
                {({ input, meta }) => (
                  <div>
                    <label>
                      One
                      <input {...input} type="radio" value="Option 1" />
                    </label>
                  </div>
                )}
              </Field>
              <Field
                name="radio"
                component="input"
                type="radio"
                value="Option 2"
              >
                {({ input, meta }) => (
                  <div>
                    <label>
                      Two
                      <input {...input} type="radio" value="Option 2" />
                    </label>
                    {meta.error && meta.touched && (
                      <span className={styles.error}>{meta.error}</span>
                    )}
                  </div>
                )}
              </Field>
            </div> 
            <button type="submit" className={"disabled-btn"}>
              Sign In
            </button>
          </form>
        )}
      />
    </div>
  );
}

The validate function holds the form's custom validation rules. onSubmit receives the form values once the user submits and all validation passes. Validation runs on input change by default; a validateonBlur prop on Form adds validation on blur as well.

Errors surface through the meta object, which exposes the metadata and state of each input. Its touched and error properties indicate whether a field has been touched and whether it has an error; both are part of the Field component's props. Errors are displayed when a field is touched and carries an error.

Custom components and dependent fields

Custom inputs are handled through the Field render prop, which gives access to each field's input and meta.

const onSubmit = (values) => {...};
const validate = (values) => {...};
export default function App() { 
  return (
      <Form
        onSubmit={onSubmit}
        validate={validate}
        render={({ handleSubmit }) => (
          <form onSubmit={handleSubmit}>
            <Field name="email" placeholder="email" validate={validate}>
              {({ input, meta }) => (
                <div>
                  <TextField label="email" type="email" {...input} />
                  {meta.error && meta.touched && <span>{meta.error}</span>}
                </div>
              )}
            </Field>
            <Button type="submit"> Sign In </Button>
          </form>
        )}
      ></Form>
    );
  }
}

The input prop bundles everything a custom input needs — name, onBlur, onChange, onFocus and value — and is spread onto the component, such as Material UI's TextField. Any component you plug in must support these props to work with RFF.

Material UI's TextField can alternatively be rendered directly through RFF's Field component, though that route gives no access to the input and meta data.

///other form stuff above
<Field
  name="email"
  component={TextField}
  type="email"
  label="Email"
/>

//we won’t be able to access the input 
//and meta data using this method.

///other form stuff below

Dependent rendering relies on the values object from the render prop: read the state of the remember field and, when it is true, render whatever the application requires.

<Form
    onSubmit={onSubmit}
    render={({ handleSubmit, values }) => (
      <form onSubmit={handleSubmit}>
        <Field name="remember" type="checkbox">
          {({ input }) => (
            <div>
              <label>Remember me</label>
              <input {...input} type="checkbox" />
            </div>
          )}
        </Field>
        {values.remember && (
          <p>Thank you for accepting our terms. You can now submit the form.</p>
        )}
        <button type="submit"> Submit</button>
      </form>
    )}
  />

RFF is 8.9kb minified and 3.2kb gzipped. It has fewer learning resources than the other libraries, and the documentation does not cover Yup or other validation schemas in detail.

React Final Form bundle size
React Final Form bundle size. (Large preview)

Unform

Unform’s API is built around one idea: hooking inputs to the library is meant to be simple. This was once handled by built-in input components, but Unform no longer follows that pattern, so every field you want tracked has to be registered yourself through the registerField method.

npm i @unform/web @unform/core
yarn add @unform/web @unform/core

Registering fields

Registration is the first step. The useField hook sits at the center of the library and returns fieldName, defaultValue, registerField, error, and more. A unique name identifies each field, defaultValue sets its starting value, registerField registers it with Unform — accepting name, ref (the field reference) and getValue (a function returning the field’s value) — and error holds the message for the registered input.

import { useEffect, useRef } from "react";
import { useField } from "@unform/core";

const Input = ({ name, label, ...rest }) => {
  const inputRef = useRef();
   const {
    fieldName,
    defaultValue,
    registerField,
    error,
    clearError
  } = useField(name);  

  useEffect(() => {
    registerField({
      name: fieldName,
      ref: inputRef.current,
      getValue: (ref) => {
        return ref.value;
      }
    });
  }, [fieldName, registerField]);
  return (
    <>
      <label htmlFor={fieldName}>{label}</label>
      <input
        id={fieldName}
        ref={inputRef}
        onFocus={clearError}
        defaultValue={defaultValue}
        {...rest}
      />
      {error && <span className="error">{error}</span>}
    </>
  );
};
export default Input;

When an input component loads, the registerField call goes inside useEffect. The returned methods and props — fieldName, defaultValue, clearError and the rest — are then spread onto the input component. clearError removes an existing error from a field on focus. Selects, checkboxes and radios are registered the same way.

Wiring the form

With fields registered, the Form component is imported from Unform and given a form reference, which exposes a set of helper methods.

import React, { useRef } from "react";
import { Form } from "@unform/web";
import * as Yup from "yup";
import Input from "./Input";
import Radio from "./Radio";
import Checkbox from "./Checkbox";
import Select from "./Select";

const radioOptions = [
  { value: "option 1", label: "One" },
  { value: "option 2", label: "Two" }
];
const selectOptions = [
  { value: "", label: "Select a color" },
  { value: "red", label: "Red" },
  { value: "blue", label: "Blue" },
  { value: "green", label: "Green" }
];

const App = () => {
  const formRef = useRef();
  async function handleSubmit(data) {
   //form validation goes here  
  }
  return (
    <div className="container">
      <Form ref={formRef} onSubmit={handleSubmit}>
        <div className="form-row">
          <Input name="email" label="Email" type="email" />
        </div>
        <div className="form-row">
          <Select
            name="select"
            label="Select a color to continue"
            options={selectOptions}
          />
        </div>
        <div className="form-row">
          <Checkbox name="checkbox" label="Accept terms and conditions" />
        </div>
        <div>
          <Radio name="radio" options={radioOptions} />
        </div>
        <button className="button" type="submit">
          Sign in
        </button>
      </Form>
    </div>
  );
};
export default App;

Validation

Unform validates through Yup, so validation starts with a schema. Because Yup’s validate() rejects the promise at the first error and skips the remaining fields, pass { abortEarly: false }. The setErrors method on the form reference stores any resulting errors.

import React, { useRef } from "react";

const validationSchema = Yup.object().shape({
  select: Yup.string().required("Color is required!"),
  email: Yup.string().email().required("Email is required"),
  checkbox: Yup.bool().oneOf([true], "Checkbox is required"),
  radio: Yup.string().required("Radio is required!")
});

const App = () => {
  const formRef = useRef();
  async function handleSubmit(data) {
    try {
      await validationSchema.validate(data, {
        abortEarly: false
      });
      // Validation passed - do something with data
      alert(JSON.stringify(data));
    } catch (err) {
      const errors = {};
      // Validation failed - do show error
      err.inner.forEach((error) => {
        errors[error.path] = error.message;
      });
      formRef.current.setErrors(errors);
    }
  }

  return (
    <div className="container">
      <Form ref={formRef} onSubmit={handleSubmit}>
    //other form stuffs below
}

Validation can also run as the user types through a handleChange function built alongside the submit handler.

import { useRef, useState } from "react";

export default function App() {
  const [form, setForm] = useState({
    name: ""
  });

  const formRef = useRef(null);

  async function handleSubmit(data) {
    //handleSubmit logic
  }
  const handleNameChange = async ({ target: { value } }) => {
    setForm({
      ...form,
      name: value
    });
    try {
      formRef.current.setErrors({});
      let schema = Yup.object().shape({
        name: Yup.string()
          .required()
          .max(20)
          .matches(
            /^[a-zA-Z]+(([',. -][a-zA-Z ])?[a-zA-Z]*)*$/,
            "Please enter valid name"
          )
      });
      await schema.validate(form, {
        abortEarly: false
      });
      console.log(form);
    } catch (err) {
      console.log(err);
      const validationErrors = {};
      if (err instanceof Yup.ValidationError) {
        err.inner.forEach((error) => {
          validationErrors[error.path] = error.message;
        });
        formRef.current.setErrors(validationErrors);
      }
    }
  };
  return (
    <div className="container">
      <Form ref={formRef} noValidate onSubmit={handleSubmit}>
        <div className="form-row">
          <Input name="name" label="Full name" onChange={handleNameChange} />
        </div>
        <button type="submit" className="button">
          Save
        </button>
      </Form>
    </div>
  );
}

The two functions share the same logic, with one difference: submit validation receives the form data through handleSubmit, while handleNameChange needs a form state to hold the input value. That state is updated on every keystroke via setForm, and the value is then validated exactly as in handleSubmit; handleNameChange is finally passed to the input’s onChange prop. In the example the two handlers use different schemas so that the onChange and onSubmit errors differ — in a real project you would reuse one schema.

Custom components, dependent fields and trade-offs

Third-party UI components integrate the same way as native HTML inputs, through registerField.

import TextField from "@material-ui/core/TextField";

const MaterialUIInput = ({ name, label, ...rest }) => {
  const inputRef = useRef();
  const { fieldName, defaultValue, registerField, error } = useField(name);
  useEffect(() => {
    //form registration here
  }, [fieldName, registerField]);
  return (
    <>
      <TextField
        inputRef={inputRef}
      />

Watching form state is not currently supported, so dependent fields are not possible; a useWatch hook for tracking form state is listed on the library’s roadmap.

Unform has the steepest start of the libraries covered. Registering each input is less developer-friendly than the alternatives, reading the errors object is tedious, and form values cannot be accessed for dependent fields. It ships with fewer of the features form work tends to require, and few resources demonstrate different use cases, though the documentation includes some examples. The library is still under development, and the team is working on new features. It is 10.4kb minified and 3.7kb gzipped.

Unform bundle size
Unform bundle size. (Large preview)

Multi-step forms

The multi-step demos were left to the end because the implementation stays similar across libraries; only the wizard UI differs.

├── components
|  ├── FormCard.js
|  ├── FormCompleted.js
|  └── Forms
|     ├── BillingInfo.js
|     ├── ConfirmPurchase.js
|     ├── index.js
|     └── PersonalInfo.js
├── context
|  └── index.js
├── pages
|  ├── api
|  |  └── hello.js
|  ├── index.js
|  └── _app.js
└── styles
   ├── globals.css
   └── styles.module.scss

The wizard begins in App.js.

const App = () => {
  const [formStep, setFormStep] = useState(0);
  const nextFormStep = () => setFormStep((currentStep) => currentStep + 1);
  const prevFormStep = () => setFormStep((currentStep) => currentStep - 1);
  return (
    <div className={styles.container}>
      <Head>
        <title>Next.js Multi Step Form</title>
      </Head>
      <h1>Formik Multi Step Form</h1>
      <FormCard currentStep={formStep} prevFormStep={prevFormStep}>
        {formStep >= 0 && (
          <PersonalInfo formStep={formStep} nextFormStep={nextFormStep} />
        )}
        {formStep >= 1 && (
          <BillingInfo formStep={formStep} nextFormStep={nextFormStep} />
        )}
        {formStep >= 2 && (
          <ConfirmPurchase formStep={formStep} nextFormStep={nextFormStep} />
        )}
        {formStep > 2 && <FormCompleted />}
      </FormCard>
    </div>
  );
};

A formStep state holds the current step, and prevFormStep and nextFormStep move between steps. FormCard receives formStep and prevFormStep, while the form components receive formStep and nextFormStep. Each form renders conditionally on formStep, and the comparison uses >= so a form stays mounted after its step has passed, which makes tracking values easier.

export default function FormCard({ children, currentStep, prevFormStep }) {
  return (
    <div className={styles.formCard}>
      {currentStep < 3 && (
        <>
          {currentStep > 0 && (
            <button
              className={styles.back}
              onClick={prevFormStep}
              type="button"
            >
              back
            </button>
          )}
          <span className={styles.steps}>Step {currentStep + 1} of 3</span>
        </>
      )}
      {children}
    </div>
  );
}

FormCard hides the back button depending on formStep, shows the current step as a progress indicator, and renders its children — the form on screen.

import { useFormData } from "../../context";

export default function PersonalInfo({ formStep, nextFormStep }) {
  const { setFormValues } = useFormData();
  const handleSubmit = (values) => {
    setFormValues(values);
    nextFormStep();
  };
  return (
    <div className={formStep === 0 ? styles.showForm : styles.hideForm}>
      <h2>Personal Info</h2>
      <form>
        <div className={styles.formRow}>
          <label htmlFor="email">Email</label>
          <input type="email" name="email" id="email" />
        </div>
        <button type="button" onClick={nextFormStep}>
          Next
        </button>
      </form>
    </div>
  );
}

The form components are structured alike. In the root div, showForm and hideForm styles are applied conditionally, which is what hides the already-mounted steps described above.

<FormCard currentStep={formStep} prevFormStep={prevFormStep}>
        {formStep >= 0 && (
          <PersonalInfo formStep={formStep} nextFormStep={nextFormStep} />
        )}
       //other forms below

Submission depends on a context that stores each form’s values.

import { useState, createContext, useContext } from "react";
export const FormContext = createContext();

export default function FormProvider({ children }) {
  const [data, setData] = useState({});
  const setFormValues = (values) => {
    setData((prevValues) => ({
      ...prevValues,
      ...values,
    }));
  };
  return (
    <FormContext.Provider value={{ data, setFormValues }}>
      {children}
    </FormContext.Provider>
  );
}

export const useFormData = () => useContext(FormContext);

setFormValues takes the data from a form and updates the data state that holds the values of every form. Both the data and setFormValues come from useFormData.

import { useFormData } from "../../context";

export default function PersonalInfo({ formStep, nextFormStep }) {
  const { setFormValues } = useFormData();
  const handleSubmit = (values) => {
    setFormValues(values);
    nextFormStep();
  };

Each form pulls setFormValues from useFormData and passes its values in, so data accumulates as each step is submitted. Once the form has been filled successfully, FormCompleted renders.

export default function FormCompleted() {
  return <h2>Thank you for your purchase! 🎉</h2>;
}

The wizard is the only new part: validation and submission per library are the same as described above, and sandbox demos cover the integration with each library.

Summary

Across these libraries the deciding factors vary, and the table below sets them side by side.

Comparison summary table
Comparison summary table. (Large preview)

In practice, I reach for either Formik or React Hook Form. They are the most popular options, with the clearest and most extensive documentation and the most learning resources in videos and articles.

Resources

Smashing Editorial