Steroids Form
Steroids Form

Это третья статья из цикла про наш фреймворк 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.

Steroids Form
Steroids Form

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 это — оптимальный выбор.

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