«Ну вот, опять эти формы...» — знакомая мысль? Мы постоянно ищем способ сделать их удобными и предсказуемыми, но идеальное решение все никак не находится. В этой серии статей Артем Леванов, 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% потребностей. Надёжность системы возрастает, снижая количество ошибок при внесении изменений или добавлении новых полей. Пользователи получают предсказуемый и интуитивно понятный опыт взаимодействия, а новые члены команды быстрее входят в проект, осваивая единую библиотеку компонентов вместо разбора множества кастомных реализаций.

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