skip to content

Use the Form, Luke

/ 5 min read

React is a very flexible tool for creating, well, reactive UIs. What React is not, and its authors take great pains to remind us of this, is a framework. React provides the APIs for manipulating a virtual DOM which is periodically flushed to the actual DOM that humans interact with, everything else is on you to figure out.

This philosophy simplifies the mental model for where React’s responsibilities start and stop. But it leaves you gluing together your own framework to build a basic app.

One of the first casualties in the battle between new users and React is forms. In this post we will build a simple sign up form which we will iteratively improve to make it more accessible, performant and maintainable.

First Attempt

Typically where new users of React will start when building a form is by using state to store a giant object in the component’s state. This object is then updated directly when users interact with the form.

Lets look at an example:

Stateful Form
import { useState } from "react";

import "./StatefulForm.css";
import { ColourOptions } from "../types";
import { callSignUpApi } from "../apiCall";
import { useRenderCounter } from "../hooks/useRenderCounter";

type Form = {
  username: string;
  password: string;
  acceptedTerms: boolean;
  favouriteColour: ColourOptions;
};

const defaultFormValues: Form = {
  username: "",
  password: "",
  acceptedTerms: true,
  favouriteColour: "blue",
};

export const StatefulForm = () => {
  const [form, setForm] = useState<Form>(defaultFormValues);
  const { count } = useRenderCounter();

  const handleFormValueChange = (
    fieldName: keyof Form,
    value: boolean | string
  ) => {
    const newForm = { ...form, [fieldName]: value };
    setForm(newForm);
  };

  const handleSignUp = async () => {
    await callSignUpApi(form);
  };

  return (
    <div>
      <fieldset className="form-wrapper">
        <legend>Sign Up</legend>

        <label htmlFor="username">Username:</label>
        <input
          type="text"
          aria-label="Username"
          id="username"
          value={form.username}
          onChange={(e) => handleFormValueChange("username", e.target.value)}
        />

        <label htmlFor="password">Password:</label>
        <input
          id="password"
          aria-label="Password"
          type="password"
          value={form.password}
          onChange={(e) => handleFormValueChange("password", e.target.value)}
        />

        <label htmlFor="favouriteColour">Favourite Colour:</label>
        <select
          id="favouriteColour"
          value={form.favouriteColour}
          aria-label="Favourite Colour"
          onChange={(e) =>
            handleFormValueChange("favouriteColour", e.target.value)
          }
        >
          <option value="blue">Blue</option>
          <option value="red">Red</option>
          <option value="green">Green</option>
          <option value="yellow">Yellow</option>
        </select>

        <div>
          <input
            type="checkbox"
            aria-label="Accept terms of service"
            id="termsOfService"
            name="termsOfService"
            checked={form.acceptedTerms}
            onChange={(e) =>
              handleFormValueChange("acceptedTerms", e.target.checked)
            }
          />
          <label htmlFor="termsOfService">I accept the terms of service</label>
        </div>

        <button
          onClick={handleSignUp}
          className="sign-up-btn"
          style={{ backgroundColor: form.favouriteColour }}
        >
          Sign Up
        </button>
      </fieldset>
      <pre>Render count: {count}</pre>
    </div>
  );
};
Sign Up
Render count: 0

Playing around with this example it clearly works, but it comes up short in a number of ways:

  • Accessibility: We did all the right things by using semantic HTML for inputs, and adding aria labels. But by not using a <form> element it’s not clear to users with assistive technologies how to submit this form - we don’t have a real submit button.
  • Validation: We aren’t doing any validation on the inputs. To do so requires us to write custom logic to validate the inputs, store the error information and render it underneath the inputs when the user hits submit. This is fiddly to do in a submit handler that’s also supposed to be handling a successful submission.
  • Performance: Controlled inputs are inputs whose state is stored in the state of a React component, whereas uncontrolled inputs are inputs whose state is stored in the browser. Here we are using controlled inputs; every change to an input field requires a full component re-render. That’s not a big deal in a form like this which is simple, but becomes problematic in more complex forms or when users have low powered devices. Watch the render counter as you interact with the form.
  • Type safety: Our handleFormValueChange function is technically typesafe but it doesn’t prevent you from making silly mistakes. Because we are using the same function to handle different types of inputs we run the risk of passing a boolean value into a string text field and vice versa. This is trivially avoidable if we write an update function for each field, but now boilerplate abounds.

Using the <form> element

Not all of the issues with our form are fixable with the <form> element but let’s try it and see where we land:

Formier Form
import { FormEvent, useState } from "react";

import "./StatefulForm.css";
import { useRenderCounter } from "../hooks/useRenderCounter";
import { ColourOptions } from "../types";
import { callSignUpApi } from "../apiCall";

const INITIAL_FAVOURITE_COLOUR = "blue" as const;

interface FormElements extends HTMLFormControlsCollection {
  username: HTMLInputElement;
  password: HTMLInputElement;
  favouriteColour: HTMLInputElement;
  termsOfService: HTMLInputElement;
}

interface SignUpForm extends HTMLFormElement {
  readonly elements: FormElements;
}

export const BetterForm = () => {
  const { count } = useRenderCounter();
  const [favouriteColour, setFavouriteColour] = useState<ColourOptions>(
    INITIAL_FAVOURITE_COLOUR
  );

  const handleSignUp = async (e: FormEvent<SignUpForm>) => {
    e.preventDefault();
    const { elements } = e.currentTarget;
    const formData = {
      username: elements.username.value,
      password: elements.password.value,
      favouriteColour: elements.favouriteColour.value as ColourOptions,
    };
    await callSignUpApi(formData);
  };

  return (
    <form onSubmit={handleSignUp}>
      <fieldset className="form-wrapper">
        <legend>Sign Up</legend>

        <label htmlFor="username">Username:</label>
        <input
          type="text"
          aria-label="Username"
          aria-required
          id="username"
          name="username"
          required
        />

        <label htmlFor="password">Password:</label>
        <input
          id="password"
          aria-label="Password"
          aria-required
          type="password"
          name="password"
          required
          minLength={8}
          autoComplete="off"
          // Minimum eight characters, at least one letter and one number:
          pattern="^(?=.*[A-Za-z])(?=.*d)[A-Za-zd]{8,}$"
        />

        <label htmlFor="favouriteColour">Favourite Colour:</label>
        <select
          id="favouriteColour"
          name="favouriteColour"
          aria-label="Favourite Colour"
          aria-required
          defaultValue={INITIAL_FAVOURITE_COLOUR}
          required
          onChange={(e) => setFavouriteColour(e.target.value as ColourOptions)}
        >
          <option value="blue">Blue</option>
          <option value="red">Red</option>
          <option value="green">Green</option>
          <option value="yellow">Yellow</option>
        </select>

        <div>
          <input
            type="checkbox"
            aria-label="Accept terms of service"
            id="termsOfService"
            name="termsOfService"
            required
            aria-required
          />
          <label htmlFor="termsOfService">I accept the terms of service</label>
        </div>

        <button
          type="submit"
          className="sign-up-btn"
          style={{
            ...(favouriteColour ? { backgroundColor: favouriteColour } : {}),
          }}
        >
          Sign Up
        </button>
      </fieldset>
      <pre>Render count: {count}</pre>
    </form>
  );
};
Sign Up
Render count: 0

This is a pretty big improvement over what we had before, it’s:

  • More Accessible: There’s still a way to go on this as we’re missing various aria annotations for error states but it’s in a much better place than it was. You can press enter to submit the form now!
  • Has Validation: All of our inputs are validated using native browser functionality. Native validation is nice, but is somewhat limited for more complex validation cases like checking if a username is available or for more troublesome inputs like comboboxes.
  • Better Performance: The render counter doesn’t lie, by switching to uncontrolled inputs the only action that triggers a re-render is changing our favourite colour. This is exactly what we want because we want to render our favourite colour on the button.
  • More typesafety: We aren’t handing state updates anymore so that risk is eliminated. However the form event payload is still manually typed, this means that changing an input name will trigger a runtime explosion that typescript couldn’t warn us about. This example is also slightly cheating by using simple inputs which are easy to add types to; custom inputs components would not be so simple.

Using useForm

For our last rendition of the form let’s add two libraries that augment the power of the <form> element:

Zod is a library that performs runtime validation of object shapes and compile time types that you can use to interact safely with the objects it has validated.

React Hook Form provides a layer of abstraction over interacting with form elements and performing validation.

When used together React Hook Form allows you to only think about the happy path where a user has submitted the form correctly, whilst using Zod to validate inputs and provide error feedback.

Hook Form
import "./StatefulForm.css";
import { useRenderCounter } from "../hooks/useRenderCounter";
import { ColourOptions, colourOptions } from "../types";
import { callSignUpApi } from "../apiCall";
import { useForm, SubmitHandler } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const formSchema = z.object({
  username: z.string().min(1, {
    message: "Username is required",
  }),
  password: z.string().regex(new RegExp("^(?=.*[0-9])(?=.{8,})[a-zA-Z0-9]+$"), {
    message:
      "Password must contain at least 8 characters including one letter and one number",
  }),
  favouriteColour: z.enum(colourOptions),
  termsOfService: z.boolean().refine((val) => !!val, {
    message: "Must accept terms of service",
  }),
});

type FormSchema = z.infer<typeof formSchema>;

export const HookForm = () => {
  const { count } = useRenderCounter();

  const {
    handleSubmit,
    register,
    watch,
    formState: { errors },
  } = useForm<FormSchema, unknown, FormSchema>({
    resolver: zodResolver(formSchema),

    defaultValues: {
      favouriteColour: "blue",
    },
  });

  const favouriteColour = watch("favouriteColour");

  const handleSignUp: SubmitHandler<{
    username: string;
    password: string;
    favouriteColour: ColourOptions;
  }> = async ({ username, password, favouriteColour }) => {
    const formData = {
      username,
      password,
      favouriteColour,
    };
    await callSignUpApi(formData);
  };

  return (
    <form onSubmit={handleSubmit(handleSignUp)}>
      <fieldset className="form-wrapper">
        <legend>Sign Up</legend>

        <label htmlFor="username">Username:</label>
        <input
          type="text"
          aria-label="Username"
          aria-required
          id="username"
          {...(errors.username?.message
            ? { "aria-describedby": "username-error" }
            : {})}
          {...register("username")}
        />
        {errors.username?.message && (
          <span
            className="error-message"
            id="username-error"
            aria-live="polite"
          >
            *{errors.username.message}
          </span>
        )}

        <label htmlFor="password">Password:</label>
        <input
          id="password"
          aria-label="Password"
          aria-required
          type="password"
          autoComplete="off"
          {...(errors.username?.message
            ? { "aria-describedby": "password-error" }
            : {})}
          {...register("password")}
        />
        {errors.password?.message && (
          <span
            className="error-message"
            id="password-error"
            aria-live="polite"
          >
            *{errors.password.message}
          </span>
        )}

        <label htmlFor="favouriteColour">Favourite Colour:</label>
        <select
          id="favouriteColour"
          aria-label="Favourite Colour"
          aria-required
          {...register("favouriteColour")}
        >
          <option value="blue">Blue</option>
          <option value="red">Red</option>
          <option value="green">Green</option>
          <option value="yellow">Yellow</option>
        </select>

        <div>
          <input
            type="checkbox"
            aria-label="Accept terms of service"
            id="termsOfService"
            aria-required
            {...(errors.termsOfService?.message
              ? { "aria-describedby": "terms-error" }
              : {})}
            {...register("termsOfService")}
          />
          <label htmlFor="termsOfService">I accept the terms of service</label>
        </div>
        {errors.termsOfService?.message && (
          <span className="error-message" id="terms-error" aria-live="polite">
            *{errors.termsOfService.message}
          </span>
        )}

        <button
          type="submit"
          className="sign-up-btn"
          style={{
            backgroundColor: favouriteColour,
          }}
        >
          Sign Up
        </button>
      </fieldset>
      <pre>Render count: {count}</pre>
    </form>
  );
};
Sign Up
Render count: 0

This form still isn’t perfect, but it’s looking better:

  • Accessibility: We have provided aria annotations for error states and only rendered them when errors occur.
  • Validation: Only submissions that satisfy our schema can be submitted, all validation is handled by a combination of Zod and React Hook Form.
  • Performance: We are still using uncontrolled components so our re-renders are minimal
  • Type safety: Only submissions that satisfy our schema can be submitted. We also cannot pass unknown input names to the register function - everything is driven by the schema.

So there we have it, an accessible, performant and type safe form.