Очистка React компонентов с помощью React Hook Form и Material UI

React Hook Form — одна из самых популярных библиотек для обработки элементов ввода формы в экосистеме React.

Но добиться ее правильной интеграции может быть непросто, если использовать какую-либо библиотеку компонентов.

Сегодня я покажу вам, как можно интегрировать React Hook Form с различными компонентами Material UI.

Предварительное условие

Я не буду подробно рассказывать о том, как использовать react-hook-form. Если же вы еще не знаете, как использовать react-hook-form, я настоятельно рекомендую вам сначала ознакомиться с этой статьей.

Все, что я могу сказать — вы не пожалеете о том, что изучили эту библиотеку.

Начальный код

Давайте посмотрим на код, с которого мы начнем.

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

FormBadDemo.tsx

Это довольно стандартная форма. Мы использовали несколько наиболее распространенных элементов ввода формы. Но у данного компонента есть некоторые проблемы.

  • Обработчики onChange работают однообразно, в повторяющемся режиме. Если бы у нас было несколько текстовых элементов ввода, нам пришлось бы управлять ими по отдельности, а это так утомительно.

  • Когда нам нужно обрабатывать ошибки, то размеры и сложность таких операций возрастают.

Основная идея

Как вы знаете, react-hook-form отлично работает со стандартными компонентами ввода HTML. Однако все обстоит иначе, если мы используем различные библиотеки компонентов, такие как Material-UI, Ant design или любые другие.

Для таких случаев react-hook-form экспортирует специальный компонент-обертку под названием Controller . Если вам известно, как работает этот специальный компонент, то интегрировать его с любой другой библиотекой будет проще простого.

Структура компонента Controller выглядит следующим образом.

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

Если вы занимались базовой обработкой форм, то знаете, что для любого компонента ввода важны два поля. Одно из них — value, а другое — onChange.

Поэтому наш компонент Controller инжектирует эти два свойства вместе со всем волшебным функционалом react-hook-form в компоненты.

Все остальное работает как по маслу! Давайте посмотрим на это в действии.

Пропсы элементов ввода формы

Каждому элементу ввода формы нужны два основных свойства — name и value. Эти 2 свойства управляют всеми функциональными возможностями.

Итак, добавьте тип для этого. Если вы используете javascript, вам это не понадобится.

export interface FormInputProps {
    name: string
    label: string
}

FormInputProps.ts

Ввод Text

Это самый основной компонент, о котором нужно позаботиться в первую очередь. Ниже представлен изолированный компонент ввода текста, построенный с помощью 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'
                />
            )}
        />
    )
}

FormInputText.tsx

В этом компоненте мы используем свойство control для формы react-hook-form. Оно экспортируется из хука useForm() библиотеки.

Мы также продемонстрировали, как отображать ошибки. Для остальных компонентов пропустим это для краткости.

Ввод Radio

Вторым наиболее распространенным компонентом ввода является селектор Radio. Код для интеграции с material-ui выглядит следующим образом.

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

Нам нужно иметь массив options, в который необходимо передать доступные опции для этого компонента.

Если внимательно присмотреться, то будет видно, что эти два компонента в основном схожи по использованию.

Ввод Dropdown 

Наш следующий компонент - это выпадающий список (Dropdown). Почти любая форма нуждается в каком-либо виде выпадающего списка. Код для компонента Dropdown выглядит следующим образом

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

FormInputDropdown.tsx

В этом компоненте мы убрали ошибку с отображением метки. Он будет таким же, как Radio.

Ввод Date

Это распространенный, но при этом особенный компонент ввода даты. В Material UI у нас нет ни одного компонента Date, который работал бы "из коробки". Для этого нам необходимы вспомогательные библиотеки.

Сначала установите эти зависимости

yarn add @date-io/date-fns@1.3.13 @material-ui/pickers@3.3.10 date-fns@2.22.1

Будьте осторожны с версиями. Это может привести к некоторым странностям. Нам также нужно обернуть наш компонент ввода данных специальной оберткой.

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

FormInputDate.tsx

Я выбрал date-fns. Вы можете выбрать другие, например moment.

Ввод Checkbox 

Это самый сложный компонент (флажок). Не существует четких примеров использования этого компонента с react-hook-form. Для обработки ввода нам придется немного поработать вручную.

Здесь мы контролируем выбранные состояния, чтобы правильно обрабатывать вводимые данные.

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

FormInputCheckbox.tsx

Теперь вы просто даете ему список опций, и все работает как надо!

Ввод Slider

Наш последний компонент - это компонент Slider (слайдер). Он является достаточно распространенным. Код прост для понимания

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

Вы можете настроить функцию handleChange, чтобы сделать компонент двухсторонним слайдером (полезно для временного диапазона). Просто замените number на number[].

Соедините все вместе

Теперь давайте используем все эти компоненты внутри нашей конечной формы. Это позволит использовать преимущества компонентов для многократного использования, которые мы только что создали.

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

FormDemo.tsx

В итоге наша форма выглядит следующим образом.


React-hooks появились в React с версии 16.8, сегодня они используются уже повсеместно. Всех заинтересованных приглашаем на двухдневный онлайн-интенсив, на котором мы разберемся, как работать с React-hooks, создадим компонент с использованием hooks, а также научимся делать кастомные hooks.Поработаем с react-testing-library и научимся тестировать компоненты и кастомные hooks. Интенсив будет полезен frontend JavaScript разработчикам и начинающим React разработчикам. Регистрация доступна по ссылке.

Комментарии (8)


  1. nin-jin
    22.02.2022 18:00
    -4

    А написали бы сразу на $mol у вас бы получилось ещё меньше кода:

    $my_form_demo $my_form sub /
        <= Text_value $my_text
            title @ \Text Input
            value?val <=> text_value?val \
        <= Radio_value $my_radio
            title @ \Radio Input
            value?val <=> radio_value?val \
        <= Dropdown_value $my_dropdown
            title @ \Dropdown Input
            value?val <=> dropdown_value?val \
            options <= dropdown_value_options /string
        <= Date_value $my_date
            title @ \Date Input
            value?val <=> date_value?val $mol_time_moment
        <= Checkbox_value $my_checkbox
            title @ \Chekbox Input
            value?val <=> checkbox_value?val false
        <= Slider_value $my_slider
            title @ \Slider Input
            value?val <=> slider_value?val 0
        <= Submit $my_button_major
            title @ \Submit
            click?val <=> submit?val null
        <= Reset $my_button_minor
            title @ \Reset
            click?val <=> reset?val null

    И не пришлось бы рефакторить.


    1. RealPeha
      22.02.2022 21:49
      +7

      Абракадабра какая-то


  1. yarkov
    22.02.2022 18:06
    +16

    Есть ощущение, что это не сокращение до 30 строк, а разбиение на компоненты.


    1. efim4eg
      23.02.2022 04:19
      +1

      поддерживаю, это называется рефакторинг лапши)


  1. nickD
    22.02.2022 20:20
    -1

    Просветите плиз, какие преимущества React Hook Form, перед antd form?


    1. DanUnited
      24.02.2022 11:14

      Принципы работы схожи, но react hook form умеет работать с обычными даже html полями, в antd нужны обертки и обязательное наличие свойств value, onChange компонентов. Более гибкая работа с ошибками к примеру, их можно вывести куда угодно без проблем, в antd попробуйте такое же реализовать с пару строк. В третьих это очень маленькая и быстрая библиотека а не гигант.


  1. claimc
    23.02.2022 00:57
    +1

    Я полез смотреть @material-ui/core. Пакет помечен как deprecated. Почему бы вам в рефакторинг не втянуть переход на @mui/material, заявленный как наследник?


  1. valera545
    24.02.2022 11:12

    Извините, но заявленный в заголовке результат не достигнут. 30 строк — это только сборка, а декомпозированные инпуты в общей сложности ещё и на большее потянут, чем прежние 165. Не собираюсь спорить с тем, что декомпозиция — хороший тон, что декомпозированный код лучше читается и вообще гибче и удобнее, чем лапша, но это всё не значит, что мы уменьшили объём кода в 5-6 раз. Статья хорошая, но заголовок кликбейтный, а это — зло.