Advanced Multistep Forms with React Hook Form

Updated on · 7 min read
Advanced Multistep Forms with React Hook Form

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:

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:

  1. Save entered data when navigating between steps, and provide user feedback.
  2. 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:

Final result

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.

Form screenshot

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