Advanced Multistep Forms with React Hook Form
Updated on · 7 min read|In the previous post, we built a basic registration multistep form using React and React Hook Form. While the form works well for a straightforward signup workflow where users don't need to navigate between steps, this article will explore a more flexible form design. We'll consider scenarios where the order of steps is not fixed and users don't need to provide all the information at once.
To keep things simple, we'll build upon the form example from the previous tutorial, although this particular workflow might not be the best fit for a registration form. Instead, imagine we're creating a checkout form where users fill in information step-by-step and can save the form as a draft to return to later. You can test the final result on CodeSandbox and the code is also available on GitHub.
This post is part of a series on working with multistep, also known as wizard, forms. Other posts explore various aspects of multistep (wizard) forms:
-
Basic setup for the form and step navigation: Build a Multistep Form 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.
Saving form data on step navigation
In the previous post, we identified several areas for improvement to make our multistep form more flexible:
- Save entered data when navigating between steps, and provide user feedback.
- Prevent form validation bypass when navigating by clicking on a step, which could lead to an incomplete form submission.
To address these concerns, we'll make the following improvements:
- Remove field validation for each step, and instead, display the state of each step in the Stepper.
- Save the entered form data on step change to prevent data loss during navigation (however, data won't be saved when clicking the
Previous
button). - Highlight missing fields on the confirmation page, allowing users to go back and fill them in if needed.
The final result will look like this:
First, we remove all validation from individual steps, as it will now be handled in the final step. Next, we ensure that form data is submitted when a user clicks on a step. To accomplish this, we'll treat navigation between steps the same as clicking the Next
button. It will save the current data to the shared context. This requires triggering the button's onClick
event from the Stepper
component, which is where the useImperativeHandle hook comes in handy. This hook allows for calling the ref target's methods outside its component (e.g., from parent components). Note that this hook changes the control flow of the component, making it harder to reason about, and should be used sparingly.
Let's start by wrapping the Button
and individual step components in forwardRef to enable receiving ref
as a prop, like so:
jsx// Steps/Contact.js import { forwardRef } from "react"; export const Contact = forwardRef((props, ref) => { //... return ( <Form onSubmit={handleSubmit(saveData)}> //... <Button ref={ref}>Next {">"}</Button> </Form> ); });
jsx// Steps/Contact.js import { forwardRef } from "react"; export const Contact = forwardRef((props, ref) => { //... return ( <Form onSubmit={handleSubmit(saveData)}> //... <Button ref={ref}>Next {">"}</Button> </Form> ); });
Triggering the form submit with the useImperativeHandle hook
Next, we set up the useImperativeHandle
hook inside the Button
component, exposing the button's click
event:
jsx// Forms/Button.js import { forwardRef, useImperativeHandle, useRef } from "react"; export const Button = forwardRef( ({ children, variant = "primary", ...props }, ref) => { const buttonRef = useRef(); useImperativeHandle(ref, () => ({ click: () => { buttonRef.current?.click(); }, })); return ( <button className={`btn btn-${variant}`} {...props} ref={buttonRef}> {children} </button> ); }, );
jsx// Forms/Button.js import { forwardRef, useImperativeHandle, useRef } from "react"; export const Button = forwardRef( ({ children, variant = "primary", ...props }, ref) => { const buttonRef = useRef(); useImperativeHandle(ref, () => ({ click: () => { buttonRef.current?.click(); }, })); return ( <button className={`btn btn-${variant}`} {...props} ref={buttonRef}> {children} </button> ); }, );
This enables us to imperatively trigger the button's onClick
event from outside the component.
Finally, we will create a shared ref
at the App level and an onStepChange
callback, which will be assigned to each Link's onClick
event. We could define onStepChange
directly within the Stepper
component, but doing so would require wrapping it in forwardRef
to accept the buttonRef
.
jsx// App.js import { useRef } from "react"; 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 = () => { const buttonRef = useRef(); const onStepChange = () => { buttonRef.current?.click(); }; return ( <div className="App"> <AppProvider> <Router> <Stepper onStepChange={onStepChange} /> <Routes> <Route path="/" element={<Contact ref={buttonRef} />} /> <Route path="/education" element={<Education ref={buttonRef} />} /> <Route path="/about" element={<About ref={buttonRef} />} /> <Route path="/confirm" element={<Confirm />} /> </Routes> </Router> </AppProvider> </div> ); };
jsx// App.js import { useRef } from "react"; 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 = () => { const buttonRef = useRef(); const onStepChange = () => { buttonRef.current?.click(); }; return ( <div className="App"> <AppProvider> <Router> <Stepper onStepChange={onStepChange} /> <Routes> <Route path="/" element={<Contact ref={buttonRef} />} /> <Route path="/education" element={<Education ref={buttonRef} />} /> <Route path="/about" element={<About ref={buttonRef} />} /> <Route path="/confirm" element={<Confirm />} /> </Routes> </Router> </AppProvider> </div> ); };
The form now seems to work correctly, and data is saved when navigating to another step. However, there's a small issue: sometimes, when attempting to move multiple steps forward (e.g., from Contact
to Confirm
), the form only navigates one step at a time. This occurs due to conflicting navigation types — one from the Stepper's NavLink
and another from the form's onSubmit
callback.
Updating the Form component
To resolve the navigation conflict, we'll modify the Form
component to include a custom onSubmit
callback and handle navigation to the next step from there. This way, when navigation is triggered from the Form
, the stepper navigation is already in progress, and the form's navigation is discarded.
jsx// Forms/Form.js import { useNavigate } from "react-router-dom"; export const Form = ({ children, onSubmit, nextStep, ...props }) => { const navigate = useNavigate(); const onSubmitCustom = (e) => { e.preventDefault(); onSubmit(); navigate(nextStep); }; return ( <form className="row" onSubmit={onSubmitCustom} {...props} noValidate> {children} </form> ); };
jsx// Forms/Form.js import { useNavigate } from "react-router-dom"; export const Form = ({ children, onSubmit, nextStep, ...props }) => { const navigate = useNavigate(); const onSubmitCustom = (e) => { e.preventDefault(); onSubmit(); navigate(nextStep); }; return ( <form className="row" onSubmit={onSubmitCustom} {...props} noValidate> {children} </form> ); };
With these changes in place, we now provide the nextStep
function to the form from each step to complete the navigation. The updated steps will appear as follows:
jsx// Steps/Contact.js import { forwardRef } from "react"; import { useForm } from "react-hook-form"; import { useAppState } from "../state"; import { Button, Field, Form, Input } from "../Forms"; export const Contact = forwardRef((props, ref) => { const [state, setState] = useAppState(); const { handleSubmit, register } = useForm({ defaultValues: state, mode: "onSubmit", }); const saveData = (data) => { setState({ ...state, ...data }); }; return ( <Form onSubmit={handleSubmit(saveData)} nextStep={"/education"}> <fieldset> <legend>Contact</legend> <Field label="First name"> <Input {...register("firstName")} id="first-name" /> </Field> <Field label="Last name"> <Input {...register("lastName")} id="last-name" /> </Field> <Field label="Email"> <Input {...register("email")} type="email" id="email" /> </Field> <Field label="Password"> <Input {...register("password")} type="password" id="password" /> </Field> <Button ref={ref}>Next {">"}</Button> </fieldset> </Form> ); });
jsx// Steps/Contact.js import { forwardRef } from "react"; import { useForm } from "react-hook-form"; import { useAppState } from "../state"; import { Button, Field, Form, Input } from "../Forms"; export const Contact = forwardRef((props, ref) => { const [state, setState] = useAppState(); const { handleSubmit, register } = useForm({ defaultValues: state, mode: "onSubmit", }); const saveData = (data) => { setState({ ...state, ...data }); }; return ( <Form onSubmit={handleSubmit(saveData)} nextStep={"/education"}> <fieldset> <legend>Contact</legend> <Field label="First name"> <Input {...register("firstName")} id="first-name" /> </Field> <Field label="Last name"> <Input {...register("lastName")} id="last-name" /> </Field> <Field label="Email"> <Input {...register("email")} type="email" id="email" /> </Field> <Field label="Password"> <Input {...register("password")} type="password" id="password" /> </Field> <Button ref={ref}>Next {">"}</Button> </fieldset> </Form> ); });
The other two steps (Education and About) need to be updated similarly. You'll notice that, in addition to removing field validation, we've also removed the password confirmation field to simplify the form. The field validation has now been moved to the final step, Confirm.
Showing field state on the Confirm page
To streamline rendering and validation, we'll store the entire form data to be displayed as an array of field objects, divided into sections.
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 }; const data = [ { title: "Personal info", url: "/", items: [ { name: "First name", value: state.firstName, required: true }, { name: "Last name", value: state.lastName, required: true }, { name: "Email", value: state.email, required: true }, { name: "Password", value: !!state.password ? "*****" : "", required: true, }, ], }, { title: "Education", url: "/education", items: [ { name: "University", value: state.university }, { name: "Degree", value: state.degree }, ], }, { title: "About", url: "/about", items: [{ name: "About me", value: state.about }], }, ]; const disableSubmit = data.some((section) => section.items.some((item) => item.required && !item.value), ); return ( <Form onSubmit={handleSubmit(submitData)}> <h1 className="mb-4">Confirm</h1> {data.map(({ title, url, items }) => { return ( <Section title={title} url={url} key={title}> {items.map(({ name, value }) => { return ( <SectionRow key={name}> <div>{name}</div> <div>{value}</div> </SectionRow> ); })} </Section> ); })} <div className="d-flex justify-content-start"> <Button disabled={disableSubmit}>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 }; const data = [ { title: "Personal info", url: "/", items: [ { name: "First name", value: state.firstName, required: true }, { name: "Last name", value: state.lastName, required: true }, { name: "Email", value: state.email, required: true }, { name: "Password", value: !!state.password ? "*****" : "", required: true, }, ], }, { title: "Education", url: "/education", items: [ { name: "University", value: state.university }, { name: "Degree", value: state.degree }, ], }, { title: "About", url: "/about", items: [{ name: "About me", value: state.about }], }, ]; const disableSubmit = data.some((section) => section.items.some((item) => item.required && !item.value), ); return ( <Form onSubmit={handleSubmit(submitData)}> <h1 className="mb-4">Confirm</h1> {data.map(({ title, url, items }) => { return ( <Section title={title} url={url} key={title}> {items.map(({ name, value }) => { return ( <SectionRow key={name}> <div>{name}</div> <div>{value}</div> </SectionRow> ); })} </Section> ); })} <div className="d-flex justify-content-start"> <Button disabled={disableSubmit}>Submit</Button> </div> </Form> ); };
It's important to note that, while this approach appears cleaner than defining all the sections and items separately in JSX, it can quickly become difficult to manage when requirements change, especially if additional rendering logic is introduced.
We've added a new required
field to the items
array, which will be used to disable form submission if any required fields are missing and to highlight which fields are mandatory. To accomplish this, we iterate over all the items and check if any of the required fields are empty. After, we can pass this value to the form's Submit
button to control its disabled
state.
To make required fields more visible, we'll highlight the field name using Bootstrap's warning yellow color and display an exclamation mark in place of the field's value.
jsx// Steps/Confirm.js <Section title={title} url={url} key={title}> {items.map(({ name, value, required }) => { const isMissingValue = required && !value; return ( <SectionRow key={name}> <div className={isMissingValue ? "text-warning" : ""}>{name}</div> <div> {isMissingValue ? <span className={"warning-sign"}>!</span> : value} </div> </SectionRow> ); })} </Section>
jsx// Steps/Confirm.js <Section title={title} url={url} key={title}> {items.map(({ name, value, required }) => { const isMissingValue = required && !value; return ( <SectionRow key={name}> <div className={isMissingValue ? "text-warning" : ""}>{name}</div> <div> {isMissingValue ? <span className={"warning-sign"}>!</span> : value} </div> </SectionRow> ); })} </Section>
As a result, we get a nice highlight for the required fields. You can find the styles for the section in the tutorial repository.
Displaying step state in the stepper
As a final visual enhancement, we can display the state of each step in the navigation. Unvisited steps will have no styling, while steps with missing fields will show a warning icon and steps with no missing required fields will display a success icon.
We begin by tracking the visited steps in the context. Then, we use this data to display the appropriate state indicator. To reduce clutter in the code, we create a helper component for rendering step state.
jsx// Steps/Stepper.js const StepState = ({ showWarning, showSuccess }) => { if (showWarning) { return <span className={"warning-sign"}>!</span>; } else if (showSuccess) { return ( <div className="checkmark"> <div className="circle"></div> <div className="stem"></div> <div className="tick"></div> </div> ); } else { return null; } };
jsx// Steps/Stepper.js const StepState = ({ showWarning, showSuccess }) => { if (showWarning) { return <span className={"warning-sign"}>!</span>; } else if (showSuccess) { return ( <div className="checkmark"> <div className="circle"></div> <div className="stem"></div> <div className="tick"></div> </div> ); } else { return null; } };
In this helper component, we'll simply render the state icon based on the value of boolean props. To track the visited steps, we can leverage hooks from React Router and save the pathname from the current location as a visited step.
jsx// Steps/Stepper.js const location = useLocation(); const [steps, setSteps] = useState([]); useEffect(() => { setSteps((steps) => [...steps, location.pathname]); }, [location]);
jsx// Steps/Stepper.js const location = useLocation(); const [steps, setSteps] = useState([]); useEffect(() => { setSteps((steps) => [...steps, location.pathname]); }, [location]);
We use the functional form of setState
here to avoid declaring steps
as one of the useEffect
dependencies, which could break the app due to an infinite rendering loop. We're not concerned with ensuring that visited steps are unique. Although we could check if a step is already added before adding a new one, this seems like a minor optimization.
Finally, we can add the step state indicators to the Stepper. For easier rendering, we collect the data for the navigation links into an array of objects and render them in a loop.
jsx// Steps/Stepper.js import { useEffect, useState } from "react"; import { NavLink, useLocation } from "react-router-dom"; import { useAppState } from "../state"; export const Stepper = ({ onStepChange }) => { const [state] = useAppState(); const location = useLocation(); const [steps, setSteps] = useState([]); useEffect(() => { setSteps((steps) => [...steps, location.pathname]); }, [location]); const getLinkClass = ({ isActive }) => `nav-link ${isActive ? "active" : undefined}`; const contactInfoMissing = !state.firstName || !state.email || !state.password; const isVisited = (step) => steps.includes(step) && location.pathname !== step; const navLinks = [ { url: "/", name: "Contact", state: { showWarning: isVisited("/") && contactInfoMissing, showSuccess: isVisited("/") && !contactInfoMissing, }, }, { url: "/education", name: "Education", state: { showSuccess: isVisited("/education"), }, }, { url: "/about", name: "About", state: { showSuccess: isVisited("/about"), }, }, { url: "/confirm", name: "Confirm", state: {}, }, ]; return ( <nav className="stepper navbar navbar-expand-lg"> <div className="navbar-collapse collapse"> <ol className="navbar-nav"> {navLinks.map(({ url, name, state }) => { return ( <li className="step nav-item" key={url}> <StepState showWarning={state.showWarning} showSuccess={state.showSuccess} /> <NavLink end to={url} className={getLinkClass} onClick={onStepChange} > {name} </NavLink> </li> ); })} </ol> </div> </nav> ); }; const StepState = ({ showWarning, showSuccess }) => { if (showWarning) { return <span className={"warning-sign"}>!</span>; } else if (showSuccess) { return ( <div className="checkmark"> <div className="circle"></div> <div className="stem"></div> <div className="tick"></div> </div> ); } else { return null; } };
jsx// Steps/Stepper.js import { useEffect, useState } from "react"; import { NavLink, useLocation } from "react-router-dom"; import { useAppState } from "../state"; export const Stepper = ({ onStepChange }) => { const [state] = useAppState(); const location = useLocation(); const [steps, setSteps] = useState([]); useEffect(() => { setSteps((steps) => [...steps, location.pathname]); }, [location]); const getLinkClass = ({ isActive }) => `nav-link ${isActive ? "active" : undefined}`; const contactInfoMissing = !state.firstName || !state.email || !state.password; const isVisited = (step) => steps.includes(step) && location.pathname !== step; const navLinks = [ { url: "/", name: "Contact", state: { showWarning: isVisited("/") && contactInfoMissing, showSuccess: isVisited("/") && !contactInfoMissing, }, }, { url: "/education", name: "Education", state: { showSuccess: isVisited("/education"), }, }, { url: "/about", name: "About", state: { showSuccess: isVisited("/about"), }, }, { url: "/confirm", name: "Confirm", state: {}, }, ]; return ( <nav className="stepper navbar navbar-expand-lg"> <div className="navbar-collapse collapse"> <ol className="navbar-nav"> {navLinks.map(({ url, name, state }) => { return ( <li className="step nav-item" key={url}> <StepState showWarning={state.showWarning} showSuccess={state.showSuccess} /> <NavLink end to={url} className={getLinkClass} onClick={onStepChange} > {name} </NavLink> </li> ); })} </ol> </div> </nav> ); }; const StepState = ({ showWarning, showSuccess }) => { if (showWarning) { return <span className={"warning-sign"}>!</span>; } else if (showSuccess) { return ( <div className="checkmark"> <div className="circle"></div> <div className="stem"></div> <div className="tick"></div> </div> ); } else { return null; } };
The Education and About steps will display a success state if they have been visited, as they don't have any required fields. If needed, it's straightforward to add validation for these steps or further extend the validation process (e.g., validating email or password).
Conclusion
In conclusion, we've successfully improved our multistep form by enabling data saving on step navigation, highlighting the state of each step, and displaying step states in the Stepper
. These enhancements contribute to a more flexible and user-friendly form experience.
Now that the step state highlight is working we have a functional multistep form, that could have a wide range of use cases. As a future improvement, we could add a "Save as draft" functionality that would save entered incomplete data to the local storage (or a database) and allow the users to come back to it later.
References and resources
- Build a Multistep Form With React Hook Form
- Codesandbox for the tutorial
- GitHub repository with the code for the tutorial
- Display Warning for Unsaved Form Data on Page Exit
- Managing Forms with React Hook Form
- React documentation: useImperativeHandle
- React documentation: forwardRef
- React documentation: functional updates