Advanced Multistep Forms with React

react forms

Image credit: Photo by Jack Anstey on Unsplash

In the previous post we have built a simple registration multistep form with React and React Hook Form. The form works fine for a simple signup workflow where the users don't need to navigate back and forth between steps. In this post, we'll consider the kind of form where the order of steps is not fixed and the users do not need to provide all the information at once. To make it simpler, we'll build on the form example from the previous tutorial, even though this workflow might not be the best for the registration form. Instead, let's imagine that we have a checkout form, where the users fill the info in steps and may save the form in a draft state to come back to it later. The final result can be tested on Codesandbox.

Saving form data on step navigation

At the end of the previous post, we identified several improvements to the form that would make it more flexible. Firstly, when moving from one step to another, the entered data is not saved (and the users don't get any feedback about that). Secondly, navigating by clicking on a step bypasses the form validation, allowing users to submit an incomplete form.

In this post we'll make a few improvements to the form to address the above concerns:

  • Remove field validation for each step and instead show their state in the Stepper.
  • Save the entered form data on step change, so the data is not lost on navigation (the entered data, however, is not saved when clicking the Previous button).
  • Highlight the missing fields in the confirm page, so the user can go back and fill them in if needed.

We start by removing all the validation from the steps, instead, it will be done in the last step. Next, we'll need to submit the form data when the user clicks on a step. There are several ways to go about this. What we'll do is treat the navigation between steps the same way as the Next button - it will save the current data to the shared context. To achieve this we'll need to somehow trigger the Button's onClick event from the Stepper component. This is where the obscure useImperativeHandle hook becomes useful. In short, this hook allows calling the ref target's methods outside its component (e.g. from parent components).

First, we wrap the Button, Stepper, and individual step components in forwardRef to enable receiving ref as a prop, e.g.:

//Steps/Contact.js
export const Contact = forwardRef((props, ref) => {
   //.. 
    return (
        <Form onSubmit={handleSubmit(saveData)}>
            //..
            <Button ref={ref}>Next {">"}</Button>
        </Form>
    );
});

Secondly, we'll set up the useImperativeHandle hook inside Button, exposing Button's click event.

//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>
    );
  }
);

Lastly, we'll create a shared ref at the App's level, and an onStepChange callback which will be assigned to each Link's onClick. We could have defined onStepChange directly inside Stepper, but then we'd need to also wrap it in forwardRef to be able to accept the buttonRef.

//App.js
export const App = () => {
  const buttonRef = useRef();

  const onStepChange = () => {
    buttonRef.current.click();
  };

  return (
    <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>
  );
};

The form seems to work properly and the data is saved when we navigate to another step. However, there's one issue - you might have noticed that sometimes when we want to go a few steps forward, e.g. from Contact to Confirm, the form navigates only one step at a time. This happens because we have two conflicting kinds of navigation - one from the Stepper's NavLink and another from the form's onSubmit callback. To fix this, we'll tweak the Form component, so it has a custom onSubmit callback and handles the navigation to the next step. This way, by the time the navigation is triggered from the Form, the stepper navigation is already in progress and the Form's navigation is discarded.

//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>
  );
};

Now we need to provide nextStep to the form from each step to complete the navigation. The updated steps will look like this:

//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>
  );
});

You can notice that, apart from removing field validation, we have also removed the password confirm field to simplify the form. This validation is now moved to the final step - Confirm. To streamline the rendering and validation, we'll store the whole form data to be rendered as an array of field objects, divided into sections.

//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 },
        { 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 }],
    },
  ];

  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="clo-md-12 d-flex justify-content-start">
        <Button>Submit</Button>
      </div>
    </Form>
  );
};

It should be noted that while this looks cleaner than defining all the sections and items separately in JSX, it can easily become hard to manage when requirements change, e.g. if there's some extra rendering logic added.

A new addition to the items array is a required field, which we'll use to disable form submission if any of the required fields are missing and to highlight which fields are required.

To achieve the former we iterate over all the items and see if any of the required fields are empty.

//Steps/Confirm.js
const disableSubmit = data.some((section) =>
    section.items.some((item) => item.required && !item.value)
);

After we can pass this value to the form's Submit button to control its disabled state.

//Steps/Confirm.js
<Button disabled={disableSubmit}>Submit</Button>

Making the required fields more visible will be achieved by highlighting the field name (we'll use Boostrap's warning yellow color) and showing an exclamation mark in place of the field's value.

//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:

Form screenshot

Displaying step state in the Stepper

As a final visual touch, we could also display the state of each step in the navigation. If the step has not been visited, it won't have any styling, otherwise, for steps with missing fields we'll show the warning icon and for the steps with no missing required fields a success icon.

Firstly, we'll start tracking the visited steps in the context. Then we can use this data to display the appropriate state indicator. To make the code less cluttered let's create a helper component for rendering step state.

//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;
    }
};

Nothing fancy here, we just render the state icon based on the value of boolean props. To track the visited steps we can leverage the hooks from React Router and save the pathname from the current location as a visited step.

//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 it as one of the useEffect dependencies (which can break the app due to infinite rendering loop). We do not care if the visited steps are unique, we could check if the step is already added before adding a new one, but that seems like a minor optimization.

Finally, we can add the step state indicators to the Stepper. For easier rendering, let's collect the data for the nav links into an array of objects and render them in a loop.

//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="collapse navbar-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 have a success state if they have been visited since they do not have any required fields. It should be pretty easy to add validation for those steps if necessary or otherwise extend the validation (e.g. validate email or password).

Conclusion

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.