Building Multistep Forms with MaterialUI and React Hooks

Subscribe to my newsletter and never miss my upcoming articles

Forms are used to collect user inputs in web applications. Although single forms can conveniently collect little to moderate amounts of user input, large amounts of user input may result in long, complex and visually unappealing forms. One way to deal with such forms is to break them down into multiple steps. These are often referred to as multistep forms. While multistep forms perceptibly reduce complexity of forms, they are oftentimes difficult to develop. However, React makes this exercise relatively easy by rendering component steps when component state is manipulated. In this article, we would learn how to implement such forms with the React useState hook and the MaterialUI component library by building a multistep medical history collection form.

Here's the final look and feel of the multistep form we would build:

Cool right

Getting Started
Multistep Forms
Creating Our Form
Creating Child Steps
Handling Navigation Between Steps
Persisting Current Step On Browser Reload
Final Steps

Getting Started

To start the project:

  1. Setup and configure the development environment.
  2. Install dependencies.

Setting up the development environment

For convenience, start and configure the React development environment with the create-react-app utility. This utility simultaneously configures the project and provides us some boilerplate code to get started. To get started, run the following script in your terminal:

npx create-react-app multistep-form

Running this command generates a directory multistep-form containing our initial project structure and installs the transitive dependencies. The folder structure looks like this:

my-app
├── README.md
├── node_modules
├── package.json
├── .gitignore
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
└── src
    ├── App.css
    ├── App.js
    ├── App.test.js
    ├── index.css
    ├── index.js
    ├── logo.svg
    └── serviceWorker.js

Installing Dependencies

As we would use Yarncreate-react-app's default package manager ─ to manage our project's packages, ensure to install if you do not have it already installed. We would use the MaterialUI component libary for styling and icons. This library provides react components for faster and easier development and supports responsiveness. To install Material-UI's source files and icons via yarn, run the following script:

yarn add @material-ui/core @material-ui/icons

This script also automatically injects the requisite CSS files needed.

Multistep Forms

As the name suggests, multistep forms are simply large forms broken down into multiple steps. A very logical way to create such forms in React would be to create a parent form component which contains the steps as child components. The following is a tree which visualizes this base concept:

├── ...
├── <ParentForm/>
│   ├── <ChildStep1/>
│   ├── <ChildStep2/>
│   ├── <ChildStep3/>
│   ├── <ChildStep4/>
│   ├── <ChildStep5/>
│   └── <ChildStep6/>
└── ...

This concept is better illustrated with the following visual:

Visual
At first glance, the project may appear complex. It is however simple as it utilizes basic React concepts:

  • Components for building the form's user interface.
  • Props for passing data from <ParentForm /> to <ChildSteps />.
  • State for storing form inputs.

One logical way to implement this would be to have a UI state which stores an arbitrary value that corresponds to a step. An array of values should store all possible step values. Hence, each string in the array would correspond to a step in the form. For example:

...
const [step, setStep] = useState('step1')
const steps = ['step1', 'step2', 'step3', 'step4', 'step5', 'step6']
...

From the example above, by default, 'step1' is the first rendered step. It follows that we can proceed to conditionally render steps in the form based on the value of step from the state. For example:

...
const ParentForm = () => {
const [step, setStep] = useState('step1')
const steps = ['step1', 'step2', 'step3', 'step4', 'step5', 'step6']

return (
   <form>
     {step==='step1' && <Step1 />}
     {step==='step2' && <Step2 />}
     {step==='step3' && <Step3 />}
     {step==='step4' && <Step4 />}
     {step==='step5' && <Step5 />}
     {step==='step6' && <Step6 />}
   </form>
)
}

Navigation between steps can then be handled by writing handling functions to modify state. For example, the following next() function modifies the state by selecting a value in the array of steps at an index one (1) position after the current value of the state.

...
const [step, setStep] = useState('step1')
const steps = ['step1', 'step2', 'step3', 'step4', 'step5', 'step6']
const next = () => {
let stateIndex = steps.indexOf(step)
let nextIndex = steps.indexOf(step)+1
let nextStep = steps[nextIndex]
//to modify state,
setStep(nextStep)
}
...

Similarly, a previous() function can be written to navigate to previous step:

...
const previous = () => {
let stateIndex = steps.indexOf(step)
let previousIndex = steps.indexOf(step)-1
let previousStep = steps[previousIndex]
//to modify state,
setStep(previousStep)
}
...

The examples shown so far would form the basic logic with which we would build our multistep form. Let's get started.

lets get started

Creating Our Form

To create our form's user interface, we would simply use prebuilt Material-UI Form Components. We would make slight modifications to fit our requirements. First, delete every other component inmultistep-form/src so that only App.js, index.js and app.css remains in the src directory.

my-app
├── ...
└── src
    ├── App.js
    ├── index.css
    └── app.js

app.css

.App {
  font-family: sans-serif;
  text-align: center;
}

Edit index.js

import React from "react";
import ReactDOM from "react-dom";

import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(
    <App />,
  rootElement
);

App.js

import React from "react";
import "./app.css";

export default function App() {
  return (
    <div className="App">
      <h1>Hello</h1>
      <h2>Let's create our multistep form.</h2>
    </div>
  );
}

We would proceed to create a multistep medical history taking form. This form would have seven (7) steps:

  1. DemographicData
  2. PresentingComplaints
  3. DetailedHistory
  4. SystemicReview
  5. ExaminationFindings
  6. Differentials
  7. Investigations.

Let's proceed to create them. First, we would create the parent form in App.js.
To create this form, follow the following steps:

  1. Create the parent form component containing all step components in the return() block.
  2. Create an array containing strings which represents each step.
  3. For ease of props passage, create an inputs state which contains an object of inputs.
  4. Handle onChange event of form input.
  5. Initialize empty nextHandler() function to be triggered on navigating to next step.
  6. Initialize empty prevHandler() function to be triggered on navigating to previous step.
  7. Initialize empty saveHandler() function to be triggered on form submit.
  8. Pass input state, handleInput, step, navigation and submit handlers to child components as props.
App.js
import React, { useState } from "react";
import { makeStyles } from "@material-ui/core/styles";
import TopBar from "./TopBar";
import DemographicData from "./DemographicData";
import PresentingComplaints from "./PresentingComplaints";
import DetailedHistory from "./DetailedHistory";
import SystemicReview from "./SystemicReview";
import ExaminationFindings from "./ExaminationFindings";
import Differentials from "./Differentials";
import Investigations from "./Investigations";

const useStyles = makeStyles((theme) => ({
  root: {
    "& .MuiTextField-root": {
      margin: theme.spacing(1),
      width: "25ch"
    }
  }
}));

export default function App() {
//2. Create an array containing strings which represents each step
  const steps = [
    "first",
    "second",
    "third",
    "fourth",
    "fifth",
    "sixth",
    "last"
  ];
//3. For ease of props passage, create an inputs state which contains an object of inputs
  const [inputs, setInputs] = useState({
    name: "",
    age: "",
    sex: "",
    occupation: "",
    maritalStatus: "",
    address: "",
    religion: "",
    tribe: "",
    complaint1: "",
    complaint2: "",
    otherComplaints: "",
    detailedHistory: "",
    SystemicReview: "",
    examinationFindings: "",
    differentials: "",
    investigations: ""
  });

   //4. handle input onChange event
  const handleInput = (event) => {
    const { name, value } = event.target;
    setInputs((inputs) => ({
      //spread inputs
      ...inputs,
      [name]: value
    }));
  };
//5. Initialize empty nextHandler() function
  const nextHandler = () => {
  };
//6. Initialize empty prevHandler() function
  const prevHandler = () => {
  };
//7. Initialize empty saveHandler() function to be triggered on form submit
  const saveHandler = (event) => {
  }

//1. Create Parent Form Component
  return (
    <>
      <TopBar />
      <form className={classes.root}  autoComplete="off" onSubmit={saveHandler}>
         {/* 8. Pass input, handleInput, step, navigation and submit handlers to child components as props. */}
          <DemographicData 
            step="first"
            inputs={inputs}
            handleInput={handleInput}
            {/* As this is the first step, it would have a next button, pass nextHandler() as props */}
            nextHandler={nextHandler}
            />
          <PresentingComplaints  
            step="second"
            inputs={inputs}
            handleInput={handleInput}
            {/* As this is the second step, it would have a both previous and next button, pass both handlers as props */}
            nextHandler={nextHandler}
            prevHandler={prevHandler}
            />
          <DetailedHistory  
            step="third"
            inputs={inputs}
            handleInput={handleInput}
            nextHandler={nextHandler}
            prevHandler={prevHandler}

            />
          <SystemicReview  
            step="fourth"
            inputs={inputs}
            handleInput={handleInput}
            nextHandler={nextHandler}
            prevHandler={prevHandler}
            />
          <ExaminationFindings  
            step="fifth"
            inputs={inputs}
            handleInput={handleInput}
            nextHandler={nextHandler}
            prevHandler={prevHandler}
            />
          <Differentials  
            step="sixth"
            inputs={inputs}
            handleInput={handleInput}
            nextHandler={nextHandler}
            prevHandler={prevHandler}
            />
          <Investigations  
            step="last"
            inputs={inputs}
            handleInput={handleInput}
            {/* As this is the last step, it would have a previous and final submit button, pass both handlers as props */}
            nextHandler={nextHandler}
            saveHandler={saveHandler}
            />
      </form>
    </>
  );
}

Proceed to create the <TopBar /> component with the Material-UI component libary.

TopBar.jsx
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";

const useStyles = makeStyles((theme) => ({
  root: {
    flexGrow: 1
  },
  title: {
    flexGrow: 1
  },
  appBar: {
    marginBottom: theme.spacing(2)
  }
}));

export default function TopBar() {
  const classes = useStyles();

  return (
    <div className={classes.root}>
      <AppBar position="static" className={classes.appBar}>
        <Toolbar>
          <Typography variant="h6" className={classes.title}>
            Multistep Form with React Hooks and MaterialUI
          </Typography>
        </Toolbar>
      </AppBar>
    </div>
  );
}

Create a reusable button component for navigation by following these steps:

  1. Create a button for navigating to previous steps -- to be rendered only when the current step is not 'first'.
  2. Create a button for navigating to next steps -- to be rendered only when the current step is not 'last'.
  3. Create a button to submit the form -- to be rendered only when the current step is 'last'.
  4. Pass handlers in props to buttons as required.
Buttons.jsx
import React from "react";
import Button from "@material-ui/core/Button";
import { makeStyles } from "@material-ui/core/styles";
import NavigateNextIcon from "@material-ui/icons/NavigateNext";
import ArrowBackIosIcon from "@material-ui/icons/ArrowBackIos";
import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder";
const useStyles = makeStyles((theme) => ({
  button: {
    margin: theme.spacing(1)
  }
}));

export default function Buttons({
  nav,
  prevHandler,
  nextHandler,
  saveHandler
}) {
  const classes = useStyles();

  return (
    <div>
      {nav !== "first" && (
{/* 1. Create a button for navigating to previous steps -- to be rendered only when the current step is not 'first'.  */}
        <Button
          variant="contained"
          color="secondary"
          className={classes.button}
          startIcon={<ArrowBackIosIcon />}
{/*  4. Pass handlers in props to buttons as required.  */}
          onClick={() => prevHandler()}
        >
          Previous
        </Button>
      )}
{/* 2. Create a button for navigating to next steps -- to be rendered only when the current step is not 'last'. */}
      {nav !== "last" && (
        <Button
          variant="contained"
          color="primary"
          className={classes.button}
          endIcon={<NavigateNextIcon />}
{/*  4. Pass handlers in props to buttons as required.  */}
          onClick={() => nextHandler()}
        >
          Continue
        </Button>
      )}
{/* 3. Create a button to submit the form -- to be rendered only when the current step is 'last'. */}
      {nav === "last" && (
        <Button
          variant="contained"
          color="primary"
          className={classes.button}
          endIcon={<FavoriteBorderIcon />}
{/*  4. Pass handlers in props to buttons as required.  */}
          onClick={(event) => saveHandler(event)}
        >
          Save To Medical Records
        </Button>
      )}
    </div>
  );
}

Creating Child Steps

Proceed to create child components in src/ directory, passing respective props as required:

DemographicData.jsx
import React from "react";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import Buttons from "./Buttons";

export default function DemographicData(props) {
  return (
    <>
      <div>
        <Typography variant="h4">Demographic Data</Typography>
        <TextField
          id="name"
          type="text"
          label="Name"
          name="name"
          onChange={(event) => props.handleInput(event)}
          value={props.inputs.name}
          variant="outlined"
        />
        <TextField
          id="age"
          type="text"
          label="Age"
          name="age"
          onChange={(event) => props.handleInput(event)}
          value={props.inputs.age}
          variant="outlined"
        />
        <TextField
          id="sex"
          type="text"
          label="Sex"
          name="sex"
          onChange={(event) => props.handleInput(event)}
          value={props.inputs.sex}
          variant="outlined"
        />
        <TextField
          id="occupation"
          type="text"
          label="Occupation"
          name="occupation"
          onChange={(event) => props.handleInput(event)}
          value={props.inputs.occupation}
          variant="outlined"
        />
        <TextField
          id="maritalStatus"
          type="text"
          label="Marital Status"
          name="maritalStatus"
          onChange={(event) => props.handleInput(event)}
          value={props.inputs.maritalStatus}
          variant="outlined"
        />
        <TextField
          id="address"
          type="text"
          label="Address"
          name="address"
          onChange={(event) => props.handleInput(event)}
          value={props.inputs.address}
          variant="outlined"
        />
        <TextField
          id="religion"
          type="text"
          label="Religion"
          name="religion"
          onChange={(event) => props.handleInput(event)}
          value={props.inputs.religion}
          variant="outlined"
        />
        <TextField
          id="tribe"
          type="text"
          label="Tribe"
          name="tribe"
          onChange={(event) => props.handleInput(event)}
          value={props.inputs.tribe}
          variant="outlined"
        />

        <Buttons nav="first" nextHandler={() => props.nextHandler()} />
      </div>
    </>
  );
}
PresentingComplaints.jsx
import React from "react";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import Buttons from "./Buttons";

export default function PresentingComplaints(props) {
  return (
    <>
      <div>
        <Typography variant="h4">Presenting Complaints</Typography>
        <TextField
          id="c1"
          type="text"
          label="Complaint 1"
          name="complaint1"
          value={props.input.complaint1}
          onChange={(event) => props.handleInput(event)}
          variant="outlined"
          multiline
          rows={4}
        />
        <TextField
          id="c2"
          type="text"
          label="Complaint 2"
          name="complaint2"
          value={props.input.complaint2}
          onChange={(event) => props.handleInput(event)}
          variant="outlined"
          multiline
          rows={4}
        />
        <TextField
          id="oc"
          type="text"
          label="Other Complaints"
          name="otherComplaints"
          value={props.input.otherComplaints}
          onChange={(event) => props.handleInput(event)}
          variant="outlined"
          multiline
          rows={4}
        />

        <Buttons
          nav="second"
          nextHandler={() => props.nextHandler()}
          prevHandler={() => props.prevHandler()}
        />
      </div>
    </>
  );
}
DetailedHistory.jsx
import React from "react";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import Buttons from "./Buttons";

export default function DetailedHistory(props) {
  return (
    <>
      <div>
        <Typography variant="h4">Detailed History</Typography>
        <TextField
          id="hpc"
          type="text"
          label="History of Presenting Complaints"
          name="detailedHistory"
          value={props.input.detailedHistory}
          onChange={(event) => props.handleInput(event)}
          variant="outlined"
          multiline
          rows={4}
        />

        <Buttons
          nav="third"
          nextHandler={() => props.nextHandler()}
          prevHandler={() => props.prevHandler()}
        />
      </div>
    </>
  );
}
SystemicReviews.jsx
import React from "react";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import Buttons from "./Buttons";

export default function SystemicReview(props) {
  return (
    <>
      <div>
        <Typography variant="h4">Review of Systems</Typography>
        <TextField
          id="review"
          type="text"
          label="Review of Systems"
          name="systemicReview"
          value={props.input.systemicReview}
          onChange={(event) => props.handleInput(event)}
          variant="outlined"
          multiline
          rows={4}
        />

        <Buttons
          nav="fourth"
          nextHandler={() => props.nextHandler()}
          prevHandler={() => props.prevHandler()}
        />
      </div>
    </>
  );
}
ExaminationFindings.jsx
import React from "react";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import Buttons from "./Buttons";

export default function ExaminationFindings(props) {
  return (
    <>
      <div>
        <Typography variant="h4">Examination Findings</Typography>
        <TextField
          id="findings"
          type="text"
          label="Examination Findings"
          name="examinationFindings"
          value={props.input.examinationFindings}
          onChange={(event) => props.handleInput(event)}
          variant="outlined"
          multiline
          rows={4}
        />

        <Buttons
          nav="fifth"
          nextHandler={() => props.nextHandler()}
          prevHandler={() => props.prevHandler()}
        />
      </div>
    </>
  );
}
Differentials.jsx
import React from "react";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import Buttons from "./Buttons";

export default function Differentials(props) {
  return (
    <>
      <div>
        <Typography variant="h4">Differentials</Typography>
        <TextField
          id="diff"
          type="text"
          label="Differentials"
          name="differentials"
          value={props.input.differentials}
          onChange={(event) => props.handleInput(event)}
          variant="outlined"
          multiline
          rows={4}
        />

        <Buttons
          nav="sixth"
          nextHandler={() => props.nextHandler()}
          prevHandler={() => props.prevHandler()}
        />
      </div>
    </>
  );
}
Investigations.jsx
import React from "react";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import Buttons from "./Buttons";

export default function Investigations(props) {
  return (
    <>
      <div>
        <Typography variant="h4">Investigations</Typography>
        <TextField
          id="inv"
          type="text"
          label="Investigations"
          name="investigations"
          value={props.input.investigations}
          onChange={(event) => props.handleInput(event)}
          variant="outlined"
          multiline
          rows={4}
        />

        <Buttons
          nav="last"
          type="submit"
          saveHandler={(event) => props.saveHandler(event)}
          prevHandler={() => props.prevHandler()}
        />
      </div>
    </>
  );
}

Handling Navigation Between Steps

As explained initially navigation between steps can be handled by:

  1. creating a state, step which stores the currently rendered step
  2. writing the nextHandler and prevHandler functions to manipulate state step on button click
  3. conditionally rendering step component which corresponds to the changed state
App.js
...
 const handleInput = (event) => {
    const { name, value } = event.target;
    setInputs(() => ({
      ...inputs,
      [name]: value
    }));
  };

  const steps = [
    "first",
    "second",
    "third",
    "fourth",
    "fifth",
    "sixth",
    "last"
  ];
  const classes = useStyles();

//1. creating a state, `step` which stores the currently rendered step
  const [step, setStep] = useState("first");

//2. writing the `nextHandler` and `prevHandler` functions to manipulate state `step` on button click  
  const nextHandler = () => {
    let nextIndex = steps.indexOf(step) + 1;
    setStep(steps[nextIndex]);
  };
  const prevHandler = () => {
    let prevIndex = steps.indexOf(step) - 1;
    setStep(steps[prevIndex]);
  };

 //BONUS: Write dummy function to handle onSubmit event
  const saveHandler = (event) => {
     event.preventDefault(); //prevents default browser behaviour.
    //API call to POST to Database here
    alert("Saved to database. Click 'Ok' to View Collected Data");
    alert(`Name:${inputs.name} 
    Age:${inputs.age} 
    Sex:${inputs.sex} 
    Occupation: ${inputs.occupation} 
    Marital Status: ${inputs.maritalStatus} 
    Address:${inputs.address} 
    Religion:${inputs.religion} 
    Tribe:${inputs.tribe} 
    Complaint 1:${inputs.complaint1} 
    Complaint 2:${inputs.complaint2} 
    Other Complaints:${inputs.otherComplaints} 
    Detailed History:${inputs.detailedHistory} 
    Systemic Review:${inputs.systemicReview} 
    Examination Findings:${inputs.examinationFindings} 
    Investigations:${inputs.investigations} `);
  };

  return (
    <>
      <TopBar />
      <form className={classes.root} noValidate autoComplete="off">
        {/*3. conditionally rendering step component which corresponds to the changed state*/}
        {step === "first" && (
          <DemographicData
            step="first"
            nextHandler={nextHandler}
            inputs={inputs}
            handleInput={handleInput}
          />
        )}
        {step === "second" && (
          <PresentingComplaints
            step="second"
            input={inputs}
            handleInput={handleInput}
            nextHandler={nextHandler}
            prevHandler={prevHandler}
          />
        )}
        {step === "third" && (
          <DetailedHistory
            step="third"
            input={inputs}
            handleInput={handleInput}
            nextHandler={nextHandler}
            prevHandler={prevHandler}
          />
        )}
        {step === "fourth" && (
          <SystemicReview
            step="fourth"
            input={inputs}
            handleInput={handleInput}
            nextHandler={nextHandler}
            prevHandler={prevHandler}
          />
        )}
        {step === "fifth" && (
          <ExaminationFindings
            step="fifth"
            input={inputs}
            handleInput={handleInput}
            nextHandler={nextHandler}
            prevHandler={prevHandler}
          />
        )}
        {step === "sixth" && (
          <Differentials
            step="sixth"
            input={inputs}
            handleInput={handleInput}
            nextHandler={nextHandler}
            prevHandler={prevHandler}
          />
        )}
        {step === "last" && (
          <Investigations
            step="last"
            input={inputs}
            handleInput={handleInput}
            prevHandler={prevHandler}
            saveHandler={saveHandler}
          />
        )}
...

Persisting Current Step On Browser Reload

Our form works almost perfectly now. It switches seamlessly between steps and accurately mocks a submit event when the submit button is clicked. However, there's a glitch somewhere. If your browser reloads when you are on any step, your state refreshes and returns you to the first step--even if you are on the last form step. To fix this:

  1. Store the step state in the browser localStorage.
  2. Rehydrate value of step state on refresh or rerender.
App.js
...
//2. Rehydrate on refresh.  
const [step, setStep] = useState(localStorage.persistStep||"first");
const nextHandler = () => {
    let nextIndex = steps.indexOf(step) + 1;
    setStep(steps[nextIndex]);
   //1. Store the `step` state
    localStorage.setItem("persistStep", steps[nextIndex]);
  };
  const prevHandler = () => {
    let prevIndex = steps.indexOf(step) - 1;
    setStep(steps[prevIndex]);
    localStorage.setItem("persistStep", steps[prevIndex]);
  };
...

Final Steps

Congratulations, you are a multistep forms expert. While we did not verbosely discuss the concepts of props and state in React, we do at least understand how to make our large forms less complex.
Again, here is the final look, feel and code of our multistep form (slide right to view code).

You are Good

Edidiong Asikpo's photo

Absolutely fantastic. Thanks for sharing Ifeanyi.

Ifeanyi Muogbo's photo

Thanks Edidiong Asikpo. Glad you you liked it.

Jonathan Case's photo

Nice writeup! Where do you attach the handlers that you defined to the buttons that you created? I see them being passed down as props to Buttons, but I don't see buttons using the handlers.

Ifeanyi Muogbo's photo

Thanks, Jonathan.
Glad you pointed that out. Many thanks.
It was an unintentional omission.
The handlers were originally included in the Button.jsx file in the codesandbox demo embed.
I've however included them in the write-up now. Check here.

Victoria Lo's photo

Great article! Definitely bookmarking for future use :)

Ifeanyi Muogbo's photo

Glad you found this useful, Victoria Lo 🙂