DEV Community

Cover image for Material-UI v5 Stepper with React-Hook-Form
Rayhanendra
Rayhanendra

Posted on

Material-UI v5 Stepper with React-Hook-Form

Material UI with React Hook Form

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install Yup Validation Schema and react-hook-form resolver to integrate it

yarn add yup @hookform/resolver
Enter fullscreen mode Exit fullscreen mode

Now install Material-UI

yarn add @mui/material @emotion/react @emotion/styled
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)