Perfect way to create forms with React in 2023: the complete guide

Creating forms is a pain in the ass: performance, validation... We will see how to create forms with React (NextJS) the easy way, with a single component call.

Perfect way to create forms with React in 2023: the complete guide

The painful path of making a good code for form management

Forms are difficult

If you're a developer, you surely faced the frustrating brain-damaging (especially if you're a junior) problem of forms. They are different to each other, you simply can't imagine how to avoid repeating boilerplate-code over your different forms, some have fields that need to be disabled or hidden depending on some other fields, form validation is a pain in the ass, you have to do some spaghetti code because each field is different etc...

You got it: forms suck.

So just for fun, let's begin this article with the most common forms integrations you should avoid and the casual problems you face with forms.

 

Declaring your fields with multiple useState

import React from 'react'

const MyForm = () => {
 const [firstname, setFirstname] = useState("");
 const [lastname, setLastname] = useState("");
 const [email, setEmail] = useState("")

 return (
  <input name="firstname" onChange={e => setFirstname(e.target.value)} value={firstname}/>
  <input name="lastname" onChange={e => setLastname(e.target.value)} value={lastname}/>
  <input name="email" onChange={e => setEmail(e.target.value)} value={email}/>
 )
}

Ok I know, this one hurts pretty bad. But we all started, at some point, with this kind of code. Let's forget about it and move to the next one. There is nothing more to say anyway.

 

Not using a global function to set value

const MyForm = () => {
  const [state, setState] = useState({
    firstname: "",
    lastname: "",
    email: "",
  });

  return (
      <input
        name="firstname"
        value={state.firstname}
        onChange={(e) => setState({ ...state, firstname: e.target.value })}
      />
      <input
        name="lastname"
        value={state.lastname}
        onChange={(e) => setState({ ...state, lastname: e.target.value })}
      />
      <input
        name="firstname"
        value={state.email}
        onChange={(e) => setState({ ...state, email: e.target.value })}
      />
  );
};

Ok this is getting better. We're using a single state to manage our form instead of many states. But you will later learn that you could use a single function to set your form state, like this:

const MyForm = () => {
  const [state, setState] = useState({
    firstname: "",
    lastname: "",
    email: "",
  });

  const onChange = e => {
    setState({...state, [e.target.name]: e.target.value})
  }

  return (
      <input
        name="firstname"
        value={state.firstname}
        onChange={onChange}
      />
      <input
        name="lastname"
        value={state.lastname}
        onChange={onChange}
      />
      <input
        name="firstname"
        value={state.email}
        onChange={onChange}
      />
  );
};

But now, what if your form has multiple field type ? You might have inputs, selects, textareas, checkboxes, radios and more in the same form. How do you manage it ? You will have to create additional functions depending on the field.

Of course, if your form is small, you'd manage it easily with inline functions. But what about a form with 10 fields ? Your code is going to be really, really long and repetitive. and you end up doing things like this:

const MyForm = () => {
  const [state, setState] = useState({
    firstname: "",
    lastname: "",
    email: "",
    useCase: ""
  });

  const onChange = (e) => {
    setState({ ...state, [e.target.name]: e.target.value });
  };

  return (
      {Object.keys(state).map((res) => (
        <input onChange={onChange} key={res} name={res} />
      ))}
      <select name="useCase" onChange={onChange}>
        <option>Personal</option>
        <option>Commercial</option>
      </select>
  );
};

But what if I told you that there is a better way ?

 

React Hook Form - the ultimate solution to your form problems

React Hook Form is the solution for your forms

You could create your own form state management library to simplify the process of creating forms. Everything is possible, for sure. But I would strongly advise you against it, for multiple reasons:

  • Time - creating it from scratch would be immensely time consuming, especially if you're a beginner, and you could possibly spend a lot of time in re-writing parts of your code if you start from a bad start
  • Complexity - a form state management library is complex, very complex, and having to think of all the possible aspects your library should cover is hard, even for good developers
  • Performance - the performance of your library would depend a lot on its architecture and many many things - ever heard of unnecessary re-rendering or package size ? (yes, I hope)

No need to re-invent the wheel. React Hook Form got your back.

 

What is React Hook Form ?

React Hook Form is a form library built for ReactJs that helps you create and validate forms. Tiny in size (. No more headaches and tears when working with your forms, see the simple example below:

import {useForm} from 'react-hook-form'

const MyForm = () => {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm();
  const onSubmit = (data) => console.log(data);

  console.log(watch("example")); // watch input value by passing the name of it

  return (
    /* "handleSubmit" will validate your inputs before invoking "onSubmit" */
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* register your input into the hook by invoking the "register" function */}
      <input defaultValue="test" {...register("example")} />

      {/* include validation with required or other standard HTML validation rules */}
      <input {...register("exampleRequired", { required: true })} />
      {/* errors will return when field validation fails  */}
      {errors.exampleRequired && <span>This field is required</span>}

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

Let's break this snippet down and explore it in details together:

  • register(name, RegisterOptions?) - one of the key concepts in React Hook Form is to register your component into the hook to make its value available for the form validation & submission. You must pass the name parameter, and optionally you can pass options as second parameter.
  • handleSubmit((data: Object, e?: Event) => void, (errors: Object, e?: Event) => void) - this function will try to validate your form inputs, if the validation passes, your callback function is executed. This will also popular the errors object if the validation fails. See API details here.
  • watch(names?: string | string[] | (data, options) => void) - this method allows you to subscribe to specific inputs change and return their value; this useful if you want to orchestrate behaviours based on a certain input value. See API details.
  • errors - this object is populated with when the form validation fails at submission, allowing an easy error handling.

 

Why use React Hook Form ?

As we said before, React Hook Form is a form library that helps you create and manage your forms. It covers all the problems we discussed earlier (Time - Complexity - Performance) in a perfect way:

  • Small package size - the package size is only 9.1kB (minified + Gzipped)
  • No dependencies - react-hook-form has 0 dependencies, explaining the small package size and ensuring that the code won't break because of a 3rd-party package
  • Performance - one of the primary reasons react-hook-form was created is performance, the package relies on an uncontrolled form which isolates each components, reducing the amount of unnecessary re-rendering when the user types in an input. This also reduces components' overhead, making them mount faster.
  • Cleaner code - write less code, make your code cleaner; react-hook-form is straightforward to use and it also supports Typescript

Let's move to the core part of this article: the approach for a better form implementation.

 

One component to rule them all

The purpose of this article is to show an approach, that I haven't seen elsewhere in the Internet, but I wish I had.

The principle is simple: your forms should be implemented with the help of a single component to which you pass:

  • an array of objects corresponding to the fields that will be displayed in the form
  • a callback function when the form is submitted and validated
  • an object to pass props to the submit button in order to customize it

Let's get started (we will use TailwindCSS & Typescript)

 

Form elements types

We must define our form elements type before we begin. My form will be called ZForm (as the Z in izoukhai), feel free to name it as you wish.

export interface ZFormCoreElement {
  key: string /** To register our field */;
  label: string /** To show labels above our field */;
  colSpan?: number /** Defined our field size in the form */;
  registerOptions?: RegisterOptions /** Allows us to pass additional options to the register method */;
  hidden?: boolean /** Hide the field based on a condition */;
}

export interface ZFormInputElement extends ZFormCoreElement {
  type: "input";
  inputProps?: React.ComponentProps<"input">;
}

export interface ZFormTextareaElement extends ZFormCoreElement {
  type: "textarea";
  textareaProps?: React.ComponentProps<"textarea">;
}

export interface ZFormSelectElement extends ZFormCoreElement {
  type: "select";
  choices: { label: string; value: any }[]; /** Allows us to pass <options> */
  selectProps?: React.ComponentProps<"select">;
}

Quick explanation, as the comments already did job: ZFormCoreElement is the base of our components, it shares the global fields to the components. Then, for each components (input, select, textarea), we declare additional fields, and a type to distinguish one from another.

Now, let's do the same for our main component:

export type ZFormElement =
  | ZFormInputElement
  | ZFormTextareaElement
  | ZFormSelectElement;

export type ZFormProps = {
  onSubmit: (data: any) => void; /** Callback function after submission */
  submitButton: { text: string; props: React.ComponentProps<"button"> } /** Used to customize submit button */
  elements: ZFormElement[]; /** Array of fields */
hookForm: UseFormReturn<FieldValues, any>; /** Form hook object */ };

 

Form element components

We have our backbone, now we need to create components for each input type.

import { ZFormInputElement } from "../@types";
import { FieldValues, UseFormReturn } from "react-hook-form";
import React from "react";

export const ZInput = (props: {
  element: ZFormInputElement /** Field of type input */;
  hookForm: UseFormReturn<
    FieldValues,
    any
  > /** Object returned from useForm() hook */;
}) => {
  const { element, hookForm } = props;
  const { register } = hookForm;
  return (
    <input
      {...element.inputProps}
      required={element.required || false}
      {...register(element.key, {
        ...element.registerOptions,
        required: element.required || false,
      })}
    />
  );
};
import { ZFormSelectElement } from "../@types";
import { FieldValues, UseFormProps, UseFormReturn } from "react-hook-form";
export const ZSelect = (props: {
  element: ZFormSelectElement;
  hookForm: UseFormReturn<FieldValues, any>;
}) => {
  const { element, hookForm } = props;
  const { register } = hookForm;
  return (
      <select
        {...element.selectProps}
        required={element.required || false}
        {...register(element.key, {
          ...element.registerOptions,
          required: element.required || false,
        })}
      >
        {element.selectProps?.placeholder && (
          <option value="" hidden selected disabled>
            {element.selectProps.placeholder}
          </option>
        )}
        {element.choices.map((res, key) => (
          <option value={res.value} key={`select-${element.key}-option-${key}`}>
            {res.label}
          </option>
        ))}
      </select>
  );
};
import { ZFormTextareaElement } from "../@types";
import { FieldValues, UseFormProps, UseFormReturn } from "react-hook-form";
export const ZTextarea = (props: {
  element: ZFormTextareaElement;
  hookForm: UseFormReturn<FieldValues, any>;
}) => {
  const { element, hookForm } = props;
  const { register } = hookForm;

  return (
    <textarea
      {...element.textareaProps}
      required={element.required || false}
      {...register(element.key, {
        ...element.registerOptions,
        required: element.required || false,
      })}
    />
  );
};

Alright, this looks clean, what do you think ? (don't hesitate to comment)

We need one more thing: a component that takes a generic ZFormElement and renders the correct component based on its type:

import React from "react";
import { UseFormReturn, FieldValues } from "react-hook-form";
import { ZFormElement } from "../@types";
import { ZInput } from "./ZInput";
import { ZSelect } from "./ZSelect";
import { ZTextarea } from "./ZTextarea";

export const RenderZElement = (props: {
  element: ZFormElement;
  hookForm: UseFormReturn<FieldValues, any>;
}) => {
  const { element, hookForm } = props;
  if (element.hidden)
    return <React.Fragment />; /** Don't render hidden fields */
  switch (element.type) {
    case "input":
      return <ZInput element={element} hookForm={hookForm} />;
    case "textarea":
      return <ZTextarea element={element} hookForm={hookForm} />;
    case "select":
      return <ZSelect element={element} hookForm={hookForm} />;
  }
};

Great! It takes shape little by little. We're ready for the big last step!

 

Creating our form renderer

The last piece of the puzzle: the form renderer component. We will just pass our form elements, submit function and optional props to it and let it render the whole thing for us:

export const ZForm = (props: ZFormProps) => {
  const { elements, onSubmit, submitButton, hookForm } = props;

  return (
    <form onSubmit={hookForm.handleSubmit(onSubmit)}>
      <div className="grid grid-cols-12 gap-5">
        {elements.map((res, key) => (
          <div
            key={`zform-element-${res.key}-${key}`}
            className={twMerge(
              `col-span-12 md:col-span-6`,
              `md:col-span-${res.colSpan}`
            )}
          >
            <RenderZElement element={res} hookForm={hookForm} />
          </div>
        ))}
      </div>
      <button className="mt-5" type="submit" {...submitButton?.props}>
        {submitButton?.text || "Submit"}
      </button>
    </form>
  );
};

 

Create forms the simple way

Let's test our new component. We will create a form with the following fields:

  • Firstname - Input
  • Lastname - Input
  • Email - input of type 'email'
  • Use case - select with options 'personal' & 'commercial'
  • Company name - input that will be rendered if use_case !== 'commercial'

This should look like this:

const Test = () => {
  const hookForm = useForm();
  const watchUseCase =
    hookForm.watch(
      "use_case"
    ); /** Allows us to know the value of use_case in real time */
    
  const elements: ZFormElement[] = [
    {
      key: "firstname",
      type: "input",
      label: "Firstname",
      colSpan: 6,
      required: true,
      inputProps: {
        placeholder: "Firstname",
      },
    },
    {
      key: "lastname",
      type: "input",
      label: "Lastname",
      colSpan: 6,
      required: true,
      inputProps: {
        placeholder: "Lastname",
      },
    },
    {
      key: "email",
      type: "input",
      label: "Email",
      colSpan: 12,
      required: true,
      inputProps: {
        type: "email",
        placeholder: "Email",
      },
    },
    {
      key: "use_case",
      type: "select",
      label: "Use case",
      colSpan: 6,
      required: true,
      choices: [
        {
          label: "Personal",
          value: "personal",
        },
        {
          label: "Commercial",
          value: "commercial",
        },
      ],
      selectProps: {
        placeholder: "Use case...",
      },
    },
    {
      key: "company_name",
      type: "input",
      label: "Company name",
      colSpan: 6,
      required: true,
      hidden: watchUseCase !== "commercial",
      inputProps: {
        placeholder: "Company name",
      },
    },
  ];
  return (
    <div className="container h-screen max-w-screen-lg">
      <div className="mx-auto w-1/2">
        <ZForm
          hookForm={hookForm}
          elements={elements}
          onSubmit={(data) => console.log(data)}
          submitButton={{
            props: {
              className:
                "mt-5 bg-accent text-center px-4 py-2 rounded-full font-bold w-full",
            },
          }}
        />
      </div>
    </div>
  );
};

This shit looks clean, let's see how it looks like:

Easy form creation & rendering

Perfect, just perfect. Note that I didn't display labels above inputs, it's just a personal preference so feel free to adjust the code as you wish.

 

Conclusion

Creating forms in your website is always a long and boring process, but put this time & effort into the creation of a big component that you will re-use for your forms. Also, don't hesitate to comment if you want to bring suggestions and ideas.