Do you really need a form library?

Simon MacDonald’s avatar

by Simon MacDonald
@macdonst
on

hero image Photo by Kelly Sikkema on Unsplash

Filling out forms is one of the backbones of web applications, and there is a myriad of form libraries that help you manage this task but do you really need them?

Defining our form

Our use case is going to be a straightforward contact form where we collect users:

  • First name
  • Last name
  • Age
  • Email address
  • Phone number

form

All of the fields in the form will be required. The age field must be a number and 18 or great. The email address and phone number fields must also follow special validation rules.

Library Selection

We’ll make the assumption that you are using React to build the application, and there is an abundance of form libraries to choose from, but we’ll settle on Formik as it passes the boring technology check. However, the sample repo will also have a react-hook-form example.

show me some code

The Code

Using Formik was a breeze. The main Formik component allows us to set the initial values of all the form fields. It also provides a validate function to add custom validation logic and a form submit handler.

import React from "react";
import { Formik, Form, Field, ErrorMessage } from 'formik';

function FormikPage() {
 return (<Formik
   initialValues={{ firstName: '', lastName: '', email: '', age: '', phone: '' }}
   validate={values => {
     const errors = {};
     if (!values.firstName) {
       errors.firstName = "Required";
     }
     if (!values.lastName) {
       errors.lastName = "Required";
     }
     if (!values.age) {
       errors.age = "Required";
     } else if (values.age < 18) {
       errors.age = "Must be 18 years or older"
     }
     if (!values.email) {
       errors.email = 'Required';
     } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)) {
       errors.email = 'Invalid email address';
     }
     if (!values.phone) {
       errors.phone = 'Required';
     } else if (!/[0-9]{3}-[0-9]{3}-[0-9]{4}$/i.test(values.phone)) {
       errors.phone = 'Invalid phone number';
     }
     return errors;
   }}
   onSubmit={(values, { setSubmitting }) => {
     alert(JSON.stringify(values, null, 2));
     setSubmitting(false);
   }}
 >
   {({ isSubmitting }) => (
     <Form>
       <label>First Name: </label>
       <Field type="text" name="firstName" />
       <ErrorMessage name="firstName" component="div" />

       <label>Last Name: </label>
       <Field type="text" name="lastName" />
       <ErrorMessage name="lastName" component="div" />

       <label>Age: </label>
       <Field type="number" name="age" />
       <ErrorMessage name="age" component="div" />

       <label>Email Address: </label>
       <Field type="email" name="email" />
       <ErrorMessage name="email" component="div" />

       <label>Phone Number: </label>
       <Field type="tel" name="phone" />
       <ErrorMessage name="phone" component="div" />

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

export default FormikPage;

first pass

It works pretty well in under 70 lines of code, but can we do better?

Take-Two

I would first look at optimizing the code by using the required attribute for the input tags. Using the required property, we will rely on the browser to keep the form from being submitted unless a value is supplied.

Let’s add the required attribute to each of the Field tags and update the validate function to remove the checks for missing values.

import React from "react";
import { Formik, Form, Field, ErrorMessage } from 'formik';

function FormikPage() {
 return (<Formik
   initialValues={{ firstName: '', lastName: '', email: '', age: '', phone: '' }}
   validate={values => {
     const errors = {};
     if (values?.age < 18) {
       errors.age = "Must be 18 years or older"
     }
     if (values.email && !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)) {
       errors.email = 'Invalid email address';
     }
     if (values.phone && !/[0-9]{3}-[0-9]{3}-[0-9]{4}$/i.test(values.phone)) {
       errors.phone = 'Invalid phone number';
     }
     return errors;
   }}
   onSubmit={(values, { setSubmitting }) => {
     alert(JSON.stringify(values, null, 2));
     setSubmitting(false);
   }}
 >
   {({ isSubmitting }) => (
     <Form>
       <label>First Name: </label>
       <Field type="text" name="firstName" required />
       <ErrorMessage name="firstName" component="div" />

       <label>Last Name: </label>
       <Field type="text" name="lastName" required />
       <ErrorMessage name="lastName" component="div" />

       <label>Age: </label>
       <Field type="number" name="age" required />
       <ErrorMessage name="age" component="div" />

       <label>Email Address: </label>
       <Field type="email" name="email" required />
       <ErrorMessage name="email" component="div" />

       <label>Phone Number: </label>
       <Field type="tel" name="phone" required />
       <ErrorMessage name="phone" component="div" />

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

export default FormikPage;

Take Two

Great! Everything still works, and we’ve shed 15 lines of code. Now when you fail to fill in a required input the browser warns you it is missing when you try to submit the form.

I wonder what else we can get rid of.

Numero Trois

I’m starting to think we can eliminate the entire validate function. Input fields of type number can use a min attribute to specify the minimum value that the field will accept. Additionally, the email and tel fields support using regular expressions via the pattern attribute for validation.

After we make a few more code changes, things look like this:

import React from "react";
import { Formik, Form } from 'formik';

function FormikPage() {
 return (<Formik
   initialValues={{ firstName: '', lastName: '', email: '', age: '', phone: '' }}
   onSubmit={(values, { setSubmitting }) => {
     alert(JSON.stringify(values, null, 2));
     setSubmitting(false);
   }}
 >
   {({ isSubmitting }) => (
     <Form>
       <label>First Name: </label>
       <input type="text" name="firstName" required />

       <label>Last Name: </label>
       <input type="text" name="lastName" required />

       <label>Age: </label>
       <input type="number" name="age" required min="18" />

       <label>Email Address: </label>
       <input type="email" name="email" required pattern="\S+@\S+\.\S+" />

       <label>Phone Number: </label>
       <input type="tel" name="phone" required pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}" />

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

export default FormikPage;

third times the charm

Whelp, we are down under 40 lines of code and shed the Field and ErrorMessage components from our import. We’ve offloaded the more complex field validation to the browser instead of using JavaScript.

If you’ve followed along with me this far, you are probably wondering why we are even using Formik in the first place, and we could write this component without Formik.

import React from "react";

function ReactWithForm() {
 return <form onSubmit={(event) => {
     event.preventDefault();
     const formData = new FormData(event.target);
     const formProps = Object.fromEntries(formData);
     alert(JSON.stringify(formProps, null, 2));
   }}>
     <label>First Name: </label>
     <input type="text" name="firstName" required />

     <label>Last Name: </label>
     <input type="text" name="lastName" required />

     <label>Age: </label>
     <input type="number" name="age" required min="18" />

     <label>Email Address: </label>
     <input type="email" name="email" required pattern="\S+@\S+\.\S+" />

     <label>Phone Number: </label>
     <input type="tel" name="phone" required pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}" />

     <input type="submit" />
   </form>;
}

export default ReactWithForm;

Removing Formik from the component removes its 580 kB and the 2.7 MB that it pulls in with its 14 dependencies. Our component only has one dependency, React, and it outputs plain old HTML.

VanillaJS

We’ve stripped away our need for a form library by using the built-in capabilities of the web browser. This should have you questioning whether or not you need a framework at all to collect form data from web users.

Let’s build our form without relying on anything that isn’t readily available in the browser.

<!DOCTYPE html>
<html lang="en">
 <head>
   <title>Vanilla App</title>
 </head>
 <body>
   <form id="form" style="display: inline-grid;">
       <label>First Name: </label>
       <input type="text" name="firstName" required />

       <label>Last Name: </label>
       <input type="text" name="lastName" required />

       <label>Age: </label>
       <input type="number" name="age" required min="18" />

       <label>Email Address: </label>
       <input type="email" name="email" required pattern="\S+@\S+\.\S+" />

       <label>Phone Number: </label>
       <input type="tel" name="phone" required pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}" />

       <input type="submit" />
     </form>
     <script>
       function logSubmit(event) {
         event.preventDefault();
         const formData = new FormData(event.target);
         const formProps = Object.fromEntries(formData);
         alert(JSON.stringify(formProps, null, 2));
       }

       const form = document.getElementById('form');
       form.addEventListener('submit', logSubmit);
     </script>
 </body>
</html>

Our entire form is built using HTML and JavaScript to handle the form submission.

Why ditch React?

No shade on React as it is a great framework, but it’s a bit of an overkill for collecting form data. If your React application is client-side rendered, you may run into a host of problems where JavaScript is disabled for accessibility, security or performance reasons.

If you have ever disabled JavaScript in a React app you may be familiar with this message in your browser.

no JavaScript

As well, React can be computationally expensive, especially on low-end devices. Here’s a summary from a recording of the performance of the web browser while filling out the form:

react performance

And here’s the same experiment using our VanillaJS form:

vanilla performance

The React example spends 10x more time running JavaScript for essentially the same functionality as it needs to manage the component state and any potential changes to the virtual DOM.

Conclusion

This isn’t a true apples to apples comparison between an app written using a React Form library and one written using components already available in the browser. However, I hope it shows that reaching for a framework by default may be suitable for your own developer experience but, it may not be best for the user experience.

Consider eschewing frameworks until they are essential. Instead, start with regular old HTML and progressively enhance it to avoid leaving older browsers and users with under-powered devices behind.