Material-UI v5 Stepper with React-Hook-Form
Tags: react, typescript, materialui, reacthookform
Published: 2023-02-26 08:48:05

![Material UI with React Hook Form](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5j7y2zaf4prb6ew4m2pw.jpeg)

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](#getting-started)
- [Base Stepper](#base-stepper)
- [Button for the Stepper](#button-for-the-stepper)
- [Field Input](#fiels-input)
- [Step Content Form](#step-content-form)
- [Main Form](#main-form)
- [DevTools] (#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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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.