Refactor a React Component from 165 Lines to 30 Lines

Mohammad Faisal - Dec 13 '23 - - Dev Community

To read more articles like this, visit my blog

React Hook Form is one of the most popular libraries for handling form inputs in the React ecosystem.

But getting it to integrate properly can be tricky if you use any Component library.

Today I will show you how you can integrate with various components of Material UI with React Hook Form.

Let’s get started.

Pre Requisite

I will not go into much detail about how to use the react-hook-form. If you don’t know how to use react-hook-form yet, I strongly suggest you check out this article first.

How to Use React Hook Form with TypeScript
Build Performant and Clean Forms for Your Application javascript.plainenglish.io

All I can say is you won’t regret learning this library.

Starting Code

Let’s see the code that we are going to start with.

import TextField from "@material-ui/core/TextField";
import React, { useState} from "react";
import {
    Button,
    Checkbox,
    FormControlLabel,
    FormLabel,
    MenuItem,
    Radio,
    RadioGroup,
    Select,
    Slider
} from "@material-ui/core";
import {KeyboardDatePicker} from '@material-ui/pickers'

const options = [
    {
        label: 'Dropdown Option 1',
        value:'1'
    },
    {
        label: 'Dropdown Option 2',
        value:'2'
    },
]

const radioOptions = [
    {
        label: 'Radio Option 1',
        value:'1'
    },
    {
        label: 'Radio Option 2',
        value:'2'
    },
]

const checkboxOptions = [
    {
        label: 'Checkbox Option 1',
        value:'1'
    },
    {
        label: 'Checkbox Option 2',
        value:'2'
    },
]

const DATE_FORMAT = 'dd-MMM-yy'

export const FormBadDemo = () => {

    const [textValue , setTextValue] = useState('');
    const [dropdownValue , setDropDownValue] = useState('');
    const [sliderValue , setSliderValue] = useState(0);
    const [dateValue , setDateValue] = useState(new Date());
    const [radioValue , setRadioValue] = useState('');
    const [checkboxValue, setSelectedCheckboxValue] = useState<any>([])

    const onTextChange = (e:any) => setTextValue(e.target.value)
    const onDropdownChange = (e:any) => setDropDownValue(e.target.value)
    const onSliderChange = (e:any) => setSliderValue(e.target.value)
    const onDateChange = (e:any) => setDateValue(e.target.value)
    const onRadioChange = (e:any) => setRadioValue(e.target.value)

    const handleSelect = (value:any) => {
        const isPresent = checkboxValue.indexOf(value)
        if (isPresent !== -1) {
            const remaining = checkboxValue.filter((item:any) => item !== value)
            setSelectedCheckboxValue(remaining)
        } else {
            setSelectedCheckboxValue((prevItems:any) => [...prevItems, value])
        }
    }

    const handleSubmit = () => {
        console.log({
            textValue: textValue,
            dropdownValue: dropdownValue,
            sliderValue: sliderValue,
            dateValue: dateValue,
            radioValue: radioValue,
            checkboxValue: checkboxValue,
        })
    }

    const handleReset = () => {
        setTextValue('')
        setDropDownValue('')
        setSliderValue(0)
        setDateValue(new Date())
        setRadioValue('')
        setSelectedCheckboxValue('')
    }

    return <form>

        <FormLabel component='legend'>Text Input</FormLabel>
        <TextField
            size='small'
            error={false}
            onChange={onTextChange}
            value={textValue}
            fullWidth
            label={'text Value'}
            variant='outlined'
        />

        <FormLabel component='legend'>Dropdown Input</FormLabel>
        <Select id='site-select' inputProps={{ autoFocus: true }} value={dropdownValue} onChange={onDropdownChange} >
            {options.map((option: any) => {
                return (
                    <MenuItem key={option.value} value={option.value}>
                        {option.label}
                    </MenuItem>
                )
            })}
        </Select>

        <FormLabel component='legend'>Slider Input</FormLabel>
        <Slider
            value={sliderValue}
            onChange={onSliderChange}
            valueLabelDisplay='auto'
            min={0}
            max={100}
            step={1}
        />

        <FormLabel component='legend'>Date Input</FormLabel>
        <KeyboardDatePicker
            fullWidth
            variant='inline'
            defaultValue={new Date()}
            id={`date-${Math.random()}`}
            value={dateValue}
            onChange={onDateChange}
            rifmFormatter={(val) => val.replace(/[^[a-zA-Z0-9-]*$]+/gi, '')}
            refuse={/[^[a-zA-Z0-9-]*$]+/gi}
            autoOk
            KeyboardButtonProps={{
                'aria-label': 'change date'
            }}
            format={DATE_FORMAT}
        />

        <FormLabel component='legend'>Radio Input</FormLabel>
        <RadioGroup aria-label='gender' value={radioValue} onChange={onRadioChange}>
            {radioOptions.map((singleItem) => (
                <FormControlLabel value={singleItem.value} control={<Radio />} label={singleItem.label} />
            ))}
        </RadioGroup>

        <FormLabel component='legend'>Checkbox Input</FormLabel>
        <div>
            {checkboxOptions.map(option =>
                <Checkbox checked={checkboxValue.includes(option.value)} onChange={() => handleSelect(option.value)} />
            )}
        </div>

        <Button onClick={handleSubmit} variant={'contained'} > Submit </Button>
        <Button onClick={handleReset} variant={'outlined'}> Reset </Button>

    </form>
}

Enter fullscreen mode Exit fullscreen mode

This is a pretty standard form. We have used some most common form inputs. But this component has some problems.

  • onChange handlers are repetitive. If we had multiple text inputs we needed to manage those individuals which is so frustrating.

  • If we want to handle errors then they will explode in size and complexity.

Main Idea

As you know react-hook-form works perfectly with the default input components of HTML. But it’s not the case if we use various component libraries like Material-UI or Ant design or any other for that matter.

To handle those cases what react-hook-form does is export a special wrapper component named Contrtoller. If you know how this special component works then integrating it with any other library will be a piece of cake.

The skeleton of the Controller component is like the following.

<Controller
    name={name}
    control={control}
    render={({ field: { onChange, value }}) => (
       <AnyInputComponent
          onChange={onChange}
          value={value}
        />
    )}
/>
Enter fullscreen mode Exit fullscreen mode

If you have done basic form handling (Which I am sure you have done) then you know that two fields are important for any input component. One is the value and another one is the onChange .

So our Controller component here injects these 2 properties along with all other magic of react-hook-form into the components.

Everything else works like a charm! let’s see it in action.

Form Input Props

Every form input needs two basic properties. they are name and value . These 2 properties control the functionalities of all the functionalities.

So, add a type for this. If you are using javascript you won’t need this.

export interface FormInputProps {
    name: string
    label: string
}
Enter fullscreen mode Exit fullscreen mode

Text Input

This is the most basic component that we need to take care of first. Following is an isolated Text input component built with material UI.

import React from 'react'
import { Controller, useFormContext } from 'react-hook-form'
import TextField from '@material-ui/core/TextField'
import {FormInputProps} from "./FormInputProps";

export const FormInputText = ({ name, label }: FormInputProps) => {
    const { control } = useFormContext()

    return (
        <Controller
            name={name}
            control={control}
            render={({ field: { onChange, value }, fieldState: { error }, formState }) => (
                <TextField
                    helperText={error ? error.message : null}
                    size='small'
                    error={!!error}
                    onChange={onChange}
                    value={value}
                    fullWidth
                    label={label}
                    variant='outlined'
                />
            )}
        />
    )
}
Enter fullscreen mode Exit fullscreen mode

In this component, we are using the control property form react-hook-form . As we already know this is exported from the useForm() hook of the library.

We also showed how to display the errors. For the rest of the components, we will skip this for brevity.

Radio Input

Our second most common input component is Radio . The code for integrating with material-ui is like the following.

import React from 'react'
import { FormControl, FormControlLabel, FormHelperText, FormLabel, Radio, RadioGroup } from '@material-ui/core'
import { Controller, useFormContext } from 'react-hook-form'
import {FormInputProps} from "./FormInputProps";

const options = [
    {
        label: 'Radio Option 1',
        value:'1'
    },
    {
        label: 'Radio Option 2',
        value:'2'
    },
]

export const FormInputRadio: React.FC<FormInputProps> = ({ name, label }) => {
    const { control, formState: { errors }} = useFormContext()

    const errorMessage = errors[name] ? errors[name].message : null

    return (
        <FormControl component='fieldset'>
            <FormLabel component='legend'>{label}</FormLabel>
            <Controller
                name={name}
                control={control}
                render={({ field: { onChange, value }, fieldState: { error }, formState }) => (
                    <RadioGroup aria-label='gender' value={value} onChange={onChange}>
                        {options.map((singleItem) => (
                            <FormControlLabel value={singleItem.value} control={<Radio />} label={singleItem.label} />
                        ))}
                    </RadioGroup>
                )}
            />
            <FormHelperText color={'red'}>{errorMessage ? errorMessage : ''}</FormHelperText>
        </FormControl>
    )
}

Enter fullscreen mode Exit fullscreen mode

We need to have an options array in which we need to pass the available options for that component.

If you look closely you will see that these 2 components are mostly similar in usage.

Dropdown Input

Our next component is Dropdown. Almost any form needs some kind of dropdown. The code for Dropdown the component is like the following

import React from 'react'
import { FormControl, InputLabel, MenuItem, Select } from '@material-ui/core'
import { useFormContext, Controller } from 'react-hook-form'
import {FormInputProps} from "./FormInputProps";

const options = [
    {
        label: 'Dropdown Option 1',
        value:'1'
    },
    {
        label: 'Dropdown Option 2',
        value:'2'
    },
]
export const FormInputDropdown: React.FC<FormInputProps> = ({ name, label }) => {
    const { control } = useFormContext()

    const generateSingleOptions = () => {
        return options.map((option: any) => {
            return (
                <MenuItem key={option.value} value={option.value}>
                    {option.label}
                </MenuItem>
            )
        })
    }

    return (
        <FormControl size={'small'}>
            <InputLabel>{label}</InputLabel>
            <Controller
                render={({ field }) => (
                    <Select id='site-select' inputProps={{ autoFocus: true }} {...field}>
                        {generateSingleOptions()}
                    </Select>
                )}
                control={control}
                name={name}
            />
        </FormControl>
    )
}
Enter fullscreen mode Exit fullscreen mode

In this component, we have removed the error showing the label. It will be just like the Radio component

Date Input

This is a common yet special component. In Material UI we don’t have any Date component which works out of the box. We need to have some helper libraries.

First, install those dependencies

yarn add @date-io/date-fns@1.3.13 @material-ui/pickers@3.3.10 date-fns@2.22.1
Enter fullscreen mode Exit fullscreen mode

Be careful about the versions. Otherwise, it may give some weird issues. We also need to wrap our data input component with a special wrapper.

import React from 'react'
import DateFnsUtils from '@date-io/date-fns'
import {KeyboardDatePicker, MuiPickersUtilsProvider} from '@material-ui/pickers'
import { Controller, useFormContext } from 'react-hook-form'
import {FormInputProps} from "./FormInputProps";
const DATE_FORMAT = 'dd-MMM-yy'

export const FormInputDate = ({ name, label }: FormInputProps) => {
    const { control } = useFormContext()

    return (
        <MuiPickersUtilsProvider utils={DateFnsUtils}>
            <Controller
                name={name}
                control={control}
                render={({ field, fieldState, formState }) => (
                    <KeyboardDatePicker
                        fullWidth
                        variant='inline'
                        defaultValue={new Date()}
                        id={`date-${Math.random()}`}
                        label={label}
                        rifmFormatter={(val) => val.replace(/[^[a-zA-Z0-9-]*$]+/gi, '')}
                        refuse={/[^[a-zA-Z0-9-]*$]+/gi}
                        autoOk
                        KeyboardButtonProps={{
                            'aria-label': 'change date'
                        }}
                        format={DATE_FORMAT}
                        {...field}
                    />
                )}
            />
        </MuiPickersUtilsProvider>
    )
}
Enter fullscreen mode Exit fullscreen mode

I have chosen date-fns. You can pick others like moment.

Checkbox Input

This is the most tricky component. There are not clear examples of how to use this component with react-hook-form . To handle the input we have to do some manual labor.

We are controlling the selected states here to handle the inputs properly.

import React, { useEffect, useState } from 'react'
import { Checkbox, FormControl, FormControlLabel, FormHelperText, FormLabel } from '@material-ui/core'
import { Controller, useFormContext } from 'react-hook-form'
import {FormInputProps} from "./FormInputProps";

const options = [
    {
        label: 'Checkbox Option 1',
        value:'1'
    },
    {
        label: 'Checkbox Option 2',
        value:'2'
    },
]

export const FormInputCheckbox: React.FC<FormInputProps> = ({ name, label }) => {
    const [selectedItems, setSelectedItems] = useState<any>([])
    const { control, setValue, formState: { errors }} = useFormContext()

    const handleSelect = (value:any) => {
        const isPresent = selectedItems.indexOf(value)
        if (isPresent !== -1) {
            const remaining = selectedItems.filter((item:any) => item !== value)
            setSelectedItems(remaining)
        } else {
            setSelectedItems((prevItems:any) => [...prevItems, value])
        }
    }

    useEffect(() => {
        setValue(name, selectedItems)
    }, [selectedItems])

    const errorMessage = errors[name] ? errors[name].message : null

    return (
        <FormControl size={'small'} variant={'outlined'}>
            <FormLabel component='legend'>{label}</FormLabel>

            <div>
                {options.map((option:any) => {
                    return (
                        <FormControlLabel
                            control={
                                <Controller
                                    name={name}
                                    render={({ field: { onChange: onCheckChange } }) => {
                                        return <Checkbox checked={selectedItems.includes(option.value)} onChange={() => handleSelect(option.value)} />
                                    }}
                                    control={control}
                                />
                            }
                            label={option.label}
                            key={option.value}
                        />
                    )
                })}
            </div>

            <FormHelperText>{errorMessage ? errorMessage : ''}</FormHelperText>
        </FormControl>
    )
}
Enter fullscreen mode Exit fullscreen mode

Now you just give it a list of options and everything works like a charm!

Slider Input

Our final component is a Slider component. Which is a fairly common component. The code is simple to understand

import React, {ChangeEvent, useEffect} from 'react'
import { FormLabel, Slider} from '@material-ui/core'
import { Controller, useFormContext } from 'react-hook-form'
import {FormInputProps} from "./FormInputProps";

export const FormInputSlider = ({ name, label }: FormInputProps) => {

    const { control , watch} = useFormContext()
    const [value, setValue] = React.useState<number>(30);

    const formValue = watch(name)
    useEffect(() => {
        if (value) setValue(formValue)
    }, [formValue])

    const handleChange = (event: any, newValue: number | number[]) => {
        setValue(newValue as number);
    };

    return (
        <>
            <FormLabel component='legend'>{label}</FormLabel>
            <Controller
                name={name}
                control={control}
                render={({ field, fieldState, formState }) => (
                    <Slider
                        {...field}
                        value={value}
                        onChange={handleChange}
                        valueLabelDisplay='auto'
                        min={0}
                        max={100}
                        step={1}
                    />
                )}
            />
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

You can customize the handleChange function to make it a two-end slider component(useful for time-range). Just change the number to number[]

Hook Everything Together

Now let’s use all of these components inside our Final Form. Which will take advantage of the reusable components we just made.

import {Button, Paper, Typography} from "@material-ui/core";
import { FormProvider, useForm } from 'react-hook-form'
import {FormInputText} from "./form-components/FormInputText";
import {FormInputCheckbox} from "./form-components/FormInputCheckbox";
import {FormInputDropdown} from "./form-components/FormInputDropdown";
import {FormInputDate} from "./form-components/FormInputDate";
import {FormInputSlider} from "./form-components/FormInputSlider";
import {FormInputRadio} from "./form-components/FormInputRadio";

export const FormDemo = () => {
    const methods = useForm({defaultValues: defaultValues})
    const { handleSubmit, reset } = methods
    const onSubmit = (data) => console.log(data)

    return <Paper style={{display:"grid" , gridRowGap:'20px' , padding:"20px"}}>
        <FormProvider {...methods}>
            <FormInputText name='textValue' label='Text Input' />
            <FormInputRadio name={'radioValue'} label={'Radio Input'}/>
            <FormInputDropdown name='dropdownValue' label='Dropdown Input' />
            <FormInputDate name='dateValue' label='Date Input' />
            <FormInputCheckbox name={'checkboxValue'} label={'Checkbox Input'}  />
            <FormInputSlider name={'sliderValue'} label={'Slider Input'}  />
        </FormProvider>
        <Button onClick={handleSubmit(onSubmit)} variant={'contained'} > Submit </Button>
        <Button onClick={() => reset()} variant={'outlined'}> Reset </Button>
    </Paper>
}
Enter fullscreen mode Exit fullscreen mode

Finally, Our Form looks like this. Isn’t it great?

I hope you learned something today. Have a great day!

Resources:

Have something to say? Get in touch with me via LinkedIn or Personal Website

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .