The Art Of Multistep Forms #1 — Navigation

How to implement the basic structure of a multistep form

Recently, we’ve had to implement a multistep form and it resulted in a very well-rounded implementation, on this form we have: validation, state management, local persistence, and backend sync. Throughout this blog post series, we’ll show you a way to implement a similar multistep form in a React application. Starting from the basics with the structure and state, then implementing form validation, persistence, and state rehydration (backend sync).

Why Multistep?

We need to understand why multistep forms are great and why we should prefer a form with multiple steps rather than a giant form with several fields.

They are user friendly

The first thing to consider is that users will find it much easier to fill up small pieces of data, rather than doing it all at once. By dividing your form into small steps, users will be more willing to provide you with the data you need. Also, if you implement a persistence strategy, they will be able to complete some steps, stop and then come back to finish it later. By the end of the day, the users will have filled the 20 fields form anyway, but for the matter of usability, they will have found it much easier to complete it in small steps. For instance, we can create a form with three steps. Being them, personal info, business info, and review. In the last step users can review what they have filled up on the previous steps before clicking on submit. This same pattern can be applied to larger forms with even more steps.

They are better to validate

While it’s true that multistep forms tend to be more complex than single-step forms, we noticed that it is much less cumbersome to validate small pieces of data than it’s to validate dozens of fields at once. Further, we will use the yup library to build our validation schemas. Then, you’ll see that each step can be treated as a small isolated form. This way, if the user fills up something wrong, they will see an error on that step and they won’t be allowed to submit the form until they enter the data correctly.

This makes validation easier, while at the same time, making the user experience more pleasant. Have you ever had to fill up a giant form where you had to scroll the page, to then have to click on the submit button only to figure out that you forgot a field at the beginning of the form? With the multistep form, this is not a problem, because the user will fill up the data in small steps, and we can validate small chunks of data, making everything more intuitive for users and us developers.

Getting started

We’ll assume that you already have a react application set up. We aren’t concerned about the design, so just to save time we’re gonna use a 3rd part library called Material UI. Let’s start adding the core types of our app:

// types.ts

export enum Steps {
  PersonalInformation = "PersonalInformation",
  BusinessInformation = "BusinessInformation",
  ReviewSubmit = "ReviewSubmit",
}

export interface Step {
  order: number;
  label: string;
}

The Steps enum will be the core of our multistep. All things related to multistep will be created based on this type.

Now, we need an object mapping the step and its info:

// utils/constants.ts

import { Step, Steps } from "../types";

export const stepsLibrary: {
    [key in Steps]: Step;
} = {
    PersonalInformation: {
        order: 0,
        label: "Personal Information",
    },
    BusinessInformation: { order: 1, label: "Business Information" },
    ReviewSubmit: {
        order: 2,
        label: "Review & Submit",
    },
};

Let’s add our first component:

// components/stepper/stepper.tsx

import React from "react";
import Stepper from "@mui/material/Stepper";
import Step from "@mui/material/Step";
import StepLabel from "@mui/material/StepLabel";
import { Steps } from "../../types";
import { stepsLibrary } from "../../utils/constants";

interface HorizontalStepperProps {
    activeStep: Steps;
}

const HorizontalStepper: React.FC<HorizontalStepperProps> = ({
    activeStep,
}) => {
    return (
        <>
            <Stepper activeStep={stepsLibrary[activeStep].order} alternativeLabel>
                {Object.values(stepsLibrary).map(step => (
                    <Step key={step.label}>
                        <StepLabel>{step.label}</StepLabel>
                    </Step>
                ))}
            </Stepper>
        </>
    );
};

export default HorizontalStepper;

Basically, we import the stepsLibrary object and iterate through its values. For each step, we will show an Step from Material UI.

If you try rendering this component, you should see something like this:

Cool, but it doesn’t make sense having a stepper without a way to navigate between the steps, right? So let’s code it:

import React from "react";
import { Box, Button } from "@mui/material";

interface StepperControllerProps {
    handleBack: () => void;
    handleNext: () => void;
    isLastStep: boolean;
    isFirstStep: boolean;
}

const StepperController: React.FC<StepperControllerProps> = ({
    handleBack,
    handleNext,
    isLastStep,
    isFirstStep,
}) => {
    return (
        <Box sx={{ display: "flex" }}>
            <Button
            color="inherit"
            disabled={isFirstStep}
            onClick={handleBack}
            sx={{ mr: 1 }}
            >
                Back
            </Button>
            <Box sx={{ flex: "1 1 auto" }} />
        <Button onClick={handleNext} sx={{ mr: 1 }}>
            {isLastStep ? "Finish" : "Next"}
        </Button>
        </Box>
    );
};

export default StepperController;

We’ve added a StepsController as a pure component to make it reusable and easy to test. We’ll have to inject the handleBack, handleNext, isLastStep and isFirstStep. Perhaps, you’re thinking where these params will come from? We’ll add a custom hook that is going to be responsible for taking care of everything related to the steps navigation logic. By the way, if you’re not familiar with custom hooks, take a look at this great article.

// hooks/use-multistep-navigation.ts

import { useState } from "react";
import { Step, Steps } from "../types";
import { stepsLibrary } from "../utils/constants";

export const getStepsByOrder = (stepsLibrary: { [key in Steps]: Step }): {
    [key: number]: Steps;
} =>
    Object.keys(Steps).reduce((prev, stepId) => {
        const step = stepId as Steps;
        return { ...prev, [stepsLibrary[step].order]: step };
  }, {});

const useMultistepNavigation = () => {
    const [activeStep, setActiveStep] = useState(Steps.PersonalInformation);

    const stepsByOrder = getStepsByOrder(stepsLibrary);
    const currentStepNumber = stepsLibrary[activeStep].order;
    const isFirstStep = currentStepNumber === 0;

    const isLastStep = () => {
        const totalSteps = Object.keys(Steps).length - 1;

        return currentStepNumber >= totalSteps;
    };

    const handleBack = () => {
        const step = stepsByOrder[currentStepNumber - 1] as Steps;

        setActiveStep(step);
    };

    const handleNext = () => {
        const step = stepsByOrder[currentStepNumber + 1] as Steps;

        setActiveStep(step);
    };

    return {
        isFirstStep,
        isLastStep: isLastStep(),
        handleBack,
        handleNext,
        activeStep,
    };
};

export default useMultistepNavigation;

The first thing we have here is a helper that will transform the stepsLibrary into an object with numeric keys, representing the order of the steps. The values will be the steps identifier (enum value). Do you remember when we mentioned that everything related to the multistep form would be based on an enum type? Here, we can see why enums are ideal to do these kinds of things. Later, if you decide to change the order or the name of the steps, the rest of your application derived from your types should keep working fine. This helper should return something like that:

{
    0: "PersonalInformation",
    1: "BusinessInformation",
    2: "ReviewSubmit",
}

But why do we need that? To make the order property the main control factor so that we can easily calculate things such as isFirstStep and isLastStep. Following the good practices, we expose only the necessary from the hook.

So far, so good. Let’s create a page to show the HorizontalStepper and the StepsController to check if they work together consuming the useMultistepNavigation hook.

// pages/multistep-form.tsx

import React from "react";
import StepperController from "../components/stepper-controller/stepper-controler";
import HorizontalStepper from "../components/stepper/stepper";
import useMultistepNavigation from "../hooks/use-multistep-navigation";

const MultistepForm: React.FC = () => {
    const { activeStep, handleBack, handleNext, isLastStep, isFirstStep } =
        useMultistepNavigation();

    return (
        <>
            <HorizontalStepper activeStep={activeStep} />
            <StepperController
            handleBack={handleBack}
            handleNext={handleNext}
            isLastStep={isLastStep}
            isFirstStep={isFirstStep}
            />
        </>
    );
};

export default MultistepForm;

Nice! Before we continue, let’s add some tests to make sure that nothing is gonna break in the future.

We won’t show you how to test trivial components. Let’s focus on the core parts of our multistep app, such as use-multistep-navigation.

import { renderHook, act } from "@testing-library/react-hooks";
import useMultistepNavigation from "./use-multistep-navigation";
import { Steps } from "../types";

describe("useMultistepNavigation", () => {
  describe("when the current step is ReviewSubmit", () => {
    it("isLastStep is true", () => {
      const { result } = renderHook(() =>
        useMultistepNavigation(Steps.ReviewSubmit),
      );

      expect(result.current.isLastStep).toBeTruthy();
    });
  });

  describe("when handleBack is triggered", () => {
    it("the activeStep is BusinessInformation", () => {
      const { result } = renderHook(() =>
        useMultistepNavigation(Steps.ReviewSubmit),
      );

      act(() => {
        result.current.handleBack();
      });

      expect(result.current.isLastStep).toBeFalsy();
      expect(result.current.activeStep).toBe(Steps.BusinessInformation);
    });
  });

  describe("when the current step is PersonalInformation", () => {
    it("isFirstStep is true", () => {
      const { result } = renderHook(() =>
        useMultistepNavigation(Steps.PersonalInformation),
      );

      expect(result.current.isFirstStep).toBeTruthy();
    });

    describe("when handleNext is triggered", () => {
      it("the activeStep is BusinessInformation", () => {
        const { result } = renderHook(() =>
          useMultistepNavigation(Steps.PersonalInformation),
        );

        act(() => {
          result.current.handleNext();
        });

        expect(result.current.isFirstStep).toBeFalsy();
        expect(result.current.activeStep).toBe(Steps.BusinessInformation);
      });
    });
  });
});

We used a utility from testing-library to help us to test our hook easier.

Allright we have the basic structure of our multistep ready! In the second part of this series, we’ll talk about the form implementation with react-hook-form and how to validate and show a visual error feedback by setting a yup schema. You can also check and run the code we showed here in our github repository.

We want to work with you. Check out our "What We Do" section!