Многие вставали перед выбором той или иной библиотеки для работы с формами в ReactJS. Когда я выбирал подходящую мне, разные библиотеки казались идеальными НО: форма на конфигах или колбеки в onSubmit эвенте, или асинхронный submit. Почему формы для реакта не соответствуют принципам реакта, почему они выглядят как что-то особенное? Если эти вопросы приходили вам в голову, или вы любите формы, приглашаю к прочтению статьи.

Давайте представим формы какими они должны быть.


Форма в реакте должна:

  • предоставлять управляемость полей и событий
  • максимально соответствовать html проекции
  • соблюдать декларативность и композицию
  • использовать типичные методы работы с React компонентами
  • иметь предсказуемое поведение

Форма в реакте не должна:

  • задавать модель управления
  • иметь избыточное состояние или требовать дополнительные данные
  • требовать настройку или обязательное использование функций-хелперов

Теперь попробуем описать идеальную форму опираясь на эти правила:

<Form action="/" method="post">
    <Validation>
        <Field type="text" name="firstName">
        <Field type="text" name="lastName">
        <Transform>
            <Field type="number" name="minSalary">
            <Field type="number" name="maxSalary">
        <Transform>
        <Field type="email" name="email">
    </Validation>
    <button type="submit">Send<button>
</Form>

Выглядит практически как обычная html форма, за исключением Field вместо input и неизвестных Validation и Transform. Вы, наверное, уже догадались что тег Validation должен проверять значение полей и создавать сообщения ошибки для них. Тег Transform в свою очередь необходим для вычисления полей minSalary и maxSalary.

Я что-то говорил про React?


Перенесёмся в реалии реакта и опишем ту же форму:

class MySexyForm extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            model: {}
        };

        this.validator = (model, meta) => {
            let errors = { ...meta.submitErrors };
            if(model.firstName && model.firstName.length > 2) {
                errors = { firstName: ["First name length must be at minimum 2"] };
            }
            if(model.lastName && model.lastName.length > 2) {
                errors = { 
                    ...errors,
                    lastName: ["Last name length must be at minimum 2"] 
                };
            }
            return errors;
        };
        this.transformer = (field, value, model) => {
            switch (field) {
                case "minSalary":
                    if (parseInt(value) > parseInt(model.maxSalary)) {
                        return {
                            maxSalary: value
                        };
                    }
                case "maxSalary":
                    if (parseInt(value) < parseInt(model.minSalary)) {
                        return {
                            minSalary: value
                        };
                    }
            }
            return {};
        };
    }
    onSubmit = (event) => (model) => {
        event.preventDefault();
        console.log("Form submitting:", model);

        this.props.sendSexyForm(model); // абстрактный action после выполнения которого в форму приходят ошибки сабмита в виде submitErrors пропа
    }
    onModelChange = (model) => {
        console.log("Model was updated: ", model);
        this.setState({ model });
    }
    render() {
        return (
            <Form 
                action="/" 
                method="post" 
                onSubmit={this.onSubmit}
                onModelChange={this.onModelChange}
                values={this.state.model}
                initValues={this.props.initValues}
            >
            <Validation validator={this.validator} submitErrors={this.props.submitErrors}>
                <Field type="text" name="firstName">
                <Field type="text" name="lastName">
                <Transform transformer={this.transformer}>
                    <Field type="number" name="minSalary">
                    <Field type="number" name="maxSalary">
                <Transform>
                <Field type="email" name="email">
            </Validation>
            <button type="submit">Send<button>
        </Form>
        );
    }
};

Я не стану подробно рассматривать Field компонент, представим что он рендерит input с переданными в Field пропами и дополнительными value и onChange. А так же сообщения об ошибках для данного поля.
Стоит объяснить появление новых полей initValues, values, onModelChange, onSubmit, validator, transformer.

Начнём с пропов добавленных в Form.

Эвент хендлер onSubmit позволяет перехватить эвент сабмита формы, получить доступ к этому эвенту и к текущим значениям полей формы через аргумент model.

Эвент хендлер onModelChange позволяет отследить изменения в полях формы.
С помощью values мы можем управлять значениями полей, а initValues позволяет задать начальные значения.

Этот базовый функционал предоставляет большинство библиотек для работы с формами в реакте, ничего необычного, всё так как должно быть.

Рассмотрим тег Validation, у него появились два пропа

  1. validator — функция возвращающая ошибки валидации на основе переданных значений полей формы
  2. submitErrors — дополнительное rest поле передающееся вторым аргументом в функцию валидатор, в нём мы передаём полученные с сервера ошибки после сабмита. По сути в rest передаются все аргументы переданные в Validation за исключением validator

К сожалению, я не встречал подобной или похожей реализации валидации, хотя она кажется очевидной: у нас есть функция валидации которая получает данные и возвращает на их основе ошибки, никакой side effect логики, всё так, как должно быть в реакте.

Перейдём к компоненту Transform. Он перехватывает изменения у вложенных полей и вызывает функцию — transformer, принимающую три аргумента:

  • field — имя поля в котором произошло изменение
  • value — новое значение этого поля
  • model — текущее значение полей формы с предыдущим значением изменившегося поля

Она должна вернуть объект вида { [field]: value } который будет использован для обновления других полей формы.

Тоже очевидная реализация вычисляемых полей.

И… что мы имеем в итоге?


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

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

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

react-painlessform


Я написал собственную библиотеку, которая помогает делать формы просто и понятно. С кодом можно ознакомиться на Github.

А так же посмотреть живой пример из статьи.

Спасибо за внимание


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

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


  1. irsick
    27.04.2018 21:40

    Элементы формы хранят собственные состояния, и это — самая большая проблема однонаправленности потока данных в React. Именно поэтому, мне кажется, формы в Angular лучше.

    Лично мне очень понравился Formik за его относительную простоту и элегантность по сравнению с redux-form и final-form


    1. Wroud Автор
      27.04.2018 23:49

      В своей библиотеке изначально я хотел сделать состояние у Field, но в ходе разработки решил все же отказаться и оставить состояние только у поля Form, в итоге это решение лишило меня множества проблем и упростило data flow. По сути инпуты подписаны на колбеки Form.


  1. yuzi
    27.04.2018 23:51

    formik (https://github.com/jaredpalmer/formik) довольно-таки удобное решение для форм.


  1. zeonz
    28.04.2018 10:16

    Я с вами полностью согласен — формы и их валидация должны быть декларативными. Статья, конечно, про React но я бы хотел вас ознакомить с вариантом для Vue — vuelidate


    1. Wroud Автор
      28.04.2018 12:40

      В статье я подробно не описывал возможные схемы валидации, но в своей библиотеке, как пример, я реализовал поддержку Yup, по сути можно использовать любую библиотеку валидации данных которая принимает на вход значение полей формы, а на выходе формирует объект с ошибками


  1. jeron-diovis
    28.04.2018 12:21

    Нет, нет и нет. Валидация форм не должна быть привязана к html вообще никак.

    Почему вообще каждое решение пытается привязать валидаторы к компонентам? Написать десяток компонент, которые каким-то там замороченным образом трансформируют пропсы для потомков, кладут чего-нибудь в локальный стейт, и пытаются реализовать мало-мальски адекватное поведение и реагирование на события, опираясь на лайфхуки Реакта? А потом обрастают вот этими «onModelChange», «onSubmitErrors» (который внезапно не то же, что onSubmit, и вешается не на форму, а куда-то там ещё), и прочим не пойми чем, которого становится тем больше, чем дольше автор пользуется этим решением, понимая, что оно не решает какую-то очередную проблему. И при этом почему-то заявляется, что всё это дело не «задаёт модель управления» и не «требует дополнительные данные».

    Форма – это состояние. Это просто кусок данных, который нужно прогнать через функцию-валидатор. Точка. Ни данные, ни валидаторы не имеют к Реакту и компонентам вообще никакого отношения. Валидация отдельно, рендеринг ошибок – отдельно. И будет вам и декларативность, и композиция, и реюзабельность, и даже юнит-тестирование можно делать.

    Для валидации требуются:

    • набор предикатов (типа isEmail)
    • тексты ошибок (задаваемые вами, а не чем-то сторонним – потому что i18n)
    • механизм соединения предиката и текста ошибки в функцию-валидатор
    • механизм соединения валидаторов в функции с более сложной логикой типа «validateScheme»

    И вот где и зачем здесь Реакт и компоненты?

    Вот пример того, как надо правильно и декларативно делать валидацию: medium.com/javascript-inside/form-validation-as-a-higher-order-component-pt-1-83ac8fd6c1f0
    Правда, автор немного чересчур угорел по функциональщине) Можно было попроще. Но саму суть подхода показывает предельно верно.


    1. Wroud Автор
      28.04.2018 12:32

      Статья как раз про разделение контроля над формой, валидацией и трансформацией данных. Если вы внимательно посмотрите на функцию `validator`, на аргументы которые она принимает и значение которое возвращает, то заметите, что эта функция совершенно не зависит от формы и реакта. В статье на которую вы ссылайтесь как раз описываются принципы по которым должна работать эта функция. Компонент `Validation` лишь предоставляет доступ к данным формы и формирует контекст для отображения ошибок.

      По поводу `onSubmitErrors ` я с вами согласен, и опять же пишу про это в статье, что компоненты Field и Form должны максимально отражать проекцию на dom и соответственно иметь схожие пропы и эвенты как у элементов form и input.


    1. mbeloshitsky
      29.04.2018 12:20

      Почему вообще каждое решение пытается привязать валидаторы к компонентам? Написать десяток компонент, которые каким-то там замороченным образом трансформируют пропсы для потомков, кладут чего-нибудь в локальный стейт, и пытаются реализовать мало-мальски адекватное поведение и реагирование на события, опираясь на лайфхуки Реакта?

      Потому что такие компоненты в React и подобных ему VirtualDOM-библиотеках (не путайте кстати с HTML) — это общепринятый способ композиции, соответственно большинство людей им и пользуются. Другой вопрос — рационально ли это? Я бы тоже наверное реализовал вариацию где-то ближе к стору и кидался бы уже оттуда сообщениями об ошибках и флажками valid/inValid.

      Приведенный вами пример неполный, в нем полученные в ходе валидации ошибки не прокидываются обратно — к визуальными компонентам, не предусмотрена асинхронная валидация и т.п. (это все несложно сделать, но говорим о том, что уже есть) Так что далеко не факт, что описанные вами ужасы не появятся как раз на этом этапе.


      1. Wroud Автор
        29.04.2018 12:23

        Если вы открывали пример из статьи на codesandbox, то вероятно пропустили асинхронную валидацию с сервера. Компонент Validation используется как контекст, связывающий значение полей формы и её валидацию

        Мой пример позволяет вынести валидацию куда угодно, все что нужно для связи валидации и модели, это покинуть дополнительную информацию для валидации (асинхронную) как дополнительные пропы и распарсить эти данные в функции валидатор


        1. mbeloshitsky
          29.04.2018 12:26

          Я имел ввиду не ваш пример, а тот, что на медиуме.


  1. vlad_shabanov
    28.04.2018 14:28
    +1

    Жизненный цикл большинства библиотек для форм выглядит так:


    1. восторг. Я сделал штуку, которая красиво и наглядно позволяет запрограммировать вот эти 5 форм.
    2. полёт нормальный. Я сделал на ней ещё 5 форм, пришлось, конечно, кое-где подпилить, что-то дописать, но пока я могу запрограммировать этим способом всё, что мне нужно.
    3. Упс, я наткнулся на сценарий, который в это API не укладывается. Выкидывать код жалко, переписывать 10 уже написанных форм некогда. Сделаю дополнительный "костыль".
    4. Упс^2. Через 5 форм "костыль" стал по объёму кода больше, чем первоначальное решение. Но переписывать 15 форм по новому нельзя, на это времени никто не выделит.
    5. Отчаяние, переписывание всего кода и т. д.

    Из этого есть только один вывод: сделайте на Вашей библиотеке максимально сложную форму и убедитесь, что это --невозможно-- можно сделать.


    Примеры:


    1. Поле выбора (select / radio button), где выбирается что-то непростое, требующее парсинга. Например, дата. Сколько раз эта дата превращается из Date в текстовую строку и обратно? Где живёт нормализация этой даты? Если в форме выбирают какой-нибудь объект из списка, в скольких местах у нас делается преобразование идентификатора объекта в ссылку на сам объект?
    2. А если этот список объектов динамически меняется?
    3. Несколько полей, граничные значения динамически вычисляются исходя из значений других полей. Например, диапазон дат.
    4. Исчезающие/появляющиеся поля. Например, способ оплаты, который исчезает, если у клиента 100% скидка. Как валидировать поле формы, которое исчезает из виртуального DOM-дерева, а потом снова снова появляется, если поменяли другое поле?
    5. Группа полей, которую можно использовать повторно в нескольких формах. Попробуйте сделать группу "сумма" + НДС + значение НДС, в которой внутри проверяется, что НДС вычислен правильно. А теперь попробуйте сделать группу "купленный товар", внутри которой есть эта группа, и которую, в свою очередь, можно использовать в нескольких местах.
    6. Форма, в которой валидация части данных делается асинхронным запросом на сервер.
    7. Форма со вложенными формами.

    Попробуйте в порядке упражнения сделать на своей библиотеке форму ввода данных из кассового чека. Дата-время, номер смены и т. д. А потом сделайте внутри неё формы для добавления товаров/услуг, там цены и НДС к ним, общая сумма из списка товаров --может-- должна совпадать с общей суммой чека, кассира нужно выбрать из списка, сверяясь с их сменами через AJAX. Товары пусть будут уникальными, НДС зависит от типа товара, но должна быть возможность задавать его вручную.
    Скорее всего, от текущей реализации ничего не останется, придётся все данные выносить в redux или mobx, всю логику валидации нести туда же.


    1. Wroud Автор
      28.04.2018 14:39

      Спасибо за полезный совет, обязательно попробую.


    1. Wroud Автор
      29.04.2018 02:28

      1) 2) 3) пример

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


    1. Wroud Автор
      30.04.2018 01:05

      5) пример

      Для реализации этого кейса мне не потребовалось пересматривать предложенный в статье API. Единственное, пришлось пересмотреть работу Validation и Transform в моей библиотеке, Transform был переделан, но не кардинально. Теперь функция трансформации выглядит так:

      (values, model) => { [field]: { value } }

      1. values — неполная копия model отражающая изменившиеся поля (в том числе изменённые другим Transform)
      2. model — текущее значение полей формы с предыдущим значением изменившегося поля
      3. field — имя поля для трансформации
      4. value — новое значение этого поля


      Возвращает объект вида { [field]: { value } }, подобный аргументу values, этот объект будет объединён с values и передан в следующий Transform.

      Validation теперь добавляет свои ошибки к hight ordered Validation если таковой есть.