Material-UI v5 Stepper with React-Hook-Form
Rayhanendra
Posted on February 26, 2023
Hello Coders!
This article explains the process on how to integrate Material-UI v5 Stepper with React-Hook-Form. It also covers some practices in building a form such as:
- Validation Schema using Yup
- TextField and Select component integrated with React-Hook-Form
- Handling loading button when submitting
- React-Hook-Form DevTools
Table of Content
- Getting Started
- Base Stepper
- Button for the Stepper
- Field Input
- Step Content Form
- Main Form
- DevTools
Getting Started
To get started, I assume the readers are already capable on creating a basic react app.
First install the react-hook-form package
yarn add react-hook-form
To add the devtools to dev dependencies use this command. And integrate it with the form. Will be explained at the Main Form
yarn add -d @hookform/devtools
Install Yup Validation Schema and react-hook-form resolver to integrate it
yarn add yup @hookform/resolver
Now install Material-UI
yarn add @mui/material @emotion/react @emotion/styled
Base Stepper
This component is the steps at the top of the UI. This component is taken from Material UI custom stepper example and modified to the current UI. To integrate with the react-hook-form, we only need to pass the number of steps and the activeStep as props.
BaseStepper.tsx
import * as React from 'react';
import { styled } from '@mui/material/styles';
import Stack from '@mui/material/Stack';
import Stepper from '@mui/material/Stepper';
import Step from '@mui/material/Step';
import StepLabel from '@mui/material/StepLabel';
import StepConnector, {
stepConnectorClasses,
} from '@mui/material/StepConnector';
import { StepIconProps } from '@mui/material/StepIcon';
import {
CheckCircle,
RadioButtonChecked,
RadioButtonUnchecked,
} from '@mui/icons-material';
const QontoConnector = styled(StepConnector)(({ theme }) => ({
[`&.${stepConnectorClasses.alternativeLabel}`]: {
top: 10,
left: 'calc(-50% + 16px)',
right: 'calc(50% + 16px)',
},
[`&.${stepConnectorClasses.active}`]: {
[`& .${stepConnectorClasses.line}`]: {
borderStyle: 'dashed',
borderColor: theme.palette.primary.main,
},
},
[`&.${stepConnectorClasses.completed}`]: {
[`& .${stepConnectorClasses.line}`]: {
borderStyle: 'solid',
borderColor: theme.palette.primary.main,
},
},
[`& .${stepConnectorClasses.line}`]: {
borderColor:
theme.palette.mode === 'dark' ? theme.palette.grey[800] : '#eaeaf0',
borderTopWidth: 3,
borderRadius: 1,
borderWidth: 1,
borderStyle: 'dashed',
},
}));
const QontoStepIconRoot = styled('div')<{ ownerState: { active?: boolean } }>(
({ theme, ownerState }) => ({
color: theme.palette.mode === 'dark' ? theme.palette.grey[700] : '#eaeaf0',
display: 'flex',
height: 22,
alignItems: 'center',
...(ownerState.active && {
color: theme.palette.primary.main,
}),
'& .QontoStepIcon-completedIcon': {
color: theme.palette.primary.main,
zIndex: 1,
},
'& .QontoStepIcon-circle': {
display: 'block',
width: 22,
height: 22,
borderRadius: '100%',
backgroundColor: theme.palette.grey[400],
border: `1px solid ${theme.palette.grey[400]}`,
backgroundClip: 'content-box',
padding: 4,
},
})
);
function QontoStepIcon(props: StepIconProps) {
const { active, completed, className } = props;
return (
<QontoStepIconRoot ownerState={{ active }} className={className}>
{completed ? (
<CheckCircle className='QontoStepIcon-completedIcon' />
) : active ? (
<RadioButtonChecked />
) : (
<RadioButtonUnchecked />
)}
</QontoStepIconRoot>
);
}
export default function CustomStepper({
activeStep,
steps,
}: {
activeStep: number;
steps: string[];
}) {
return (
<Stack sx={{ width: '100%' }} spacing={4}>
<Stepper
alternativeLabel
activeStep={0 || activeStep - 1}
connector={<QontoConnector />}
>
{steps.map((label) => (
<Step key={label}>
<StepLabel StepIconComponent={QontoStepIcon}>{label}</StepLabel>
</Step>
))}
</Stepper>
</Stack>
);
}
Button for the Stepper
Here we condition the button label and also loading text from the props. The button is loading when the passed isSubmitting state of the useForm is true.
ButtonStepper.tsx
import React from 'react';
import { LoadingButton } from '@mui/lab';
import { Box, Button } from '@mui/material';
import { Container } from '@mui/system';
type Props = {
steps: string[];
activeStep: number;
onClick?: () => void;
onClickBack?: () => void;
loading?: boolean;
};
function ButtonStepper({
steps,
activeStep,
onClick,
onClickBack,
loading,
}: Props) {
const isLastStep = activeStep === steps.length;
return (
<Container
maxWidth='xs'
sx={{ position: 'fixed', left: '0', bottom: '0', right: '0', p: 0 }}
>
<Box
sx={{
p: 2,
background: 'white',
zIndex: 100,
borderTop: '1px solid #e0e0e0',
display: 'flex',
alignItems: 'center',
gap: 2,
}}
>
{activeStep > 1 && (
<Button
type='button'
color='primary'
variant='outlined'
fullWidth
onClick={onClickBack}
>
Back
</Button>
)}
<LoadingButton
type='submit'
color='primary'
variant='contained'
fullWidth
onClick={onClick}
loading={loading}
>
{isLastStep ? 'Submit' : 'Next'}
</LoadingButton>
</Box>
</Container>
);
}
export default ButtonStepper;
Field Input
To integrate react-hook-form with material ui text field component, we can use Controller component from react-hook-form and return TextField component inside the render props. Pass the onChange and value from the render props to use it with the TextField onChange and value so the react-hook-form can read the event from the TextField. And also pass the error to the helperText props in TextField to get the error message from the form.
FieldInputText.tsx
import React from 'react';
import { TextField } from '@mui/material';
import { Controller } from 'react-hook-form';
type Props = {
type?: 'text' | 'email' | 'password';
name: string;
label: string;
control: any;
};
function FieldInputText({ type = 'text', name, label, control }: Props) {
return (
<Controller
name={name}
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<TextField
type={type}
onChange={onChange}
value={value}
label={label}
size='small'
helperText={`${error?.message ? error?.message : ''}`}
error={!!error}
fullWidth
/>
);
}}
/>
);
}
export default FieldInputText;
FieldInputSelect.tsx
import React from 'react';
import { Controller } from 'react-hook-form';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
type Props = {
name: string;
label: string;
options: Array<{ value: string; label: string }>;
disabled?: boolean;
control: any;
};
function FieldInputSelect({ name, label, options, disabled, control }: Props) {
return (
<Controller
name={name}
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<TextField
select
value={value}
defaultValue={value ? value : ''}
onChange={onChange}
label={label}
size='small'
helperText={`${error ? error.message : ''}`}
error={!!error}
disabled={disabled}
>
{options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
);
}}
/>
);
}
export default FieldInputSelect;
Step Content Form
Because the form might be deeply nested, we use the useFormContext to get access to the Main Form and also we must use FormProvider component from react-hook-form in the Main Form to make use of it. Pass the control as props to the input component to give access from the form to input.
Form1.tsx
import React from 'react';
import { Stack } from '@mui/material';
import { useFormContext } from 'react-hook-form';
import FieldInputText from '../atoms/FieldInputText';
function FormOne() {
const { control } = useFormContext();
return (
<Stack gap={2}>
<FieldInputText name='name' control={control} label='Name' />
<FieldInputText
type='email'
name='email'
label='Email'
control={control}
/>
</Stack>
);
}
export default FormOne;
The genderOptions is an array of object with value and label
Array<{ value: string; label: string }>
Form2.tsx
import React from 'react';
import { Box, Stack } from '@mui/material';
import { useFormContext } from 'react-hook-form';
import { genderOptions } from '../../utils/constants';
import FieldInputSelect from '../atoms/FieldInputSelect';
function FormTwo() {
const { control } = useFormContext();
return (
<Stack gap={2}>
<FieldInputSelect
name='gender'
label='Gender'
control={control}
options={genderOptions}
/>
<Box mb={7} />
</Stack>
);
}
export default FormTwo;
Form3.tsx
import React from 'react';
import { Stack } from '@mui/material';
import { useFormContext } from 'react-hook-form';
import FieldInputText from '../atoms/FieldInputText';
function FormThree() {
const { control } = useFormContext();
return (
<Stack gap={2}>
<FieldInputText
type='password'
name='password'
label='Password'
control={control}
/>
</Stack>
);
}
export default FormThree;
Main Form
In this component we put all the logic for the react-hook-form and also the steps component.
The number of steps are defined as the length of an array of string. Here we also determine the label for the step content.
We define the step content as forms. Use the useState hook to determine the activeStep and pass it to the _renderStepComponent function to determine the current step content form.
To validate each step content form, we make the validation schema as array of yup object with each object contains the validation of each input.
To make use of the isSubmitting state from the react-hook-form and use it as a loading state for the submit you just have to pass an async function with the await following the promise and put the function as an argument inside the react-hook-form handleSubmit function. Then, we pass the isSubmitting state to the submit button as props.
DevTools
To use the devtools use the DevTool component and pass the control from formProps to the DevTool component
FormRegistration.tsx
import React, { useState } from 'react';
import { Box, Divider } from '@mui/material';
import * as Yup from 'yup';
import { FormProvider, useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { DevTool } from '@hookform/devtools';
import BaseStepper from '../atoms/BaseStepper';
import Form1 from '../molecules/Form1';
import Form2 from '../molecules/Form2';
import Form3 from '../molecules/Form3';
import ButtonStepper from '../atoms/ButtonStepper';
const steps = ['First', 'Second', 'Third'];
function _renderStepContent(step: number) {
switch (step) {
case 1:
return <Form1 />;
case 2:
return <Form2 />;
case 3:
return <Form3 />;
default:
return <div>Not Found</div>;
}
}
const validationSchema = [
// Form 1
Yup.object().shape({
name: Yup.string().required().label('Name'),
email: Yup.string().email().required().label('Email'),
}),
// Form 2
Yup.object().shape({
gender: Yup.string().required().label('Gender'),
}),
// Form 3
Yup.object().shape({
password: Yup.string().required().label('Password'),
}),
];
function FormRegistration() {
const [activeStep, setActiveStep] = useState(1);
const currentValidationSchema = validationSchema[activeStep - 1];
const isLastStep = activeStep === steps.length;
const formProps = useForm({
resolver: yupResolver(currentValidationSchema),
defaultValues: {
name: '',
email: '',
gender: '',
password: '',
},
});
const { handleSubmit, control, formState } = formProps;
const onSubmit = async (value: any) => {
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
await sleep(2000).then(() => {
console.log('value', value);
});
};
function _handleSubmit() {
if (isLastStep) {
return handleSubmit(onSubmit)();
} else {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}
}
function _handleBack() {
if (activeStep === 1) {
return;
}
setActiveStep(activeStep - 1);
}
return (
<>
<Box pt={2}>
<BaseStepper activeStep={activeStep} steps={steps} />
</Box>
<Divider sx={{ mt: 2 }} />
<Box p={2}>
<FormProvider {...formProps}>
<form onSubmit={handleSubmit(_handleSubmit)}>
{_renderStepContent(activeStep)}
<ButtonStepper
steps={steps}
activeStep={activeStep}
onClickBack={_handleBack}
loading={formState.isSubmitting}
/>
</form>
</FormProvider>
</Box>
{control && <DevTool control={control} />}
</>
);
}
export default FormRegistration;
App.tsx
import React from 'react';
import { Paper } from '@mui/material';
import { Container } from '@mui/system';
import FormRegistration from './components/organisms/FormRegistration';
function App() {
return (
<div>
<Container maxWidth='xs' sx={{ p: 0 }}>
<Paper sx={{ height: '100vh' }}>
<FormRegistration />
</Paper>
</Container>
</div>
);
}
export default App;
index.tsx
import { CssBaseline } from '@mui/material';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<CssBaseline />
<App />
</React.StrictMode>
);
Conclusion
There you go! Material UI v5 with React-Hook-Form. If you want to take a further look at the code i got you with my codesandbox project and here is the link https://codesandbox.io/s/eloquent-zeh-dh6ee5.
Posted on February 26, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.