
Это третья статья из цикла про наш фреймворк Steroids: в ней мы рассказываем о том, как пришли к созданию собственного движка форм для React. Если вы ещё не читали предыдущие материалы, рекомендуем с ними ознакомиться:
Почему мы решили создать свои формы?
Изначально для работы с формами мы использовали библиотеку Redux-Form. Она обеспечивала автоматическое обновление состояния, валидацию и другие полезные функции.
Со временем мы упёрлись в проблемы производительности. В сложных формах на React и React Native постоянно сталкивались с лишними перерендерами и фризами страницы. К тому же Redux-Form перестала поддерживаться автором. Поэтому настал момент переехать на другое решение или написать свой движок. Для этого предварительно выделили основные пункты, которые нам нравились в Redux-Form, и сформулировали то, что мы хотим от собственного решения.
Сохранить предыдущее API
На тот момент у нас уже было много проектов, которые нужно было поддерживать. Чтобы переход прошёл максимально безболезненно, требовалось сохранить прежнее API.
Иметь возможность использовать как глобальный, так и локальный стейт
Хотелось, чтобы форма могла работать и с глобальным redux-хранилищем — для сложных wizard-форм, и с локальным состоянием — для более быстрого рендера.
Иметь возможность рендерить поле разными способами
Формы в проектах бывают разные, и одного способа рендера полей оказалось мало — в итоге у нас получилось три варианта для рендера поля.
Обычным компонентом поля (InputField
, DropDownField
, TextField
и тд):
<InputField
attribute='name'
label='Имя'
/>
Применяется, когда очевидно, какое поле нужно отрендерить.
Передавать сам компонент в обертку <Field/>
:
<Field
attribute='name'
label='Имя'
component={InputField}
/>
Данный способ применяется, когда мы заранее не знаем, какое поле нужно отрендерить или оно может меняться, в зависимости от условий.
Передавать название компонента в виде строки:
<Field
attribute='name'
label='Имя'
component='InputField'
/>
Для нас это важно, так как мы рендерим динамические формы на основе мета-модели, которая приходит с бэкенда.
Иметь возможность использовать поля вне формы
То есть не оборачивать их в <Form/>
, а использовать поле как контролируемый компонент, передав value
и onChange
.
<InputField
attribute='foo'
label='FooName'
value='1'
onChange={(value) => console.log('Value:', value)}
/>
Сформулировав требования и попробовав популярные библиотеки, мы поняли, что лучший для нас вариант — написать собственный движок.
Как устроены Steroids Form
В этой главе мы разберём, как устроены Steroids Form «под капотом».
На схеме показана последовательность вложенных компонентов на примере InputField
— от общей обёртки Form
до конкретного поля ввода InputField
.

Form
Основой любой формы в Steroids выступает компонент Form
. Он отвечает за:
инициализацию провайдера данных формы;
обработку ошибок;
вызов колбэков (
onSubmit
,onError
и другие);синхронизацию значений формы с адресной строкой;
установку начальных значений.
Внутри компонента Form
создается контекст, внутри которого хранятся:
уникальный идентификатор формы, позволяющий сохранять и получать данные;
модель конфигурации полей, на основе которой происходит рендеринг;
провайдер данных формы, использующий в качестве хранилища либо React Reducer, либо Redux;
Важно: сам контекст не хранит все значения формы напрямую. В нём находятся только методы и инструменты для работы со значениями и состоянием.
Режимы работы с данными формы
Форма может работать с двумя хранилищами данных:
Локальный (React Reducer) — подходит для простых форм, где данные нужны только внутри текущего компонента.
Глобальный (Redux) — используется в случаях, когда значения формы должны сохраняться между экранами (например, в пошаговых формах) или быть доступны извне.
Благодаря общему для обоих хранилищ интерфейсу провайдера, в обоих режимах доступны одни и те же утилиты и хуки:
useForm
— используется внутриForm.tsx
. Позволяет получить текущее состояние формы: ошибки, количество сабмитов, признак отправки и метод для сохранения ошибок.useField
— используется вfieldWrapper
. Возвращает значение конкретного поля, ошибки и метод для изменения значения.select
— метод для получения значений формы напрямую.
FieldWrapper
Каждое поле формы оборачивается в fieldWrapper
. Он выполняет сразу несколько задач:
определяет, какой UI-компонент и с какими параметрами нужно отрендерить, исходя из модели и переданных данных;
из контекста получает данные провайдера: текущее значение поля, ошибки и метод изменения значения;
передаёт эти данные в компонент поля, в нашем случае в
InputField
;оборачивает это поле в
FieldLayout
, который отвечает за отображение лейбла, подсказок, обязательности и ошибок.
Компонент поля (InputField)
Компонент поля находится на самом нижнем уровне. Он отвечает за собственную бизнес-логику (например, валидацию формата данных или специфическое поведение поля). При этом он не управляет получением и сохранением значений — эта базовая логика полностью передаётся ему через fieldWrapper
.
Использование форм в Steroids
Теперь давайте на примерах посмотрим, что умеет Steroids Form.
Создание простой формы
Форма авторизации может быть описана всего парой компонентов. При успешной отправке значений выводим уведомление:
import React from 'react';
import {Form, EmailField, PasswordField, Button} from '@steroidsjs/core/ui/form';
import {useDispatch, useBem} from '@steroidsjs/core/hooks';
import {showNotification} from '@steroidsjs/core/actions/notifications';
import './LoginFrom.scss';
export default function LoginFrom() {
const bem = useBem('LoginFrom');
const dispatch = useDispatch();
return (
<div className={bem.block()}>
<Form
onSubmit={() => {
dispatch(showNotification(__('Успешно!')));
}}
>
<EmailField
attribute='email'
label='Email'
required
/>
<PasswordField
attribute='password'
label='Пароль'
required
security
/>
<Button type='submit' label='Войти' />
</Form>
</div>
);
}
Задание начальных значений
Чтобы предзаполнить поля формы, достаточно передать объект в initialValues
. Ключи должны совпадать с attribute
у полей:
<Form
initialValues={{
email: 'john@gmail.com',
}}
>
<EmailField
attribute='email'
label='Email'
required
/>
<PasswordField
attribute='password'
label='Пароль'
required
security
/>
<Button
type='submit'
label='Войти'
/>
</Form>
Хранение состояния в Redux
По умолчанию состояние формы хранится внутри самого React (через useReducer). Если же нужно иметь доступ к данным формы по всему проекту, например выводить в шапке сумму заказа, исходя из значений формы, то в этом случае удобнее подключить глобальное хранилище с помощью флага useRedux
.
const ORDER_FORM_ID = 'OrderFrom'; // идентификатор формы в Redux
export default function OrderForm() {
return (
<Form
formId={ORDER_FORM_ID}
useRedux
>
<NumberField
attribute='count'
label='Количество'
type='number'
/>
<NumberField
attribute='price'
label='Цена за единицу'
/>
</Form>
);
}
Обратите внимание, что мы указали attribute
для каждого поля формы. Значения таких полей записываются в хранилище, и их можно получить из любого места приложения через getFormValues
:
export default function OrderSummary() {
const {
count = 0,
price = 0
} = useSelector(state => getFormValues(state, ORDER_FORM_ID));
const total = count * price;
return (
<div>
Итог: {total} ₽
</div>
);
}
Динамическая конфигурация полей через массив
Список полей можно передать не только JSX-разметкой, но и динамически, при помощи массива объектов. Данный способ применяется когда внешний вид, поведение или даже состав полей может меняться в зависимости от тех или иных условий. Например, поле «Адрес» в форме для создания профиля сотрудника может выглядеть по-разному:
если сотрудник работает в офисе — это будет поле со списком доступных офисов,
если сотрудник работает удалённо — обычное текстовое поле для ввода адреса.
В таком кейсе удобнее передать конфиг формы, который отрендерится с помощью Field
:
const EMPLOYEE_FORM_ID = 'EmployeeFormId';
export default function EmployeeForm() {
const {isRemote} = useSelector(state => getFormValues(state, EMPLOYEE_FORM_ID));
const fields = [
{
attribute: 'fullName',
label: 'ФИО',
required: true,
component: InputField,
},
{
attribute: 'email',
label: 'E-mail',
required: true,
component: EmailField,
},
{
attribute: 'isRemote',
label: 'Планируете работать удаленно?',
required: true,
component: RadioField,
},
{
attribute: 'address',
label: 'Адрес сотрудника',
component: isRemote ? TextField : DropDownField,
items: !isRemote && [
{
id: 1,
label: 'Москва, Ленинградский проспект 10',
},
{
id: 2,
label: 'Санкт-Петербург, Невский проспект 25',
},
],
},
];
return (
<Form
formId={EMPLOYEE_FORM_ID}
fields={fields}
onSubmit={(values) => console.log('Отправлено:', values)}
/>
);
}
Интеграция с бэкендом
Form
умеет сам отправлять данные на сервер. Достаточно передать action
(URL) и метод запроса. Причем action
может быть как строкой, так и функцией:
<Form
formId={FORM_ID}
fields={formFields}
action='/api/v1/auth/login'
actionMethod='POST'
useRedux
onComplete={(values, data) => {
if (data.accessToken) {
dispatch(login(data.accessToken, ROUTE_USERS));
}
}}
submitLabel='Войти'
/>
В этом примере после получения данных от сервера Form
вызовет функцию onComplete
со значениями формы и данными ответа.
Жизненный цикл и обработчики событий
Кроме onComplete
, есть еще несколько полезных колбэков жизненного цикла формы.
onBeforeSubmit
— срабатывает при отправке формы, позволяет, к примеру, добавить кастомную проверку и предотвратить запрос:
const onBeforeSubmit = (data) => {
if (!data.email) {
dispatch(formSetErrors(FORM_ID, {
email: [__('Введите корректный email.')],
}));
return false;
}
return data;
};
onAfterSubmit
— срабатывает после завершения запроса, даже если сервер вернул ошибку:
const onAfterSubmit = (values, response) => {
if (response.statusCode === 400) {
dispatch(showNotification(response.errors.message));
}
};
onError
— срабатывает при сетевых или серверных ошибках:
const onError = (requestError) => {
if (requestError instanceof GlobalError) {
throwAsyncError(
new GlobalError(requestError),
);
}
};
Валидация и ошибки
По умолчанию ошибки приходят от сервера в объекте errors
, и Form
сам показывает их под соответствующими полями.
<Form>
<EmailField
attribute='email'
label='Email'
required
/>
<PasswordField
attribute='password'
label='Пароль'
required
security
/>
<Button
type='submit'
label='Войти'
/>
</Form>
При получении следующего объекта errors
, сообщение с ошибкой сразу появится под полем пароля:
{
"statusCode": 400,
"errors": {
"password": "Неверный пароль"
}
}
Если же нужно вывести общее сообщение об ошибке в виде тоста, можно использовать проп submitErrorMessage
:
<Form
submitErrorMessage='Упс, что-то пошло не так'
/* ... */
/>
Синхронизация с адресной строкой
При разработке каталога товаров или любого списка данных с множеством фильтров, удобно хранить значения формы прямо в URL, чтобы восстановить их после перезагрузки. Кроме того, это позволяет поделиться ссылкой на текущий вид каталога со всеми фильтрами. Для этого в Steroids Form есть флаг addressBar
:
<Form
formId={FORM_ID}
fields={formFields}
useRedux
addressBar
submitLabel='Войти'
/>
Таким образом, Steroids Form закрывает все наши стандартные сценарии работы с формами: от локального состояния и валидации до интеграции с Redux и API. Более подробное описание пропсов формы и примеры использования можно найти в документации.
Сравнение с другими библиотеками
Мы не стали замыкаться только на своём решении, поэтому сравнили Steroids Form с другими библиотеками для работы с формами в React. Взяли несколько критериев, которые, на наш взгляд, наиболее важны:
Производительность — насколько быстро библиотека работает на больших формах, как она оптимизирует повторный рендер полей при изменении значений, можно ли избежать избыточных обновлений.
Активность разработки — поддерживается ли проект, как часто выходят обновления, насколько активно сообщество.
Управление состоянием — какие подходы используются для хранения данных формы (локальный state, reducer, Redux и т.п.).
Валидация — какие механизмы встроены (синхронная, асинхронная, кастомные правила), насколько просто их подключать.
Для сравнения с Steroids Form мы выбрали несколько популярных библиотек для работы с формами:
Formik;
React Hook Form;
React Final Form.
Вот что у нас получилось:

React Hook Form и Formik по умолчанию не зависят от Redux — состояние форм в них хранится локально внутри компонентов. В отличие от них, Steroids Form изначально предоставляет встроенные механизмы синхронизации с Redux, сохраняя при этом возможность работать через React Reducer или локальный стейт, что делает библиотеку более гибкой в выборе хранилища данных.
React Hook Form минимизирует количество лишних перерисовок благодаря использованию неконтролируемых компонентов. В Formik при изменении данных зачастую перерисовывается вся форма, если специально не применять оптимизации. В Steroids, благодаря subscription-based архитектуре, количество перерисовок остаётся на среднем уровне.
Вывод
Steroids выгодно выделяется благодаря глубокой интеграции с React и Redux, что делает его особенно удобным для проектов, где критична работа с глобальным состоянием. При этом библиотека сохраняет хороший уровень производительности и гибкость за счёт поддержки разных способов хранения состояния. В экосистеме фреймворка Steroids это — оптимальный выбор.