Структура против хаоса - элегантное решение для создания форм в React.js

Структура против хаоса - элегантное решение для создания форм в React.js

«Ну вот, опять эти формы...» — знакомая мысль? Мы постоянно ищем способ сделать их удобными и предсказуемыми, но идеальное решение все никак не находится. В этой серии статей Артем Леванов, Front Lead в WebRise, подробно разберет, с какими сложностями мы сталкиваемся, изучим разные подходы и в итоге придем к элегантному решению: как описывать все формы на сайте, используя всего по одному компоненту для каждого типа полей.

Проблемы кастомных React форм

Когда разработчик впервые сталкивается с задачей создать форму в React, всё кажется довольно простым: пара input, пара стейтов и обработчик submit.

Но чем больше полей и логики появляется, тем быстрее «игрушечное» решение превращается в гору кода, которую тяжело поддерживать. Вот простая форма логина. Пара input, к ним обработчики useState, перед отправкой запуск валидации. Все логично, просто и понятно.

export const LoginForm = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [errors, setErrors] = useState<{ email?: string; password?: string }>({}); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!email.includes('@')) { setErrors({ email: 'Некорректный email' }); } if (password.length < 6) { setErrors((prev) => ({ ...prev, password: 'Минимум 6 символов' })); } }; return ( <form onSubmit={handleSubmit}> <input value={email} onChange={(e) => setEmail(e.target.value)} /> {errors.email && <span>{errors.email}</span>} <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> {errors.password && <span>{errors.password}</span>} <button type="submit">Войти</button> </form> ); };

Через какое-то время нас попросят сделать форму регистрации, где уже 10-20 полей. Что нам предстоит:

  • написать для каждого поля свой useState;
  • написать много if для проверки каждого поля на валидность;
  • написать для каждого поля свое отображение ошибок.

В итоге файл формы может занимать сотни строк и мы сталкиваемся с очевидными проблемами:

  • сложно ревьюить такую большую форму;
  • легко допустить ошибку при написании кода (много однотипных действий и можно забыть например отрендерить ошибку для нового поля);
  • невозможно быстро обучить новых разработчиков, потому что у каждого своя логика «как писать формы».

Так же, очевидной проблемой будет повторное использование форм. Допустим, у нас есть форма регистрации и форма профиля. Поля вроде «email» и «пароль» в них одинаковые, но код приходится дублировать. В результате:

  • одно и то же поле реализовано в двух местах;
  • правила валидации могут различаться;
  • стили и UX становятся непоследовательными.

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

Когда ручной код становится неуправляемым, разработчики ищут способы унификации работы с формами. В целом у нас три пути. Написать собственный велосипед, подключить готовые библиотеки, попытаться найти золотую середину, сочетая библиотеки и собственные слои абстракции. Первая реакция на хаос в коде — это желание спрятать его под универсальный компонент. И это приводит к созданию собственных франкенштейнов. Как пример компонент, который является и простым input, input c маской, селектом, обрабатывает ошибки, видимость поля:

import React from 'react'; import './style.sass'; import InputMask from 'react-input-mask'; export default class Inputs extends React.Component { render() { return ( <div className={'inputBlock ' + (this.props.option_show && 'show ') + (this.props.required && ' required')}> {this.props.label && <label htmlFor={this.props.id}>{this.props.label}</label> } <span className="star">*</span> {this.props.options && <span className="arrowSelect"></span> } <InputMask type={this.props.type || 'text'} className={'input ' + this.props.className} style={this.props.style} name={this.props.name} value={this.props.value} placeholder={this.props.placeholder} id={this.props.id} onChange={(e) => { this.props.onChange(this.props.name, e.target.value, this.props.index) }} onClick={() => {this.props.options && this.props.onClick()}} readOnly={this.props.options} required={this.props.required} disabled={this.props.disabled} mask={this.props.mask} /> {this.props.options && <div className={"options " + (this.props.option_show && 'showOptions ')}> {Object.keys(this.props.options).map((key) => ( <div key={key} className={"option " + (this.props.option_active === key && 'active')} onClick={() => {this.props.option_click(key, this.props.index)}} > {this.props.options[key]} </div> ))} </div> } {this.props.errorText && <div className="errorText">{this.props.errorText}</div> } </div> ); }

На первый взгляд универсально, на практике же компонент перегружен условиями, его сложно поддерживать и тестировать, со временем даже автору будет сложно разобраться в его работе. И кажется теперь, что где-то мы свернули не туда с универсальностью и собственными решениями. Мы тоже так подумали и решили, что есть же уже готовые решения (React Hook Form, Formik), давайте возьмем их.

Популярные решения решают сразу несколько проблем:

  • упрощает работу со стейтом и валидацией;
  • экономят код, дают хорошую производительность;
  • требуют меньше времени на обучение новых разработчиков работе с кодом наших форм.

Однако тут есть проблема, библиотеки помогают упаковать логику, но не ui. Вот пример формы с полями email и password и библиотекой React Hook Form.

import { useForm } from "react-hook-form"; export const LoginForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm(); return ( <form onSubmit={handleSubmit((data) => console.log(data))}> <input type="email" placeholder="Email" {...register('email', { required: 'Введите email' })} /> {errors.email && <span>{errors.email.message}</span>} <input type="password" placeholder="Пароль" {...register('password', { required: 'Введите пароль' })} /> {errors.password && <span>{errors.password.message}</span>} <button type="submit">Войти</button> </form> ); };

У нас ушла пачка useState, валидации теперь прописываются в компоненте, мы сильно сократили верхнюю часть, относительно первой формы. Но что делать с выводом ошибок и дизайном каждого поля? Упакуем в отдельный компонент и в название добавим приписку Field. Так мы получим InputField, который уже можно переиспользовать в разных формах

type InputFieldProps = { label: string; error?: string; } & React.InputHTMLAttributes<HTMLInputElement>; export const InputField = ({ label, error, ...props }: InputFieldProps) => ( <div className="field"> <label className="field__label">{label}</label> <input className={`field__input ${error ? 'field__input--error' : ''}`} {...props} /> {error && <span className="field__error">{error}</span>} </div> );

В итоге получаем достаточно универсальный компонент для использования в разных формах

import { useForm } from "react-hook-form"; import { InputField } from "./InputField"; export const LoginForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm(); return ( <form onSubmit={handleSubmit((data) => console.log(data))}> <InputField label="Email" type="email" error={errors.email?.message} {...register('email', { required: 'Введите email' })} /> <InputField label="Пароль" type="password" error={errors.password?.message} {...register('password', { required: 'Введите пароль' })} /> <button type="submit">Войти</button> </form> ); };

Уже не плохо, но мы все еще можем столкнуться с проблемами. Например нам будет сложно использовать InputField вне тега form, для каждого поля мы будем дублировать логику обработки ошибок, нет возможности внести даже небольшие изменения в дизайн поля.

Наше решение

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

Примитивы - это маленькие переиспользуемые компоненты, которые:

  • ничего не знают про react-hook-form или бизнес-логику;
  • отвечают за внешний вид и базовое поведение;
  • легко расширяются (например за счет тем для поддержки разных стилей).

Вот пример простого примитива Input:

import { InputHTMLAttributes } from 'react'; import { classNames } from '@/shared/lib/classNames/classNames'; import cls from './Input.module.scss'; export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { className?: string; ref?: React.Ref<HTMLInputElement>; } export const Input = (props: InputProps) => { const { className, type = 'text', ref, ...otherProps } = props; return ( <input ref={ref} type={type} className={classNames(cls.Input, {}, [className])} {...otherProps} /> ); };

Нет ничего лишнего только код самого поля. Такую часть можно легко вставить в любую систему при этом она будет иметь уже нужный нам вид.

Обертку, в которую мы вставляем наши примитивы мы назвали ячейкой Cell.

import { classNames } from '@/shared/lib/classNames/classNames'; import { FieldErrorType, useFieldError } from '@/shared/lib/hooks/useFieldError'; import cls from './Cell.module.scss'; import { ReactNode } from 'react'; interface CellProps { className?: string; label?: string; withoutBorder?: boolean; error?: FieldErrorType; noteText?: ReactNode; children: ReactNode; } export const Cell = (props: CellProps) => { const { className, label, withoutBorder, error, noteText, children } = props; const errorMessage = useFieldError(error); return ( <div className={classNames(cls.Cell, {}, [className])}> {!withoutBorder && ( <div className={cls.content}> <label className={cls.name}>{label || ''}</label> <div className={cls.data}>{children}</div> </div> )} {noteText && <div className={cls.note}>{noteText}</div>} {withoutBorder && children} {errorMessage && <div className={cls.errorMessage}>{errorMessage}</div>} </div> ); };

Она отвечает за единый дизайн поля, вывод ошибок и сопутствующей информации (подписи, примечания).

Совмещая ячейку и примитивные поля, мы формируем уже непосредственный компонент формы, который работает с логикой react-hook-form.

import { InputHTMLAttributes } from 'react'; import { useFormContext } from 'react-hook-form'; import { Cell } from '@/shared/ui/FormPrimitives/Cell/Cell'; import { Input } from '@/shared/ui/FormPrimitives/Input/Input'; interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> { className?: string; label: string; name: string; } export const TextField = (props: TextFieldProps) => { const { className, label, name, ...otherProps } = props; const { register, formState: { errors }, } = useFormContext(); return ( <Cell className={className} label={label} error={errors[name]} > <Input {...register(name)} {...otherProps} /> </Cell> ); };

На каждый тип поля, который есть в html, мы создаем отдельный компонент TextField, TextareaField, SelectField, CheckboxField, RadioButtonField, FileField, плюс отдельно выделяем компонент под поля с масками MaskedField. Таким образом перекрываем все возможные поля, которые могут понадобиться при разработке форм.

import { classNames } from '@/shared/lib/classNames/classNames'; import { FormProvider, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { TextField, MaskedField } from '@/shared/ui/FormFields'; import { ZzServerFormSchema, ZzServerFormType } from '../../model/types/zzServerFormSchema'; import { defaultValues } from './ZzServerForm.const'; import { masks } from '@/shared/lib/masks/commonMasks'; import cls from './ZzServerForm.module.scss'; interface ZzServerFormProps { className?: string; } export const ZzServerForm = (props: ZzServerFormProps) => { const { className } = props; const methods = useForm<ZzServerFormType>({ resolver: zodResolver(ZzServerFormSchema), defaultValues, mode: 'onSubmit', }); const { handleSubmit } = methods; const onSubmit = handleSubmit(async (_, event) => { const formData = new FormData(event?.target as HTMLFormElement); console.log('Отправка формы: ', formData); }); return ( <div className={classNames(cls.ZzServerForm, {}, [className])}> <FormProvider {...methods}> <form onSubmit={onSubmit}> <div className={cls.row}> <TextField name="name" label="Заголовок" /> <MaskedField name="phone" label="Телефон" maskOptions={masks.phone} placeholder="+7 (___) ___-__-__" /> </div> <input type="submit" /> {methods.formState.errors.root && <div className={cls.error}>{methods.formState.errors.root.message}</div>} </form> </FormProvider> </div> ); };

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

Заключение

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

Представленный подход предлагает решение этих проблем через систему простых примитивов и типовых полей. Базовые примитивы, такие как Input и Cell, обеспечивают единообразие дизайна и поведения, на основе которых строятся конкретные поля — TextField, MaskField, RadioField и другие. Это позволяет собирать формы из готовых, отлаженных блоков, а не писать их с нуля каждый раз.

В результате разработчики получают читаемый код, где каждое поле представлено небольшим самостоятельным компонентом, а не сотнями строк условной логики. Это обеспечивает единый UX/UI для всех форм, что гарантирует консистентность и упрощает работу дизайнеров и тестировщиков. Компоненты легко переиспользуются не только в формах, но и в других сценариях, таких как поиск, а система остаётся гибкой и простой для масштабирования.

Со стороны бизнеса это означает повышение скорости разработки. Создание новых форм ускоряется за счёт готовых решений, покрывающих до 90% потребностей. Надёжность системы возрастает, снижая количество ошибок при внесении изменений или добавлении новых полей. Пользователи получают предсказуемый и интуитивно понятный опыт взаимодействия, а новые члены команды быстрее входят в проект, осваивая единую библиотеку компонентов вместо разбора множества кастомных реализаций.

По вопросам, телеграм @webrise1

Начать дискуссию