maximedubourg.com

Pragmatic form development in React

08/06/2023

If there is a thing that almost all user-facing softwares have in common, it is forms. They are the way users interact with applications and thus are a central part of most products, and it is especially true for web applications.

With the rise of modern JavaScript frameworks, a lot of common issues have been made really easier to solve for frontend developers, but while several libraries took their shots, forms are still not easy to get right in React.

What makes forms hard ?

As said before, forms are the main way for users to interact with applications. This implies several things:

All these constraints off course vary with the nature, complexity, and size of applications, but they are the most common pain points of developing forms.

What is wrong with form libs ?

I have to clarify that I'm not trying to tackle on any of the libraries I mention here, I've used them extensively and they are great tools that do a lot of good for the React community.

Popular React forms libraries (as for example react-hook-form or formik) are greats tools that can help React developers to build forms fastly, but in my opinion, they share the same drawback:

They try to abstract a domain that is intrisically too diverse to be abstracted correctly: as a project grows, almost no form will remain "simple" (i.e. keep constant validation rules, keep no "other-field-dependent" update logic, etc...), and the more complex they get, the more the abstraction provided by these libraries becomes a pain point.

I did not discovered this issue by myself as it's something that has been discussed a lot in the web development community. It is for example discussed in this well known article: Avoid Hasty Abstractions (AHA).

So, what's my point here ?

Sometimes, the right library is no library

About a year ago, at ekino, I initiated the application I was about to work on for at least the upcoming year (I'm still working on it to the day I'm writing this article). I knew this application would mostly be composed of forms, BIG forms (more than 30 fields for some of them). Yet, for the reasons I explained above, I made the decision not to use any form library.

After now a year of working and maintaining this project every day, I can say that I do not regret this decision.

Here's a detailed sight of what I used instead:

ℹī¸ TL;DR: The following part details the implementation step by step, you can find the full source code on this sandbox.

1. Several field components, but the same API

As stated earlier in this article, one of the pain points when developing forms in React is to handle the variety of data types that can be used in forms. As in most frontend projects, my first step was to create a set of presentational components for each type of field my forms would use.

The key for me here was to make sure every one of these components had the same read/write API (understand "the same props"):

export type ChangeEventBase<T> = {
  target: {
    name: string;
    value: T;
  };
};

export type BlurEventBase = {
  target: {
    name: string;
  };
};

// ----- TextField.tsx -----

export type TextFieldProps = {
  value: string;
  error?: string;
  onChange: (event: ChangeEventBase<string>) => void;
  onBlur: (event: BlurEventBase) => void;
  // ...
};

//  ----- SelectField.tsx -----

export type SelectOption = { label: string; value: string };

export type SelectFieldProps = {
  value: SelectOption["value"];
  error?: string;
  options: SelectOption[];
  onChange: (e: ChangeEventBase<SelectOption["value"]>) => void;
  onBlur: (e: BlurEventBase) => void;
  // ...
};

// same goes for CheckboxField, DateField, etc...

Here are the things to notice here:

📁 Here's what our project structure looks like for now, (it will be updated after every step):

src/
└── presentationals/fields/
    ├── TextField.tsx
    ├── CheckboxField.tsx
    └── SelectField.tsx

2. We have fields, now we need a form

This step is actually a fake one!

Not to reproduce the things I didn't like in common form libraries and to keep the control of my forms, I did not create any Form component or useForm hook to magically abstract logic, but instead used the standard HTML's form element and the useReducer hook to handle the state of my forms. Nothing more.

For the rest of this article, I'll take the example of building a simple application form for a chess tournament.

// ----- ChessTournamentForm.tsx -----

import { TextField } from "../../presentationals/fields/TextField";
import { CheckboxField } from "../../presentationals/fields/CheckboxField";
import { SelectField } from "../../presentationals/fields/SelectField";
import { COUNTRIES_OPTIONS } from "./constants";

export function ChessTournamentForm() {
  return (
    <form>
      <TextField name="fullName" label="Full name" />
      <SelectField name="country" label="Country" options={COUNTRIES_OPTIONS} />
      <TextField name="eloRating" label="Elo rating" type="number" />

      <CheckboxField
        name="isGrandmaster"
        label="Yes, I'm a chess grandmaster"
      />

      <button type="button">Submit</button>
    </form>
  );
}

About Elo rating: https://www.chess.com/terms/elo-rating-chess

As is, this form does nothing. We now need to handle its state (values and errors), its validation, and then its submission.

3. Making the form alive

As I already talked about in a previous article, I really like React's useReducer hook to handle state and logic in my React apps, so naturally I also use it as the central part of my forms.

Althought our reducer will do a lot of things, let's start with the basics: handling the state of the form.

First we need to define the shape of the state that will hold the fields values:

// ----- reducer.ts -----

import { SelectOption } from "../../presentationals/fields/SelectField";

type ChessTournamentFormState = {
  values: {
    fullName: string;
    country: SelectOption["value"];
    eloRating: string;
    isGrandmaster: boolean;
  };
};

Then, let's define the action that will be dispatched to the reducer to update the fields state (more actions will be added later):

// ----- reducer.ts -----

// ...

// => 'fullName' | 'eloRating' | 'country' | 'isGrandmaster'
export type StateValuesKey = keyof ChessTournamentFormState["values"];

// => string | boolean
export type StateValuesValue =
  ChessTournamentFormState["values"][StateValuesKey];

type ChessTournamentFormAction = {
  type: "CHANGE_FIELD_VALUE";
  field: StateValuesKey;
  value: StateValuesValue;
};

And finally, let's create the reducer function:

// ----- reducer.ts -----

// ...
import _ from "lodash";

// ...

export function getInitialState(): ChessTournamentFormState {
  return {
    values: {
      fullName: "",
      country: "",
      eloRating: "",
      isGrandmaster: false,
    },
  };
}

export function reducer(
  state: ChessTournamentFormState,
  action: ChessTournamentFormAction
): ChessTournamentFormState {
  switch (action.type) {
    case "CHANGE_FIELD_VALUE": {
      const newState = structuredClone(state);
      _.set(newState.values, action.field, action.value);

      // example of "other-field-dependent" update logic
      if (action.field === "eloRating") {
        newState.values.isGrandmaster = +action.value > 2500;
      }

      return newState;
    }

    default: {
      return state;
    }
  }
}

We can now wire our form together:

// ----- ChessTournamentForm.tsx -----

// ...

import { useReducer, useCallback } from "react";

import {
  reducer,
  getInitialState,
  StateValuesKey,
  StateValuesValue,
} from "./reducer";

function ChessTournamentForm() {
  const [state, dispatch] = useReducer(reducer, undefined, getInitialState);

  const handleChange = useCallback(
    (event: ChangeEventBase<StateValuesValue>) => {
      dispatch({
        type: "CHANGE_FIELD_VALUE",
        field: event.target.name as StateValuesKey,
        value: event.target.value,
      });
    },
    []
  );

  return (
    <form>
      <TextField
        // ...
        value={state.values.fullName}
        onChange={handleChange}
      />
      <SelectField
        // ...
        value={state.values.country}
        onChange={handleChange}
      />
      <TextField
        // ...
        value={state.values.eloRating}
        onChange={handleChange}
      />
      <CheckboxField
        // ...
        value={state.values.isGrandmaster}
        onChange={handleChange}
      />

      <button type="button">Submit</button>
    </form>
  );
}

📁 The form is now fully working and reactive! Now let's add some validation, but first, here's a recap of our project structure at this point:

src/
├── presentationals/fields/
│   ├── TextField.tsx
│   ├── CheckboxField.tsx
│   └── SelectField.tsx
└── containers/ChessTournamentForm/
    ├── ChessTournamentForm.tsx
    └── reducer.ts

4. Validating user inputs and displaying errors

Data validation is a concern that is as old as software development itself and a lot of great libraries already exist to handle it in JavaScript, so we just have to plug one of them to our solution.

No big surprises here, I chose Zod for this purpose.

Let's start by updating the state of our form to hold a potential error for each field, using an errors object:

// ----- reducer.ts -----

type ChessTournamentFormState = {
  // ...

  errors: {
    fullName?: string;
    country?: string;
    eloRating?: string;
    isGrandmaster?: string;
  };
};

export function getInitialState(): ChessTournamentFormState {
  return {
    // ...

    errors: {},
  };
}

As we want the field validation to happen on field blur, we also need to define the type of the action that will be dispatched to the reducer when a field is blurred.

// ----- reducer.ts -----

type ChessTournamentFormAction =
  | {
      type: "CHANGE_FIELD_VALUE";
      field: StateValuesKey;
      value: StateValuesValue;
    }
  | {
      type: "BLUR_FIELD";
      field: StateValuesKey;
    };

We also need to define the Zod schema that will be used to validate the form:

// ----- validationSchema.ts -----

import { z } from "zod";

export const validationSchema = z.object({
  fullName: z.string().nonempty(),
  // yes, eloRating is a string
  // because it's what our input will give us
  eloRating: z.string().nonempty(),
  country: z.string().nonempty(),
  isGrandmaster: z.boolean(),
});

And finally, we can update our reducer to handle the blurred field validation. As we'll dispatch the name of the blurred field along the BLUR_FIELD action, we can use it to pick the corresponding validation rule from the Zod schema and validate only the blurred field's value:

// ----- reducer.ts -----

import { validationSchema } from "./validationSchema";

// ...

export function reducer(
  state: ChessTournamentFormState,
  action: ChessTournamentFormAction
): ChessTournamentFormState {
  switch (action.type) {
    // ...

    case "BLUR_FIELD": {
      const newState = structuredClone(state);

      try {
        const narrowedSchema = validationSchema.pick({
          [action.field]: true,
        });
        narrowedSchema.parse(state.values);

        // if the validation succeeds,
        // we clear potential previous error for that field
        _.set(newState, ["errors", action.field], undefined);
      } catch (error) {
        if (error instanceof z.ZodError) {
          for (const issue of error.issues) {
            // else, we set the error message
            _.set(newState, ["errors", ...issue.path], issue.message);
          }
        }
      }

      return newState;
    }

    default:
      return state;
  }
}

Let's not forget to wire our form to the validation logic:

// ----- ChessTournamentForm.tsx -----

// ...

function ChessTournamentForm() {
  // ...

  const handleBlur = useCallback((event: BlurEventBase) => {
    dispatch({
      type: "BLUR_FIELD",
      field: event.target.name as StateValuesKey,
    });
  }, []);

  return (
    <form>
      <TextField
        // ...
        error={state.errors.fullName}
        onBlur={handleBlur}
      />
      <SelectField
        // ...
        error={state.errors.country}
        onBlur={handleBlur}
      />
      <TextField
        // ...
        error={state.errors.eloRating}
        onBlur={handleBlur}
      />
      <CheckboxField
        // ...
        error={state.errors.isGrandmaster}
        onBlur={handleBlur}
      />

      {/* ... */}
    </form>
  );
}

📁 Great ! At this point, our form handles its state, its validation, and its error displaying. Here's a recap of our project structure after this step:

src/
├── presentationals/fields/
│   ├── TextField.tsx
│   ├── CheckboxField.tsx
│   └── SelectField.tsx
└── containers/ChessTournamentForm/
    ├── ChessTournamentForm.tsx
    ├── validationSchema.ts
    └── reducer.ts

5. Submitting the form

Don't worry, we're almost done! This is the last step: handling the form submission. It can be divided in several parts:

Let's start with the submission logic. For this we need to define the shape of the data that will be sent to the server, and then write the actual react-query mutation:

// ----- useRegisterToChessTournamentMutation.ts -----

import { useMutation } from "@tanstack/react-query";

export type RegisterToChessTournamentMutationVariables = {
  fullName: string;
  country: string;
  eloRating: number;
  isGrandmaster: boolean;
};

export function useRegisterToChessTournamentMutation() {
  return useMutation<void, Error, RegisterToChessTournamentMutationVariables>(
    async (variables) => {
      const response = await fetch(`/api/register-chess-tournament`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(variables),
      });

      if (!response?.ok) {
        throw new Error(response?.statusText);
      }
    }
  );
}

Our mutation is pretty simple but it fullfils its job: it sends the HTTP request and/or throws if needed.

Let's now add the reducer action and event handler that will be dispatched when clicking the submit button:

// ----- reducer.ts -----

// ...

type ChessTournamentFormAction =
  | {
      type: "CHANGE_FIELD_VALUE";
      field: StateValuesKey;
      value: StateValuesValue;
    }
  | {
      type: "BLUR_FIELD";
      field: StateValuesKey;
    }
  | {
      type: "SUBMIT_FORM";
      submit: () => void;
    };

export function reducer(
  state: ChessTournamentFormState,
  action: ChessTournamentFormAction
): ChessTournamentFormState {
  switch (action.type) {
    // ...

    case "SUBMIT_FORM": {
      const newState = structuredClone(state);

      try {
        validationSchema.parse(state.values);

        // if the validation succeeds,
        // we clear potential previous errors
        newState.errors = {};
        // and then we submit the form
        action.submit();
      } catch (error) {
        if (error instanceof z.ZodError) {
          for (const issue of error.issues) {
            // else, we set the error message
            _.set(newState, ["errors", ...issue.path], issue.message);
          }
        }
      }

      return newState;
    }

    default: {
      return state;
    }
  }
}

ℹī¸ Notice here that it's the reducer that has the reponsibility for calling the submission function after having validated the whole form.

We can now write our submit button's onClick handler, along with potential submission's success/error messages:

// ----- ChessTournamentForm.tsx -----

// ...

import { Message } from "../presentationals/Message";
import { useRegisterToChessTournamentMutation } from "./useRegisterToChessTournamentMutation";

function ChessTournamentForm() {
  // ...

  const {
    mutate: registerToChessTournament,
    isSuccess: isRegisterSuccess,
    error: registerError,
  } = useRegisterToChessTournamentMutation();

  const handleSubmit = useCallback(
    (event: React.FormEvent) => {
      event.preventDefault();

      dispatch({
        type: "SUBMIT_FORM",
        submit: () => {
          // this could be done in a separate file's function
          const formattedFormForApi = {
            ...state.values,
            eloRating: Number(state.values.eloRating),
          };

          registerToChessTournament(formattedFormForApi);
        },
      });
    },
    [registerToChessTournament, state.values]
  );

  if (isRegisterSuccess) {
    return <Message type="success">You're registered!</Message>;
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* ... */}

      {registerError && <Message type="error">{registerError.message}</Message>}

      <button>Submit</button>
    </form>
  );
}

Summary

Alright! We're done!

Here's a summary of what I think is to be remembered from this solution:

I acknowledge that this solution might look like a lot of code for forms that are not initially "complex", but I believe it's always worth it as it prevents your code from becoming a mess as your project scales.

I hope this article will make you want to implement this solution in your future React projects. If you have any question or suggestion, feel free to reach me on Twitter.

Dont' forget to check the full source code on this CodeSandbox !

Thanks for reading!