Build a Multistep Form With React Hook Form
Updated on · 8 min read|Multistep forms, also known as wizard or funnel forms, have a wide range of uses. They are most commonly used when collecting different types of information into one data structure, but they are also helpful for breaking down large forms into a less intimidating multistep process. They can be statically defined or dynamic, supporting a variable number of fields. Some popular examples of multistep forms include checkout and user registration forms.
In this post, we'll build a basic multistep registration form that collects user information and presents it in a confirmation view, which can be reviewed and edited before submission. It should be noted that the form would likely have more fields in a real-life application; however, we'll keep it simple for the sake of easier understanding. Additionally, the focus is on the JavaScript part, and proper form styling is outside the scope of this tutorial. The code for the tutorial is available on GitHub and CodeSandbox.
The final form looks like this:
This post is part of a series on working with multistep, also known as wizard, forms. It covers the basic setup for the form. Subsequent posts dive into more advanced aspects of multistep (wizard) forms:
- Saving form data during step navigation and displaying the state of each step in the Stepper component: Advanced Multistep Forms with React Hook Form.
- Displaying a warning if a user attempts to navigate away from the form while having unsaved data in the form: Display Warning for Unsaved Form Data on Page Exit.
If you're interested in the basics of working with React Hook Form, you may find this post helpful: Managing Forms with React Hook Form.
Choosing the form structure
One of the most challenging and essential aspects when working with multistep forms is deciding on their structure. Ultimately, the choice depends on the project requirements and the form's purpose. One common option is to use a single form component, inside which an active step is rendered. In this tutorial, we will use a different approach - each step component will be a separate form, collecting data from its fields and sending it to the central store on submit. This way, we achieve a nice separation of concerns, making it easier to adapt to future requirement changes.
For the form logic, we'll use the React Hook Form library, which greatly simplifies working with forms. For the central store, we'll use React Context, although a separate state management library (e.g., Redux) could be a better fit for more complicated cases. Lastly, each form step will have its own route, for which we'll use React Router.
To summarize, the app flow is as follows:
- Each step is a separate form with its own handlers and local state.
- Pressing
Next
submits the data from the current step to the centralized store and redirects to the route for the next step. - At the final step, all the data is shown, and users can edit any of the steps before submitting.
Setting up
As mentioned earlier, we're using React Context for persisting the state between the steps and later showing the form data in the preview before submitting. We'll abstract the context providers and consumers into a separate file for easier use.
jsx// state.js import { createContext, useContext, useState } from "react"; export const AppStateContext = createContext({}); export function AppProvider({ children }) { const value = useState({}); return ( <AppStateContext.Provider value={value}> {children} </AppStateContext.Provider> ); } export function useAppState() { const context = useContext(AppStateContext); if (!context) { throw new Error("useAppState must be used within the AppProvider"); } return context; }
jsx// state.js import { createContext, useContext, useState } from "react"; export const AppStateContext = createContext({}); export function AppProvider({ children }) { const value = useState({}); return ( <AppStateContext.Provider value={value}> {children} </AppStateContext.Provider> ); } export function useAppState() { const context = useContext(AppStateContext); if (!context) { throw new Error("useAppState must be used within the AppProvider"); } return context; }
Here, we create an AppProvider
, which handles the app state and related logic, and a custom hook, useAppState
. With this abstraction, we don't need to import AppStateContext
into every component. Additionally, we can validate that the component calling this hook is used inside the AppProvider
.
Next, we add the routes to the app and create the components for the form steps, wrapping all of this within the AppProvider
.
jsx// App.js import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { AppProvider } from "./state"; import { Contact } from "./Steps/Contact"; import { Education } from "./Steps/Education"; import { About } from "./Steps/About"; import { Confirm } from "./Steps/Confirm"; export const App = () => { return ( <div className="App"> <AppProvider> <Router> <Routes> <Route path="/" element={<Contact />} /> <Route path="/education" element={<Education />} /> <Route path="/about" element={<About />} /> <Route path="/confirm" element={<Confirm />} /> </Routes> </Router> </AppProvider> </div> ); };
jsx// App.js import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { AppProvider } from "./state"; import { Contact } from "./Steps/Contact"; import { Education } from "./Steps/Education"; import { About } from "./Steps/About"; import { Confirm } from "./Steps/Confirm"; export const App = () => { return ( <div className="App"> <AppProvider> <Router> <Routes> <Route path="/" element={<Contact />} /> <Route path="/education" element={<Education />} /> <Route path="/about" element={<About />} /> <Route path="/confirm" element={<Confirm />} /> </Routes> </Router> </AppProvider> </div> ); };
After that, let's set up the components for each step. Since they all will be form components, the code for each one will be quite similar. To abstract some repeated functionality, we create a separate Field
component:
jsx// Forms/Field.js import React from "react"; export const Field = ({ children, label, error }) => { const id = getChildId(children); return ( <div className="col-sm-12 mb-3"> <label htmlFor={id} className="form-label"> {label} </label> {children} {error && <small className="error">{error.message}</small>} </div> ); }; // Get id prop from a child element export const getChildId = (children) => { const child = React.Children.only(children); if ("id" in child?.props) { return child.props.id; } };
jsx// Forms/Field.js import React from "react"; export const Field = ({ children, label, error }) => { const id = getChildId(children); return ( <div className="col-sm-12 mb-3"> <label htmlFor={id} className="form-label"> {label} </label> {children} {error && <small className="error">{error.message}</small>} </div> ); }; // Get id prop from a child element export const getChildId = (children) => { const child = React.Children.only(children); if ("id" in child?.props) { return child.props.id; } };
This component also handles the error message and, as a bonus, sets the label's htmlFor
attribute to match the id
of the child input, making the input properly accessible. We just need to make sure that the input has an id
. We'll also abstract the logic for the Form
, Input
, and Button
components, although they are just wrappers for the native HTML elements with custom styling applied. They can be inspected on GitHub.
Adding the steps components
Now we can start creating the actual components for each step. They are deliberately simple, as it's easy to get lost in all the details and edge cases. The form will have four steps:
- Contact - Get basic user details - name, email, and password.
- Education - Let's imagine that our form is used on some sort of education or career website, and we would like to know the user's education details.
- About - A step with a free text field for the users to provide more detailed info about themselves.
- Confirm - This step is not used for entering details but for previewing and editing them, if necessary.
Additionally, all the fields in the first step will be required and have basic validation to demonstrate how it's usually done in React Hook Form.
jsx// Steps/Contact.js import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { useAppState } from "../state"; import { Button, Field, Form, Input } from "../Forms"; export const Contact = () => { const [state, setState] = useAppState(); const { handleSubmit, register, watch, formState: { errors }, } = useForm({ defaultValues: state, mode: "onSubmit" }); const watchPassword = watch("password"); const navigate = useNavigate(); const saveData = (data) => { setState({ ...state, ...data }); navigate("/education"); }; return ( <Form onSubmit={handleSubmit(saveData)}> <fieldset> <legend>Contact</legend> <Field label="First name" error={errors?.firstName}> <Input {...register("firstName", { required: "First name is required" })} id="first-name" /> </Field> <Field label="Last name" error={errors?.lastName}> <Input {...register("lastName", { required: "Last name is required" })} id="last-name" /> </Field> <Field label="Email" error={errors?.email}> <Input {...register("email", { required: "Email is required" })} type="email" id="email" /> </Field> <Field label="Password" error={errors?.password}> <Input {...register("password", { required: "Password is required" })} type="password" id="password" /> </Field> <Field label="Confirm password" error={errors?.confirmPassword}> <Input {...register("confirmPassword", { required: "Confirm the password", validate: (value) => value === watchPassword || "The passwords do not match", })} type="password" id="password-confirm" /> </Field> <Button>Next {">"}</Button> </fieldset> </Form> ); };
jsx// Steps/Contact.js import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { useAppState } from "../state"; import { Button, Field, Form, Input } from "../Forms"; export const Contact = () => { const [state, setState] = useAppState(); const { handleSubmit, register, watch, formState: { errors }, } = useForm({ defaultValues: state, mode: "onSubmit" }); const watchPassword = watch("password"); const navigate = useNavigate(); const saveData = (data) => { setState({ ...state, ...data }); navigate("/education"); }; return ( <Form onSubmit={handleSubmit(saveData)}> <fieldset> <legend>Contact</legend> <Field label="First name" error={errors?.firstName}> <Input {...register("firstName", { required: "First name is required" })} id="first-name" /> </Field> <Field label="Last name" error={errors?.lastName}> <Input {...register("lastName", { required: "Last name is required" })} id="last-name" /> </Field> <Field label="Email" error={errors?.email}> <Input {...register("email", { required: "Email is required" })} type="email" id="email" /> </Field> <Field label="Password" error={errors?.password}> <Input {...register("password", { required: "Password is required" })} type="password" id="password" /> </Field> <Field label="Confirm password" error={errors?.confirmPassword}> <Input {...register("confirmPassword", { required: "Confirm the password", validate: (value) => value === watchPassword || "The passwords do not match", })} type="password" id="password-confirm" /> </Field> <Button>Next {">"}</Button> </fieldset> </Form> ); };
jsx// Steps/Education.js import { useForm } from "react-hook-form"; import { useNavigate, Link } from "react-router-dom"; import { useAppState } from "../state"; import { Button, Field, Form, Input } from "../Forms"; export const Education = () => { const [state, setState] = useAppState(); const { handleSubmit, register } = useForm({ defaultValues: state }); const navigate = useNavigate(); const saveData = (data) => { setState({ ...state, ...data }); navigate("/about"); }; return ( <Form onSubmit={handleSubmit(saveData)}> <fieldset> <legend>Education</legend> <Field label="University"> <Input {...register("university")} id="university" /> </Field> <Field label="Degree"> <Input {...register("degree")} id="degree" /> </Field> <div className="button-row"> <Link className={`btn btn-secondary`} to="/"> {"<"} Previous </Link> <Button>Next {">"}</Button> </div> </fieldset> </Form> ); };
jsx// Steps/Education.js import { useForm } from "react-hook-form"; import { useNavigate, Link } from "react-router-dom"; import { useAppState } from "../state"; import { Button, Field, Form, Input } from "../Forms"; export const Education = () => { const [state, setState] = useAppState(); const { handleSubmit, register } = useForm({ defaultValues: state }); const navigate = useNavigate(); const saveData = (data) => { setState({ ...state, ...data }); navigate("/about"); }; return ( <Form onSubmit={handleSubmit(saveData)}> <fieldset> <legend>Education</legend> <Field label="University"> <Input {...register("university")} id="university" /> </Field> <Field label="Degree"> <Input {...register("degree")} id="degree" /> </Field> <div className="button-row"> <Link className={`btn btn-secondary`} to="/"> {"<"} Previous </Link> <Button>Next {">"}</Button> </div> </fieldset> </Form> ); };
jsx// Steps/About.js import { useForm } from "react-hook-form"; import { useNavigate, Link } from "react-router-dom"; import { useAppState } from "../state"; import { Button, Field, Form } from "../Forms"; export const About = () => { const [state, setState] = useAppState(); const { handleSubmit, register } = useForm({ defaultValues: state }); const navigate = useNavigate(); const saveData = (data) => { setState({ ...state, ...data }); navigate("/confirm"); }; return ( <Form onSubmit={handleSubmit(saveData)}> <fieldset> <legend>About</legend> <Field label="About me"> <textarea {...register("about")} id="about" className="form-control" /> </Field> <div className="button-row"> <Link className={`btn btn-secondary`} to="/education"> {"<"} Previous </Link> <Button>Next {">"}</Button> </div> </fieldset> </Form> ); };
jsx// Steps/About.js import { useForm } from "react-hook-form"; import { useNavigate, Link } from "react-router-dom"; import { useAppState } from "../state"; import { Button, Field, Form } from "../Forms"; export const About = () => { const [state, setState] = useAppState(); const { handleSubmit, register } = useForm({ defaultValues: state }); const navigate = useNavigate(); const saveData = (data) => { setState({ ...state, ...data }); navigate("/confirm"); }; return ( <Form onSubmit={handleSubmit(saveData)}> <fieldset> <legend>About</legend> <Field label="About me"> <textarea {...register("about")} id="about" className="form-control" /> </Field> <div className="button-row"> <Link className={`btn btn-secondary`} to="/education"> {"<"} Previous </Link> <Button>Next {">"}</Button> </div> </fieldset> </Form> ); };
React Hook Form internally handles the form state, so all we need to do is save it to our app state after it's been validated.
The final step, Confirm, is slightly different since we need to display the data entered by the user. We'll add a couple of section components, which we then compose to build the data presentation.
jsx// Forms/Section.js import { Link } from "react-router-dom"; export const Section = ({ title, children, url }) => { return ( <div className="section mb-4"> <div className="title-row mb-4"> <h4>{title}</h4> <Link className={`btn btn-secondary`} to={url}> Edit </Link> </div> <div className="content">{children}</div> </div> ); }; export const SectionRow = ({ children }) => { return <div className="section-row">{children}</div>; };
jsx// Forms/Section.js import { Link } from "react-router-dom"; export const Section = ({ title, children, url }) => { return ( <div className="section mb-4"> <div className="title-row mb-4"> <h4>{title}</h4> <Link className={`btn btn-secondary`} to={url}> Edit </Link> </div> <div className="content">{children}</div> </div> ); }; export const SectionRow = ({ children }) => { return <div className="section-row">{children}</div>; };
jsx// Steps/Confirm.js import { useForm } from "react-hook-form"; import { useAppState } from "../state"; import { Button, Form, Section, SectionRow } from "../Forms"; export const Confirm = () => { const [state] = useAppState(); const { handleSubmit } = useForm({ defaultValues: state }); const submitData = (data) => { console.info(data); // Submit data to the server }; return ( <Form onSubmit={handleSubmit(submitData)}> <h1 className="mb-4">Confirm</h1> <Section title="Personal info" url="/"> <SectionRow> <div>First name</div> <div>{state.firstName}</div> </SectionRow> <SectionRow> <div>Last name</div> <div>{state.lastName}</div> </SectionRow> <SectionRow> <div>Email</div> <div>{state.email}</div> </SectionRow> </Section> <Section title="Education" url="/education"> <SectionRow> <div>University</div> <div>{state.university}</div> </SectionRow> <SectionRow> <div>Degree</div> <div>{state.degree}</div> </SectionRow> </Section> <Section title="About" url="/about"> <SectionRow> <div>About me</div> <div>{state.about}</div> </SectionRow> </Section> <div className="d-flex justify-content-start"> <Button>Submit</Button> </div> </Form> ); };
jsx// Steps/Confirm.js import { useForm } from "react-hook-form"; import { useAppState } from "../state"; import { Button, Form, Section, SectionRow } from "../Forms"; export const Confirm = () => { const [state] = useAppState(); const { handleSubmit } = useForm({ defaultValues: state }); const submitData = (data) => { console.info(data); // Submit data to the server }; return ( <Form onSubmit={handleSubmit(submitData)}> <h1 className="mb-4">Confirm</h1> <Section title="Personal info" url="/"> <SectionRow> <div>First name</div> <div>{state.firstName}</div> </SectionRow> <SectionRow> <div>Last name</div> <div>{state.lastName}</div> </SectionRow> <SectionRow> <div>Email</div> <div>{state.email}</div> </SectionRow> </Section> <Section title="Education" url="/education"> <SectionRow> <div>University</div> <div>{state.university}</div> </SectionRow> <SectionRow> <div>Degree</div> <div>{state.degree}</div> </SectionRow> </Section> <Section title="About" url="/about"> <SectionRow> <div>About me</div> <div>{state.about}</div> </SectionRow> </Section> <div className="d-flex justify-content-start"> <Button>Submit</Button> </div> </Form> ); };
We could have gone further with abstractions and collected the data for display into a separate structure, which we'd then iterate over and render. However, going that much DRY with the code could prove problematic when we get new requirements, to which the code is not robust enough to adapt. One important rule to keep in mind is that it's way easier to DRY the code than to un-DRY it.
When using TypeScript with React Hook Form, it's important to properly type the form events and data. This is especially important when the form data is passed between the steps.
Adding a step indicator
The form, although minimally styled, is functional, which we can confirm by filling in all the details and submitting them. As it currently stands, users have no clear idea how many steps in total there are and where they are currently in the process. This is not good UX and makes multistep forms more intimidating than they are. We'll fix this by adding a step indicator, also known as a stepper. The Stepper
component is navigation for the form, but with the links disabled:
jsx// Steps/Stepper.js import { useLocation } from "react-router-dom"; export const Stepper = () => { const location = useLocation(); const getLinkClass = (path) => { return ( "nav-link disabled " + (path === location.pathname ? "active" : undefined) ); }; return ( <nav className="stepper navbar navbar-expand-lg"> <div className="navbar-collapse collapse"> <ol className="navbar-nav"> <li className="step nav-item"> <span className={getLinkClass("/")}>Contact</span> </li> <li className="step nav-item"> <span className={getLinkClass("/education")}>Education</span> </li> <li className="step nav-item"> <span className={getLinkClass("/about")}>About</span> </li> <li className="step nav-item"> <span className={getLinkClass("/confirm")}>Confirm</span> </li> </ol> </div> </nav> ); };
jsx// Steps/Stepper.js import { useLocation } from "react-router-dom"; export const Stepper = () => { const location = useLocation(); const getLinkClass = (path) => { return ( "nav-link disabled " + (path === location.pathname ? "active" : undefined) ); }; return ( <nav className="stepper navbar navbar-expand-lg"> <div className="navbar-collapse collapse"> <ol className="navbar-nav"> <li className="step nav-item"> <span className={getLinkClass("/")}>Contact</span> </li> <li className="step nav-item"> <span className={getLinkClass("/education")}>Education</span> </li> <li className="step nav-item"> <span className={getLinkClass("/about")}>About</span> </li> <li className="step nav-item"> <span className={getLinkClass("/confirm")}>Confirm</span> </li> </ol> </div> </nav> ); };
Since each form step has its own route, it is pretty simple to get the active step by the current page location. Now we can add this component to the App
:
jsx// App.js import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { AppProvider } from "./state"; import { Contact } from "./Steps/Contact"; import { Education } from "./Steps/Education"; import { About } from "./Steps/About"; import { Confirm } from "./Steps/Confirm"; import { Stepper } from "./Steps/Stepper"; export const App = () => { return ( <div className="App"> <AppProvider> <Router> <Stepper /> <Routes> <Route path="/" element={<Contact />} /> <Route path="/education" element={<Education />} /> <Route path="/about" element={<About />} /> <Route path="/confirm" element={<Confirm />} /> </Routes> </Router> </AppProvider> </div> ); };
jsx// App.js import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { AppProvider } from "./state"; import { Contact } from "./Steps/Contact"; import { Education } from "./Steps/Education"; import { About } from "./Steps/About"; import { Confirm } from "./Steps/Confirm"; import { Stepper } from "./Steps/Stepper"; export const App = () => { return ( <div className="App"> <AppProvider> <Router> <Stepper /> <Routes> <Route path="/" element={<Contact />} /> <Route path="/education" element={<Education />} /> <Route path="/about" element={<About />} /> <Route path="/confirm" element={<Confirm />} /> </Routes> </Router> </AppProvider> </div> ); };
With this change, we have a fully working multistep form with a progress indicator.
Avenues for Improvement
The form example in this tutorial is deliberately basic and concise. Several improvements can be made here, and some of them will be discussed in future tutorials. A few examples of the most obvious improvements are:
- The step indicator improves the UX quite a bit; however, once the user goes back to a step to edit it, they need to click
Next
until the last step is reached. This is quite annoying, particularly when going to the first step. To help with this, we can enable navigation via the stepper. - If we enable navigation via the stepper, we'd also look into saving the form state when navigating via the stepper to ensure that field validation is not bypassed.
- Alternatively, we could disable validation for the required form fields and display the status of each step (is any required data missing) in the stepper. This is helpful when we allow saving partial data as a draft.
- Currently, if the user enters some data and decides to navigate away from the form, they lose all their progress. This becomes a problem when such navigation is done by mistake. In this case, we could add a prompt that would ask the user to confirm if they want to move to another page and lose all their progress.
Testing the Form
Even though multistep forms seem more complex than the usual, one-page forms, the approach to testing here is the same. My preferred way would be to test each step component separately with the basic user flow - fill in the fields, test that they are validated, and verify that correct data is sent on submit. Thinking about testing from the user's perspective makes it easier. I have previously written about form testing and some best practices, most of which could also be applied here.
Conclusion
In this tutorial, we walked through the process of building a basic multistep registration form using React Hook Form, React Context, and React Router. We learned about structuring the form components, handling the form state, and adding a step indicator to improve the user experience.
While our example form was kept simple, there are numerous avenues for improvement that we briefly touched upon. You can apply the concepts presented in this tutorial to more complex use cases and further enhance the functionality of your multistep forms.
Remember, when building forms, it's essential to keep the user experience in mind and ensure that the process is as smooth and intuitive as possible. Hopefully, this tutorial has provided you with the foundational knowledge to create and optimize your own multistep forms in React. Stay tuned for more advanced topics in upcoming tutorials!
References and resources
- Advanced Multistep Forms with React Hook Form
- Build Dynamic Forms with React Hook Form
- Codesandbox for the tutorial
- Creating Accessible Form Components with React
- Display Warning for Unsaved Form Data on Page Exit
- Form Validation with React Hook Form
- GitHub repository with the code for the tutorial
- Improving React Testing Library Tests
- Managing Forms with React Hook Form
- React Context
- React Hook Form
- React Router
- Redux
- Simplifying Form Rendering In React with Field Component Abstraction
- Testing React Hook Form With React Testing Library
- TypeScript: Typing Form Events In React
- Typing React Context In TypeScript