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:
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:
- Setup and configure the development environment.
- 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 Yarn ─create-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:
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.
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:
- DemographicData
- PresentingComplaints
- DetailedHistory
- SystemicReview
- ExaminationFindings
- Differentials
- 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:
- Create the parent form component containing all step components in the
return()
block. - Create an array containing strings which represents each step.
- For ease of props passage, create an inputs state which contains an object of inputs.
- Handle
onChange
event of form input. - Initialize empty nextHandler() function to be triggered on navigating to next step.
- Initialize empty prevHandler() function to be triggered on navigating to previous step.
- Initialize empty saveHandler() function to be triggered on form submit.
- 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:
- Create a button for navigating to previous steps -- to be rendered only when the current step is not
'first'
. - Create a button for navigating to next steps -- to be rendered only when the current step is not
'last'
. - Create a button to submit the form -- to be rendered only when the current step is
'last'
. - 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:
- creating a state,
step
which stores the currently rendered step - writing the
nextHandler
andprevHandler
functions to manipulate statestep
on button click - 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:
- Store the
step
state in the browser localStorage. - 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).