Building Multi-Step forms with React.js

In this article, we explain how to build Multi-Step forms with Next.js and the library react-hook-form

·12 min read
Cover Image for Building Multi-Step forms with React.js

Multi-Step forms are commonly used to break down complex flows in simpler bits of data-entry; as such, they're found in most applications. With that said, there are various technical and UX challenges that web developers face when building proper multi-step forms.

In this article, I want to describe the conventions used in Makerkit to create multi-step forms with React.js. While some of the code uses Makerkit's components, you can easily apply the same principles to your components.

In this example, we will use:

  1. React.js
  2. The form library react-hook-form (it is added by default to Makerkit)
  3. Immer, a library that helps us simply write immutable state.
  4. Various Makerkit components, such as a Stepper and a TextInput field. As long as you follow the principles outlined in the post, consider these implementation details.

Overview

For building our multi-step forms, we'll use a Stepper component that allows users to see all the steps and also allows them to click on each step to navigate between them if these are valid and do not have pending changes.

  1. We will hold the state in a container component: switching pages will reset the state. If you'd like this to be global, you can opt for lifting the state or using a library such as Zustand.
  2. State is passed down using the Context. This won't be needed if you use Zustand (for example).
  3. Each step will have its own component. For example, the details step's form will be defined in the DetailsForm component. When submitted, the component will update the global state

The end result will look like the below:

Defining the Form's Steps

We add the steps to a global (component-level) constant named FORM_STEPS. We use an object for each item: in this way, we can add any additional properties to them.

In this article, we only add a label property:

const FORM_STEPS = [
  {
    label: `Details`,
  },
  {
    label: `Preferences`,
  },
  {
    label: `Complete`,
  },
];

Defining the Form's State

First, we want to define the default form state.

  1. We want to store the selected step's index
  2. For each step, we want the value of its fields and two additional properties: valid and dirty. We use these properties to determine whether the user can skip to the next step or not.
const FORM_STATE = {
  selectedIndex: 0,
  steps: {
    details: {
      valid: false,
      dirty: false,
      value: {
        name: '',
        dueDate: '',
      },
    },
    preferences: {
      valid: false,
      dirty: false,
      value: {
        receiveEmails: false,
        receiveNotifications: false,
      },
    },
  },
};

Then, we add the useState result to the Context API. This avoids the need of prop-drilling.

const FormStateContext = createContext({
  form: FORM_STATE,
  setForm: (
    form: typeof FORM_STATE | ((form: typeof FORM_STATE) => typeof FORM_STATE)
  ) => {},
});

Defining the Container Component

The Container component will be responsible for keeping the state of the form, and handling the event when the form is completed.

We create the form's state using useState, and we pass the default state FORM_STATE as its initial value (defined above).

const [form, setForm] = useState(FORM_STATE);

The full component will look like the below:

import { useCallback } from 'react';

function CreateTaskMultiStepFormContainer() {
  const [form, setForm] = useState(FORM_STATE);

  const onComplete = useCallback((state) => {
    // do something with "state"
  }, []);

  return (
    <FormStateContext.Provider
      value={{
        form,
        setForm,
      }}
    >
      <CreateTaskMultiStepForm />
    </FormStateContext.Provider>
  );
}

export default CreateTaskMultiStepFormContainer;

NB: the component above is the component exported that will need to be instantiated in your pages.

Creating the Steps using the Stepper component

Now, we're going to create the CreateTaskMultiStepForm component, which will render the Stepper component.

First, we want to use the Context created above using the useContext hook:

const { form, setForm } = useContext(FormStateContext);

We now want to define some functions to go back and forth. Then, we define the functions: next, prev and setSelectedStep:

// imports from our packages
import { useCallback } from 'react';
import { produce } from 'immer';

// in the component
const { form, setForm } = useContext(FormStateContext);

const next = useCallback(() => {
  setForm(
    produce((form) => {
      form.selectedIndex += 1;
    })
  );
}, [setForm]);

const prev = useCallback(() => {
  setForm(
    produce((form) => {
      form.selectedIndex -= 1;
    })
  );
}, [setForm]);

const setSelectedIndex = useCallback(
  (index: number) => {
    setForm(
      produce((form) => {
        form.selectedIndex = index;
      })
    );
  },
  [setForm]
);

Thanks to Immer's produce, we don't need to write immutable code, which makes our code a lot shorter and more straightforward. Of course, you don't need to use it, but it's convenient.

Now, let's create the render function of the component. For the time being, we're only adding the stepper: we will follow up with the individual forms.

For each step, we want to define the following:

  1. If the user can skip directly to the step using the onSelect callback, that is, when the user clicks on the step rather than on the Next submit button
  2. We want to add a couple of classes for styling reasons, such as when the step has been completed and when it's actionable (i.e., the user can click on it to jump to that step)
<Tab.Group selectedIndex={selectedIndex}>
  <Tab.List className={'Stepper mb-6'}>
    {FORM_STEPS.map((step, index) => {
      const canSelectStep = Object.values(form.steps)
        .slice(0, index)
        .every((step) => step.valid && !step.dirty);

      return (
        <StepperItem
          key={index}
          className={classNames({
            ['CompletedStep']: index < selectedIndex,
            ['StepperStepActionable']: canSelectStep,
          })}
          step={index + 1}
          onSelect={() => {
            if (canSelectStep) {
              setSelectedIndex(index);
            }
          }}
        >
          {step.label}
        </StepperItem>
      );
    })}
  </Tab.List>

  <Tab.Panels>
    <Tab.Panel>
      Here we will add the Details Form
    </Tab.Panel>

    <Tab.Panel>
      Here we will add the Preferences Form
    </Tab.Panel>

    <Tab.Panel>
      Here goes the last step
    </Tab.Panel>
  </Tab.Panels>
</Tab.Group>

The canSelectStep value is important; in fact, this defines whether a user can jump to another step or not. This value is determined as follows: take all the previous steps, and check they're both valid and are not "dirty", i.e. the user has pending fields that have been changed/touched, but have not yet submitted.

Building the Forms components

It's now time to build the forms components. The form components use react-hook-form, a hooks-based library that helps with writing forms with React.

Each form component has the following responsibilities:

  1. rendering the form components
  2. collecting data from the user
  3. setting the context's state when valid and when dirty
  4. setting the context's state values
  5. notifying the parent components when the submission is successful

Details Form

We will start with the first step's form, which collects two data points: The task's name and the due date.

The form components accept a property onNext: when the submission is valid, the forms will notify the parent to proceed to the next step.

function DetailsForm(
  props: React.PropsWithChildren<{
    onNext: () => void;
  }>
) {
  // implementation
}

Creating the Form

First, we define the form using useForm. Additionally, we will retrieve the current value of the form using the form's state context:

const { form, setForm } = useContext(FormStateContext);

const { register, handleSubmit, control } = useForm({
  shouldUseNativeValidation: true,
  defaultValues: {
    name: form.steps.details.value.name,
    dueDate: form.steps.details.value.dueDate,
  },
});

Why set the values again? This is important, as the component is re-rendered when switching back and forth. Without doing this, the values would be lost.

Registering the Controls

Using the register function we will create the form controls and set them as required. Additionally, we extract the ref property and add it manually because we use a custom React component. Therefore, if you use a normal input field, it is not necessary.

const { ref: nameRef, ...nameControl }
  = register('name', { required: true });

const { ref: dueDateRef, ...dueDateControl }
  = register('dueDate', {
      required: true,
    });

Updating the state when the form status changes

Whenever the form becomes dirty, we must set the state to avoid forbidden navigation to other steps. This is good for UX: if the field has been touched without a submission, you want the user to confirm their changes by using the submit button.

const { isDirty } = useFormState({
  control,
});

useEffect(() => {
  setForm(
    produce((form) => {
      form.steps.details.dirty = isDirty;
    })
  );
}, [isDirty, setForm]);

Rendering the form fields

Below we render the form and its fields. Additionally, we use handleSubmit to handle the form submission: when valid, we set the form's values using setForm.

Afterward, we call the props.onNext() callback to proceed to the next step.

<form
  onSubmit={handleSubmit((value) => {
    setForm(
      produce((formState) => {
        formState.steps.details = {
          value,
          valid: true,
          dirty: false,
        };
      })
    );

    props.onNext();
  })}
>
  <div className={'flex flex-col space-y-4'}>
    <TextField.Label>
      Task Name
      <TextField.Input {...nameControl} innerRef={nameRef} />
    </TextField.Label>

    <TextField.Label>
      Due date
      <TextField.Input
        type={'date'}
        {...dueDateControl}
        innerRef={dueDateRef}
      />
    </TextField.Label>

    <Button>Next</Button>
  </div>
</form>

And this form component is finally complete! Now we can add it to the first plan of the Stepper component:

<Tab.Panel>
  <div className={'flex w-full flex-col space-y-6'}>
    <div>
      <Heading type={3}>Details</Heading>
    </div>

    <DetailsForm onNext={next} />
  </div>
</Tab.Panel>

Preferences Form

The preferences form will follow precisely the same pattern, which doesn't need further explanations.

What you should notice is that we pass a property onPrev: in fact, being the second step should allow going back to the previous step.

function PreferencesForm(
  props: React.PropsWithChildren<{
    onNext: () => void;
    onPrev: () => void;
  }>
) {
  const { form, setForm } = useContext(FormStateContext);

  const { register, handleSubmit, control } = useForm({
    shouldUseNativeValidation: true,
    defaultValues: form.steps.preferences.value,
  });

  const { isDirty } = useFormState({ control });

  const receiveEmailsControl = register('receiveEmails');
  const receiveNotificationsControl = register('receiveNotifications');

  useEffect(() => {
    setForm(
      produce((form) => {
        form.steps.preferences.dirty = isDirty;
      })
    );
  }, [isDirty, setForm]);

  return (
    <form
      onSubmit={handleSubmit((value) => {
        setForm(
          produce((state) => {
            state.steps.preferences = {
              valid: true,
              dirty: false,
              value,
            };
          })
        );

        props.onNext();
      })}
    >
      <div className={'flex w-full flex-col space-y-4'}>
        <Label className={'flex items-center space-x-4'}>
          <input
            type={'checkbox'}
            className={'Toggle'}
            {...receiveEmailsControl}
          />

          <span>Receive Emails</span>
        </Label>

        <Label className={'flex items-center space-x-4'}>
          <input
            type={'checkbox'}
            className={'Toggle'}
            {...receiveNotificationsControl}
          />

          <span>Receive Notifications</span>
        </Label>

        <div className={'flex space-x-2'}>
          <Button>Next</Button>

          <Button color={'transparent'} onClick={props.onPrev}>
            Back
          </Button>
        </div>
      </div>
    </form>
  );
}

Then, we add this new form to the Stepper:

<Tab.Panel>
  <div className={'flex w-full flex-col space-y-6'}>
    <div>
      <Heading type={3}>Preferences</Heading>
    </div>

    <PreferencesForm onNext={next} onPrev={prev} />
  </div>
</Tab.Panel>

Completing the Form

Once we're in the last step, we simply need to notify our container component that the form has been completed.

You have two choices:

  1. You let the users manually finish the form using a button
  2. You automatically complete the form when the user ends the flow

While the first case is relatively simple to achieve, let's see how to automatically complete the flow.

We can do so using useEffect and checking if the selected index is the same as the length of the steps.

useEffect(() => {
  const lastStepIndex = FORM_STEPS.length;

  if (form.selectedIndex === lastStepIndex) {
    onComplete();
  }
}, [form.selectedIndex, onComplete]);

The callback onComplete can make an HTTP call to save the information and then redirect the user to another page.

Here is the complete code:

import { useCallback, useState } from 'react';

function CreateTaskMultiStepFormContainer() {
  const [form, setForm] = useState(FORM_STATE);

  const onComplete = useCallback((state) => {
    // do something with "state"
  }, []);

  useEffect(() => {
    const lastStepIndex = FORM_STEPS.length;

    if (form.selectedIndex === lastStepIndex) {
      onComplete();
    }
  }, [form.selectedIndex, onComplete]);

  return (
    <FormStateContext.Provider
      value={{
        form,
        setForm,
      }}
    >
      <CreateTaskMultiStepForm />
    </FormStateContext.Provider>
  );
}

export default CreateTaskMultiStepFormContainer;

If we print the final state value, we will have the following data structure:

{
  "selectedIndex": 2,
  "steps": {
    "details": {
      "value": {
        "name": "Build Multi step form",
        "dueDate": "2022-10-28"
      },
      "valid": true,
      "dirty": false
    },
    "preferences": {
      "valid": true,
      "dirty": false,
      "value": {
        "receiveEmails": true,
        "receiveNotifications": true
      }
    }
  }
}

And here's the final demo of our code:

Loading video...

Full Source Code

Below is the full source code of the component:

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useState,
} from 'react';

import { produce } from 'immer';
import classNames from 'classnames';

import { Tab } from '@headlessui/react';
import { useForm, useFormState } from 'react-hook-form';

import Heading from '~/core/ui/Heading';
import Button from '~/core/ui/Button';
import StepperItem from '~/core/ui/StepperItem';
import TextField from '~/core/ui/TextField';
import Label from '~/core/ui/Label';

const FORM_STATE = {
  selectedIndex: 0,
  steps: {
    details: {
      valid: false,
      dirty: false,
      value: {
        name: '',
        dueDate: '',
      },
    },
    preferences: {
      valid: false,
      dirty: false,
      value: {
        receiveEmails: false,
        receiveNotifications: false,
      },
    },
  },
};

const FORM_STEPS = [
  {
    label: `Details`,
  },
  {
    label: `Preferences`,
  },
  {
    label: `Complete`,
  },
];

const FormStateContext = createContext({
  form: FORM_STATE,
  setForm: (
    form: typeof FORM_STATE | ((form: typeof FORM_STATE) => typeof FORM_STATE)
  ) => {},
});

function CreateTaskMultiStepFormContainer() {
  const [form, setForm] = useState(FORM_STATE);

  return (
    <FormStateContext.Provider
      value={{
        form,
        setForm,
      }}
    >
      <CreateTaskMultiStepForm />
    </FormStateContext.Provider>
  );
}

const CreateTaskMultiStepForm = () => {
  const { form, setForm } = useContext(FormStateContext);

  const next = useCallback(() => {
    setForm(
      produce((form) => {
        form.selectedIndex += 1;
      })
    );
  }, [setForm]);

  const prev = useCallback(() => {
    setForm(
      produce((form) => {
        form.selectedIndex -= 1;
      })
    );
  }, [setForm]);

  const setSelectedIndex = useCallback(
    (index: number) => {
      setForm(
        produce((form) => {
          form.selectedIndex = index;
        })
      );
    },
    [setForm]
  );

  const selectedIndex = form.selectedIndex;

  return (
    <Tab.Group selectedIndex={selectedIndex}>
      <Tab.List className={'Stepper mb-6'}>
        {FORM_STEPS.map((step, index) => {
          const canSelectStep = Object.values(form.steps)
            .slice(0, index)
            .every((step) => step.valid && !step.dirty);

          return (
            <StepperItem
              key={index}
              className={classNames({
                ['CompletedStep']: index < selectedIndex,
                ['StepperStepActionable']: canSelectStep,
              })}
              step={index + 1}
              onSelect={() => {
                if (canSelectStep) {
                  setSelectedIndex(index);
                }
              }}
            >
              {step.label}
            </StepperItem>
          );
        })}
      </Tab.List>

      <Tab.Panels>
        <Tab.Panel>
          <div className={'flex w-full flex-col space-y-6'}>
            <div>
              <Heading type={3}>Details</Heading>
            </div>

            <DetailsForm onNext={next} />
          </div>
        </Tab.Panel>

        <Tab.Panel>
          <div className={'flex w-full flex-col space-y-6'}>
            <div>
              <Heading type={3}>Preferences</Heading>
            </div>

            <PreferencesForm onNext={next} onPrev={prev} />
          </div>
        </Tab.Panel>

        <Tab.Panel>
          <div className={'flex w-full flex-col space-y-6'}>
            <div>
              <Heading type={3}>Complete</Heading>
            </div>

            <pre>{JSON.stringify(form, null, 2)}</pre>

            <div className={'flex space-x-2'}>
              <Button>Proceed to your Dashboard</Button>
              <Button color={'transparent'} onClick={prev}>
                Back
              </Button>
            </div>
          </div>
        </Tab.Panel>
      </Tab.Panels>
    </Tab.Group>
  );
};

function DetailsForm(
  props: React.PropsWithChildren<{
    onNext: () => void;
  }>
) {
  const { form, setForm } = useContext(FormStateContext);

  const { register, handleSubmit, control } = useForm({
    shouldUseNativeValidation: true,
    defaultValues: {
      name: form.steps.details.value.name,
      dueDate: form.steps.details.value.dueDate,
    },
  });

  const { isDirty } = useFormState({
    control,
  });

  const { ref: nameRef, ...nameControl } = register('name', { required: true });

  const { ref: dueDateRef, ...dueDateControl } = register('dueDate', {
    required: true,
  });

  useEffect(() => {
    setForm(
      produce((form) => {
        form.steps.details.dirty = isDirty;
      })
    );
  }, [isDirty, setForm]);

  return (
    <form
      onSubmit={handleSubmit((value) => {
        setForm(
          produce((formState) => {
            formState.steps.details = {
              value,
              valid: true,
              dirty: false,
            };
          })
        );

        props.onNext();
      })}
    >
      <div className={'flex flex-col space-y-4'}>
        <TextField.Label>
          Task Name
          <TextField.Input {...nameControl} innerRef={nameRef} />
        </TextField.Label>

        <TextField.Label>
          Due date
          <TextField.Input
            type={'date'}
            {...dueDateControl}
            innerRef={dueDateRef}
          />
        </TextField.Label>

        <Button>Next</Button>
      </div>
    </form>
  );
}

function PreferencesForm(
  props: React.PropsWithChildren<{
    onNext: () => void;
    onPrev: () => void;
  }>
) {
  const { form, setForm } = useContext(FormStateContext);

  const { register, handleSubmit, control } = useForm({
    shouldUseNativeValidation: true,
    defaultValues: form.steps.preferences.value,
  });

  const { isDirty } = useFormState({ control });

  const receiveEmailsControl = register('receiveEmails');
  const receiveNotificationsControl = register('receiveNotifications');

  useEffect(() => {
    setForm(
      produce((form) => {
        form.steps.preferences.dirty = isDirty;
      })
    );
  }, [isDirty, setForm]);

  return (
    <form
      onSubmit={handleSubmit((value) => {
        setForm(
          produce((state) => {
            state.steps.preferences = {
              valid: true,
              dirty: false,
              value,
            };
          })
        );

        props.onNext();
      })}
    >
      <div className={'flex w-full flex-col space-y-4'}>
        <Label className={'flex items-center space-x-4'}>
          <input
            type={'checkbox'}
            className={'Toggle'}
            {...receiveEmailsControl}
          />

          <span>Receive Emails</span>
        </Label>

        <Label className={'flex items-center space-x-4'}>
          <input
            type={'checkbox'}
            className={'Toggle'}
            {...receiveNotificationsControl}
          />

          <span>Receive Notifications</span>
        </Label>

        <div className={'flex space-x-2'}>
          <Button>Next</Button>

          <Button color={'transparent'} onClick={props.onPrev}>
            Back
          </Button>
        </div>
      </div>
    </form>
  );
}

export default CreateTaskMultiStepFormContainer;

Stay informed with our latest resources for building a SaaS

Subscribe to our newsletter to receive updatesor

Read more about

Cover Image for Authenticating users with Remix and Supabase

Authenticating users with Remix and Supabase

·16 min read
Learn how to use Remix and Supabase to authenticate users in your application.
Cover Image for How Makerkit helps boost your SaaS SEO

How Makerkit helps boost your SaaS SEO

·4 min read
Learn how Makerkit can help boost your SaaS SEO thanks to its optimized codebase and SEO-friendly features.
Cover Image for How to sell code with Gumroad and Github

How to sell code with Gumroad and Github

·7 min read
Sell and monetize your code by giving private access to your Github repositories using Gumroad
Cover Image for Migrating to Next.js Server Components Layouts

Migrating to Next.js Server Components Layouts

·6 min read
A simple guide to migrating your _app.tsx component to the new Server Components released with Next.js 13
Cover Image for Getting Started with Next.js Server Components

Getting Started with Next.js Server Components

·8 min read
A simple introduction to using Server Components and the new Layouts Folder Structure with Next.js 13
Cover Image for Counting a collection's documents with Firebase Firestore

Counting a collection's documents with Firebase Firestore

·2 min read
In this article, we learn how to count the number of documents in a Firestore collection using a custom React.js hook.