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:
- React.js
- The form library react-hook-form (it is added by default to Makerkit)
- Immer, a library that helps us simply write immutable state.
- Various Makerkit components, such as a
Stepper
and aTextInput
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.
- 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.
- State is passed down using the Context. This won't be needed if you use Zustand (for example).
- Each step will have its own component. For example, the
details
step's form will be defined in theDetailsForm
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.
- We want to store the selected step's index
- For each step, we want the value of its fields and two additional
properties:
valid
anddirty
. 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:
- 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 theNext
submit button - 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:
- rendering the form components
- collecting data from the user
- setting the context's state when
valid
and whendirty
- setting the context's state values
- 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:
- You let the users manually finish the form using a button
- 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;