Comparing React Form Libraries: SurveyJS, Formik, React Hook Form, React Final Form And Unform

About The Author

Nefe is a Frontend Developer who enjoys learning new things and sharing his knowledge with others. More about Nefe ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

In handling forms in React, you can either set up a custom solution or reach out to one of the many form libraries available. In this article, we compare some React Libraries: SurveyJS, Formik, React Hook Form, React Final Form, and Unform.

Working with user input has always been one of the most vital parts of developing any website. Handling things like form validation, submission, and displaying errors can become complex, so using existing form solutions may be the way to go, and there are several solutions for dealing with forms in React.

In this article, we will look at SurveyJS, Formik, React Hook Form, React Final Form and Unform. We will compare how they are used, how we can integrate them into custom UI components, and how to set up dependent fields with them. Along the way, we will also learn how to validate forms using Yup, a JavaScript object schema validator.

This article is useful for those who want to know the best form libraries to use for future applications.

Note: This article requires a basic understanding of React and Yup.

Should You Use A Form Library?

Forms are an integral part of how users interact with the web. They are used to collect data for processing from users, and many websites today have one or more forms. While forms can be handled in React by making them controlled components, it can become tedious with a lot of repetitive code if you build a lot of forms. You have an option of reaching out to one of the many form libraries that exist in the React ecosystem. These libraries make it easier to build forms of varying complexity, as they provide validation and state management out of the box, among other useful form-handling features.

Factors To Be Considered

It is one thing to know the different form libraries available, and another to know the appropriate one to use for your next project. In this article, we will examine how these form libraries work and compare them based on the following factors:

  • Implementation
    We will look at their APIs, and consider how easy it is to integrate them into an app, handle form validation, submission, and the overall developer experience.
  • Usage with Custom Components
    How easy is it to integrate these libraries with inputs from UI libraries like Material UI?
  • Dependent Fields
    You may want to render a form field B that depends on the value of a field A. How can we handle that use case with these libraries?
  • Learning Curve
    How quickly can you start using these forms? How much learning resources and examples are available online?
  • Bundle Size
    We always want our applications to be performant. What tradeoffs are there in terms of the bundle size of these forms?

We will also consider how to make multi step forms using these libraries. I didn’t add that to the list above due to how I structured the article. We will look at that at the end of the article.

React Form Library by SurveyJS

SurveyJS Form Library is an open-source client-side component that renders dynamic JSON-driven forms in React applications. It uses JSON objects to communicate with the server. These objects, also known as JSON schemas, define various aspects of a form, including its style, contents, layout, and behavior in response to user interactions, such as data submission, input validation, error messages, and so on.

The library has native support for React. It is free to use and is distributed under the MIT license. The SurveyJS product family also includes a self-hosted JSON form builder that features drag-and-drop UI, a CSS Theme Editor, and a GUI for conditional logic and form branching.

Features

How To Install

npm install survey-react-ui --save

How To Use

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;

SurveyJS Form Library for React consists of two npm packages: survey-core (platform-independent code) and survey-react-ui (rendering code). Run the npm install survey-react-ui --save command to install survey-react-ui. The survey-core package will be installed automatically as a dependency. Another advantage of SurveyJS is its seamless integration with custom UI libraries and their form components. This dedicated guide demonstrates how to integrate the React Color component to a basic SurveyJS form.

To add SurveyJS themes to your application, open the React component that will render a form and import the Form Library style sheet import 'survey-core/defaultV2.min.css';. This style sheet applies the Default theme. You can also apply a different predefined theme or create a custom one.

Next, you need to create a model that describes the layout and contents of a form. Models are specified by model schemas (JSON objects). SurveyJS website offers a full-featured JSON form builder demo that you can use to generate JSON schemas for your forms. In this example, model schema declares two textual questions, each with a title and a name. Titles are visible to respondents, while names are used to identify the questions in code. To instantiate a model, pass the model schema to the Model constructor as shown in the code above.

To render a form, import the Survey component, add it to the template, and pass the model instance you created in the previous step to the component’s model attribute.

As a result, you should see the following form:

View Full Source Code on GitHub

Formik

Formik is a flexible library. You can choose to use Formik with native HTML elements or with Formik’s custom components. You also have the option of setting up your form validation rules or a third-party solution like Yup. It allows you to decide when and how much you want to use it. We can control how much functionality of the Formik library we use.

Formik takes care of the repetitive and annoying stuff — keeping track of values, errors, visited fields, orchestrating validation, and handling submission — so you don’t have to. This means you spend less time setting up form state and onChange and onBlur handlers.

Installation

npm i formik
yarn add formik

Implementation

Formik keeps track of your form’s state and then exposes it plus a few reusable methods and event handlers (handleChange, handleBlur, and handleSubmit) to your form via props. You can find out more about the methods available in Formik here.

While Formik can be used alongside HTML’s native input fields, Formik comes with a Field component that you can use to determine the input field you want, and an ErrorMessage component that handles displaying the error for each input field. Let’s see how these work in practice.

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>
);

In the code above, we are working with four input fields, an email, a select, a checkbox, and a radio field. Form is a small wrapper around an HTML <form> element that automatically hooks into Formik’s handleSubmit and handleReset. We will look into what withFormik does next.

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 within the wrapped component. We can pass an options object into withFormik where we define the behaviour of the Formik context.

Formik works well with Yup in handling the form validation, so we don’t have to set up custom validation rules. We define a schema for validation pass it to validationSchema. With mapPropsToValues, Formik transfers the updated state of the input fields and makes the values available to the App component through props as props.values. The handleSubmit function handles the form submission.

Usage With Custom Components

Another benefit of Formik is how straightforward it is to integrate custom UI libraries and their form components. Here, we set up a basic form using Material UI’s TextField component.

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 hooks up inputs to Formik automatically. Formik injects onChange, onBlur, name, and value props to the TextField component. It does the same for any type of custom component you decide to use. We pass TextField to Field through it’s as prop.

We can also use Material UI’s TextField component directly. The docs also provide an example that covers that scenario.

Dependent Fields

To set up dependent fields in Formik, we access the input’s value we want to track through the values object in the render props. Here, we are tracking the remember field, which is the checkbox, and rendering a message based on the state of the field.

 <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

Formik’s docs are easy to understand and straight to the point. It covers several use cases, including how to use Formik with third-party UI libraries like Material UI. There are also several resources from the Formik community to aid your learning.

Bundle Size

Formik is 44.4kb minified and 13.1kb gzipped.

Formik bundle size
Formik bundle size. (Large preview)

React Hook Form

React Hook Form, or RHF is a lightweight, zero-dependency, and flexible form library built for React.

Installation

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

Implementation

RHF provides a useForm hook which we can use to work with forms.

We start by setting up the HTML input fields we need for this form. Unlike Formik, RHF does not have a custom Field component, so we will use HTML’s native input fields.

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>
  );
};

RHF supports Yup and other validation schemas. To use Yup with RHF, we need to install the @hookform/resolvers package.

npm i @hookform/resolvers

Next, we have to configure the RHF setup and instruct it to use Yup as the form validator. We do so through the resolver property useForm hook’s configuration. object. We pass in yupResolver, and now RHF knows to use Yup to validate the form.

The useForm hook gives us access to several form methods and properties like an errors object, and the handleSubmit and register methods. There are other methods we can extract from useForm. You can find the complete list of methods.

The register function connects input fields to RHF through the input field’s ref prop. We pass the register function as a ref into each element we want RHF to watch. This approach makes the forms more performant and avoids unnecessary re-renders.

The handleSubmit method handles the form submission. It will only run if there are no errors in the form.

The errors object contains the errors present in each field.

Usage With Custom Components

RHF has made it easy to integrate with external UI component libraries. When using custom components, check if the component you wish to use exposes a ref. If it does, you can use it like you would native HTML form elements. However, if it doesn’t you will need to use RHF’s Controller component.

Material-UI and Reactstrap’s TextField expose their inputRef, so you can pass register 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>
  );
}

In a situation where the custom component’s inputRef is not exposed, we have to use RHF’s Controller component.

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>
  );
}

We import the Controller component from RHF and access the control object from the useForm hook.

Controller acts as a wrapper that allows us to use custom components in RHF. Any prop passed into Controller will be propagated down to the Checkbox.

The render prop function returns a React element and provides the ability to attach events and value into the component. This simplifies integrating RHF with custom components. render provides onChange, onBlur, name, ref, and value to the custom component.

Dependent Fields

In some situations, you may want to render a secondary form field based on the value a user puts in field a primary form field. RHF provides a watch API that enables us to track the value of am input field.

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>
  );
}

Learning Curve

Asides from its extensive and straightforward documentation that covers several use cases, RHF is a very popular React Library. This means there are several learning resources to get you up and running.

Bundle Size

RHF is 26.4kb minified and 9.1kb gzipped.

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

Final Form

Final Form is a framework-agnostic form library. However, its creator, Erik Rasmussen, created a React wrapper for Final Form, React Final Form.

Installation

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

Implementation

Unlike Formik and React Hook Form, React Final Form (RFF) does not support validation with Object Schemas like Yup out of the box. This means you have to set up validation yourself.

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 is a special wrapper provided by RFF that manages the state of the form. The main props when using RFF are onSubmit, validate, and render. You can get more details on the form props RFF works with.

We start by setting up the necessary input fields. render handles the rendering of the form. Through the render props, we have access to the FormState object. It contains form methods like handleSubmit, and other useful properties regarding the state of the form.

Like Formik, RFF has its own Field component for rendering input fields. The Field component registers any input field in it, subscribes to the input field’s state, and injects both field state and callback functions, onBlur, onChange, and onFocus via a render prop.

Unlike Formik and RHF, RFF does not provide support for any validation Schema, so we have to set up custom validation rules.

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 handles the validation for the form. The onSubmit function will be called with the values of your form when the user submits the form and all validation passes. Validation runs on input change by default. However, we can also pass a validateonBlur prop to the Form component validation so it also runs on blur.

To display the validation errors, we make use of the meta object. We can get access to the metadata and state of each input field through the meta object. We can find out if the form has been touched or has any errors through the meta’s touched and error properties respectively. These metadata are part of the props of the Field component. If the input fields have been touched, and there is an error for we display that error.

Usage With Custom Components

Working with custom input components in RFF is straightforward. Using the render props method in the Field component, we can access the input and meta of each input field.

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>
    );
  }
}

RFF’s Field component bundles all of the props that your input component needs into one object prop, called input, which contains name, onBlur, onChange, onFocus, and value. The input prop is what we spread to the TextField component. The custom form component you plan on using must support these props in order to be compatible with RFF.

Alternatively, we could render Material UI’s TextField using the component Field in RFF. However, we won’t be able to access the input and meta data using this method.

///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 Fields

The values object can be accessed from the render prop. From here, we can track the state of the remember field, and if true, render a message, or whatever use case fits your app’s needs.

<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>
    )}
  />

Learning Curve

Compared to other form libraries, RFF does not have as many learning resources. Also, the docs do not go into detail to show how RFF can be used with Yup or any other validation schema, and that would be a helpful addition.

Bundle Size

RFF is 8.9kb minified and 3.2kb gzipped.

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

Unform

A core aspect of Unform’s API design is how straightforward it is to hook form inputs to Unform. Initially, Unform came with its built-in input components, however, it no longer follows that pattern. This means you have to register each field you want Unform to track. We can do that with the registerField method Unform provides.

Installation

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

Implementation

The first step in using Unform is registering the input fields we want the library to track.

The useField hook is the heart of Unform. From this hook, we get access to the fieldName, defaultValue, registerField, error, and more. Let’s see what they do and how they work.

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;
  • fieldName: a unique field name.
  • defaultValue: the default value of the field.
  • registerField: this method is used to register a field on Unform. When registering a field, you can pass some properties to the registerField method.
  • error: the error message of the registered input field.

Whenever the input component loads, we call the registerField method in the useEffect to register the input. registerField accepts some options:

  • name: the name of the field that needs to be registered.
  • ref: the reference to the field.
  • getValue: this function returns the value of the field.

We pass the methods; fieldName, defaultValue, clearError and any other props to the input component. clearError clears the error of an input field on focus if there is any. This is how we register input fields with Unform. We do the same thing for the select, checkbox, and radio input fields.

Now that we have registered the input fields, we have to bring everything together.

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;

We import the Form component from Unform and set up a form reference for the Form component. We get access to several helpful methods from this reference.

Now that we’ve registered the input fields, created a form reference, and set up the form, the next step is to handle the form validation and submission.

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
}

Unform supports validation with Yup, so we create a schema. By default, the schema’s validate() method will reject the promise as soon as it finds the error and won’t validate any further fields. So to avoid that you need to pass the abortEarly option and set the boolean to false { abortEarly: false }. We use the setErrors method from the form reference we created to set the errors for the form if any.

Similar to the handleSubmit function that handles submit validation, a handleChange function can be created that will handle form validation as the user types.

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>
  );
}

Both validation functions work the same way, so the logic for the submit and onChange validation are the same. However, there are some differences. Unlike validation on submit, where we can get access to and validate the form data with handleSubmit, we need a way to handle the input’s data in handleNameChange. To do that, we set up a form state where we will store the input’s value. Then as the user types, we update the form state through setForm. Now that we have access to the form data, we validate it in the same manner we did in handleSubmit. Lastly, we have to pass handleNameChange to the input’s onChange prop, which we do.

You will note that in handleNameChange, I created a different validation schema than the one I used in handleSubmit. I did this so there will be two different errors to make the onChange and onSubmit error different. However, in a real-world project, you would use the same validation schema.

The sandbox below provides a demo for validation on input change.

Usage with Custom Components

We integrate third-party UI components with Unform the same way we do with native HTML inputs, through the registerField method.

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}
      />

Dependent Fields

Unform currently does not provide any watch functionality to help in creating dependent fields. However, there are plans in the library’s roadmap to create a useWatch hook for tracking the form state.

Learning Curve

Unform is not the easiest library to get started with. The process of registering input fields is not developer-friendly compared to other articles. Also, validation and accessing errors object in Unform is more tedious than it should be. It also doesn’t help that there is no way to access form values to set up dependent fields. It doesn’t come with a lot of features that would be needed when working with forms, and there are very few resources out there that show different use cases in using Unform. However, the documentation provides a few examples.

Overall, it is not the most straightforward library to use. I believe more work can be done in providing a better documentation.

With that being said, Unform is still under development, and the team is currently working on new features.

Bundle Size

Unform is 10.4kb minified and 3.7kb gzipped.

Unform bundle size
Unform bundle size. (Large preview)

Creating Multi Step Forms

I have created multi-step form demos for each library. I left this to the last section because the implementation for the libraries remains similar. The only different thing is the multi-step form UI. Let’s see the structure of the UI.

Breaking Down The Form Wizard

For the multi step wizard, let’s see the file structure and how it works.

├── 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

Let’s look at the App.js file.

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>
  );
};

Here, we define a formStep state, which holds the state of the current step of the form wizard. We also define prevFormStep and nextFormStep functions to go back and forth in the form wizard.

Next, we pass the formStep state prevFormStep to the FormCard. This will enable us to go a step backward and also display the current step of the form.

Finally, we pass formStep and nextFormStep to the form components. We conditionally render each form based on the value of formStep. We use >= to render the forms because we want the forms to remain rendered even though it’s step has been passed. This makes tracking the form values easier.

Now the FormCard component. This is the container for each form.

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 does 3 things: conditionally render the back button based on the value of formStep, show the value of the current step to the user as a form of progress tracker, and render its children, which is the current form being displayed.

The form components have a similar structure. Let’s see PersonalInfo.

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>
  );
}

In the root div, we conditionally apply showForm and hideForm styles to show or hide the form. We do this because of how we implemented the form wizard.

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

We display a particular form based on the value of formStep. However, we use the >= conditional to conditionally render a form. This means that all the forms will render as formStep increases. We want them to render, but also be hidden. That’s why we conditionally apply the showForm and hideForm styles.

Finally, we have the handleSumbit. Let’s look into how that works.

Handling form submission starts with creating a context to store the values of the forms.

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 is a function that takes the data from each form and uses those values to update the state of data, which will hold the values of each form.

We can access the form data and the setFormValues function from useFormData.

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

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

In each form, we pull setFormValues from useFormData and pass in the values of the form. This way, as we submit each form, the data is being stored.

Finally, if the form has been successfully filled, the FormCompleted component renders.

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

The core aspect of creating the multi-step form is the wizard. The form validation and submission for each library are the same as what we covered above. I attached these sandbox demos for integration with each library.

Summary

In comparing these 4 form libraries, we have considered different factors. The image below is a tabular representation of how these libraries stand against each other.

Comparison summary table
Comparison summary table. (Large preview)

I use either Formik or RHF in handling forms in my projects. These are my top choices because they are the most popular, have the clearest and most extensive documentation, and the most learning resources in terms of YouTube videos and articles.

Conclusion

Forms will remain a critical part of how users interact with the web, and these libraries, among others, have done a good job in creating form management solutions for the common developer use cases, and much more.

Resources

Smashing Editorial (ks, vf, yk, il)