
«Ну вот, опять эти формы...» — знакомая мысль? Мы постоянно ищем способ сделать их удобными и предсказуемыми, но идеальное решение все никак не находится. В этой серии статей Артем Леванов, 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% потребностей. Надёжность системы возрастает, снижая количество ошибок при внесении изменений или добавлении новых полей. Пользователи получают предсказуемый и интуитивно понятный опыт взаимодействия, а новые члены команды быстрее входят в проект, осваивая единую библиотеку компонентов вместо разбора множества кастомных реализаций.
Комментарии (6)
Elendiar1
03.10.2025 02:32Имею дело с формами на реакте, использую компоненты antd, думаю в любой другой ui библиотеке есть удобная работа с формами из коробки.
Городить свои костыли в таком унылом деле как формошлёпство желания конечно никакого.
А так красота, initialValues, onFinish, валидация... все удобно. А переопределить стили можно и с помощью css modules
webrise Автор
03.10.2025 02:32Antd - отличная библиотека, которая справиться со многими задачами по созданию форм. Мы пытались внедрять различные библиотеки, но каждый раз упирались в их ограничения по UI/UX, так как переиспользуем формы между разными проектами, где дизайн и логика работы могут сильно отличаться. Что бы решить эту проблему, нам нужен полный контроль над формой. Именно это и привело нас к созданию собственного решения.
ironvd
03.10.2025 02:32Подход имеет смысл когда необходимо реализовать уникальный дизайн системы по макетами с нуля.
P.S.:<input type="submit" />
желательно вынести так же в отдельный компонент.webrise Автор
03.10.2025 02:32В точку. Часто с нуля пишем дизайн и логику форм и нужна уверенность, что мы не будем ограниченны рамками библиотек, так как рано или поздно в них упремся
Asantasan
Так а где удобство? С вашим подходом большое количество разных полей не сильно удобнее будет делать. Что мешает делить на группы инпутов, связанных между собой какой-либо логикой?
имхо, если в проекте реально много разных форм, то стоит посмотреть в сторону конфига. Довольно кастомизируемое решение
webrise Автор
В статье описана база по созданию компонентов полей. Далее с ней можно работать как удобно. Если у вас много простых форм из 3-7 полей, нет взаимной валидации или динамики, то конфиги подходят. Если что-то сложнее, то конфиги слишком сильно усложняются. Плюс конфиги сложно расширять, так как от заказчика часто приходят новые требования, которые проблемно вписать в логику конфига.