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;


Read more about Tutorials

Cover Image for Building an AI Writer SaaS with Next.js and Supabase

Building an AI Writer SaaS with Next.js and Supabase

Ā·57 min read
Learn how to build an AI Writer SaaS with Next.js and Supabase - from writing SEO optimized blog posts to managing subscriptions and billing.
Cover Image for Announcing the Data Loader SDK for Supabase

Announcing the Data Loader SDK for Supabase

Ā·8 min read
We're excited to announce the Data Loader SDK for Supabase. It's a declarative, type-safe set of utilities to load data into your Supabase database that you can use in your Next.js or Remix apps.
Cover Image for Adding AI capabilities to your Next.js SaaS with Supabase and HuggingFace

Adding AI capabilities to your Next.js SaaS with Supabase and HuggingFace

Ā·20 min read
In this tutorial, we will learn how to use add AI capabilities to your SaaS using Supabase Vector, HuggingFace models and Next.js Server Components.
Cover Image for Building an AI-powered Blog with Next.js and WordPress

Building an AI-powered Blog with Next.js and WordPress

Ā·17 min read
Learn how to build a blog with Next.js 13 and WordPress and how to leverage AI to generate content.
Cover Image for Using Supabase Vault to store secrets

Using Supabase Vault to store secrets

Ā·6 min read
Supabase Vault is a Postgres extension that allows you to store secrets in your database. This is a great way to store API keys, tokens, and other sensitive information. In this tutorial, we'll use Supabase Vault to store our API keys
Cover Image for Introduction to Next.js Server Actions

Introduction to Next.js Server Actions

Ā·9 min read
Next.js Server Actions are a new feature introduced in Next.js 13 that allows you to run server code without having to create an API endpoint. In this article, we'll learn how to use them.