Работа с формами в React.js, используя базовый инструментарий

Введение


За время работы на React.js мне часто приходилось сталкиваться с обработкой форм. Через мои руки прошли Redux-Form, React-Redux-Form, но ни одна из библиотек не удовлетворила меня в полной мере. Мне не нравилось, что состояние формы хранится в reducer, а каждое событие проходит через action creator. Также, согласно мнению Дана Абрамова, «состояние формы по своей сути является эфемерным и локальным, поэтому отслеживать его в Redux (или любой библиотеке Flux) не нужно».


Замечу, что в React-Redux-Form есть компонент LocalForm, который позволяет работать без redux, но на мой взгляд, пропадает смысл устанавливать библиотеку размером 21,9kB и использовать её менее чем на половину.


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


Я начал использовать локальный state компонента, при этом возникли новые трудности: увеличилось количество кода, компоненты потеряли читаемость, появилось много дублирования.


Решением проблем стала концепция High Order Component. Если коротко, HOC — это функция, которая получает на вход компонент и возвращает его обновлённым с интеграцией дополнительных или изменённых props-ов. Подробнее о HOC можно почитать на официальном сайте React.js. Цель использования концепции HOC была в разделении компонента на две части, одна из которых отвечала бы за логику, а вторая — за отображение.


Создание формы


В качестве примера создадим простую форму обратной связи, в которой будет 3 поля: имя, email, телефон.


Для простоты используем Create-React-App. Установим его глобально:


npm i -g create-react-app

затем создадим свое приложение в папке pure-form


create-react-app pure-form

Дополнительно установим prop-types и classnames, они нам пригодятся в дальнейшем:


npm i prop-types classnames -S

Создадим две папки /components и /containers. В папке /components будут лежать все компоненты, отвечающие за отображение. В папке /containers, компоненты, отвечающие за логику.


В папке /components создадим файл Input.jsx, в котором объявим общий компонент для всех инпутов. Важно на этом этапе не забыть качественно прописать ProptTypes и defaultProps, предусмотреть возможность добавления кастомных классов, а также наследовать его от PureComponent для оптимизации.
В результате получится:


import React, { PureComponent } from 'react';
import cx from 'classnames';
import PropTypes from 'prop-types';

class Input extends PureComponent {
  render() {
    const {
      name,
      error,
      labelClass,
      inputClass,
      placeholder,
      ...props
    } = this.props;
    return (
      <label
        className={cx('label', !!labelClass && labelClass)}
        htmlFor={`id-${name}`}
      >
        <span className="span">{placeholder}</span>
        <input
          className={cx(
            'input',
            !!inputClass && inputClass,
            !!error && 'error'
          )}
          name={name}
          id={`id-${name}`}
          onFocus={this.handleFocus}
          onBlur={this.handleBlur}
          {...props}
        />
        {!!error && <span className="errorText">{error}</span>}
      </label>
    );
  }
}

Input.defaultProps = {
  type: 'text',
  error: '',
  required: false,
  autoComplete: 'off',
  labelClass: '',
  inputClass: '',
};

Input.propTypes = {
  value: PropTypes.string.isRequired,
  name: PropTypes.string.isRequired,
  onChange: PropTypes.func.isRequired,
  placeholder: PropTypes.string.isRequired,
  error: PropTypes.string,
  type: PropTypes.string,
  required: PropTypes.bool,
  autoComplete: PropTypes.string,
  labelClass: PropTypes.string,
  inputClass: PropTypes.string,
};

export default Input;


Далее в папке /components создадим файл Form.jsx, в котором будет объявлен компонент, содержащий форму. Все методы для работы с ней будем получать через props, так же как и value для инпутов, поэтому state здесь не нужен. Получаем:


import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Input from './Input';
import FormWrapper from '../containers/FormWrapper';

class Form extends Component {
  render() {
    const {
      data: { username, email, phone },
      errors,
      handleInput,
      handleSubmit,
    } = this.props;
    return (
      <div className="openBill">
        <form className="openBillForm" onSubmit={handleSubmit}>
          <Input
            key="username"
            value={username}
            name="username"
            onChange={handleInput}
            placeholder="Логин"
            error={errors.username}
            required
          />
          <Input
            key="phone"
            value={phone}
            name="phone"
            onChange={handleInput}
            placeholder="Телефон"
            error={errors.phone}
            required
          />
          <Input
            key="email"
            value={email}
            type="email"
            name="email"
            onChange={handleInput}
            placeholder="Электронная почта"
            error={errors.email}
            required
          />
          <button type="submit" className="submitBtn">
            Отправить форму
          </button>
        </form>
      </div>
    );
  }
}

Form.propTypes = {
  data: PropTypes.shape({
    username: PropTypes.string.isRequired,
    phone: PropTypes.string.isRequired,
    email: PropTypes.string.isRequired,
  }).isRequired,
  errors: PropTypes.shape({
    username: PropTypes.string.isRequired,
    phone: PropTypes.string.isRequired,
    email: PropTypes.string.isRequired,
  }).isRequired,
  handleInput: PropTypes.func.isRequired,
  handleSubmit: PropTypes.func.isRequired,
};

export default FormWrapper(Form);


Создание HOC


В папке /containers создадим файл FormWrapper.jsx. Объявим внутри функцию, которая в качестве аргумента получает компонент WrappedComponent и возвращает класс WrappedForm. Метод render этого класса возвращает WrappedComponent с интегрированными в него props. Старайтесь использовать классическое объявление функции, это упростит процесс отладки.


В классе WrappedForm создадим state: isFetching – флаг для контроля асинхронных запросов, data — объект с value инпутов, errors – объект для хранения ошибок. Объявленный state передадим во WrappedComponent. Таким образом реализуется вынос хранилища состояний формы на верхний уровень, что делает код более читаемым и прозрачным.


export default function Wrapper(WrappedComponent) {
  return class FormWrapper extends Component {
    state = {
      isFetching: false,
      data: {
        username: '',
        phone: '',
        email: '',
      },
      errors: {
        username: '',
        phone: '',
        email: '',
      },
    };

    render() {
      return <WrappedComponent {...this.state} />;
    }
  };
}

Но такая реализация не универсальная, потому что для каждой формы придётся создать свою обёртку. Можно усовершенствовать эту систему и вложить HOC внутрь ещё одной функции, которая будет формировать начальные значения state.


import React, { Component } from 'react';

export default function getDefaultValues(initialState, requiredFields) {
  return function Wrapper(WrappedComponent) {
    return class WrappedForm extends Component {
      state = {
        isFetching: false,
        data: initialState,
        errors: requiredFields,
      };

      render() {
        return <WrappedComponent {...this.state} {...this.props} />;
      }
    };
  };
}

В эту функцию можно передавать не только начальные значения state, но вообще любые параметры. Например, атрибуты и методы, на основе которых можно будет создать форму в Form.jsx. Пример такой реализации будет темой для следующей статьи.


В файле Form.jsx объявим начальные значения state и передадим их в HOC:


const initialState = {
    username: '',
    phone: '',
    email: '',
};

export default FormWrapper(initialState, initialState)(Form);

Создадим метод handleInput для обработки введённых в инпут значений. Он получает event, из которого берём value и name и передаём их в setState. Поскольку значения инпутов хранятся в объекте data, в setState вызываем функцию. Одновременно с сохранением полученного значения обнуляем хранилище ошибки изменяемого поля. Получим:


handleInput = event => {
  const { value, name } = event.currentTarget;
  this.setState(({ data, errors }) => ({
    data: {
      ...data,
      [name]: value,
    },
    errors: {
      ...errors,
      [name]: '',
    },
  }));
};

Теперь создадим метод handeSubmit для обработки формы и выведем данные в консоль, но перед этим необходимо пройти валидацию. Валидировать будем только обязательные поля, то есть все ключи объекта this.state.errors. Получим:


handleSubmit = e => {
    e.preventDefault();
    const { data } = this.state;
    const isValid = Object.keys(data).reduce(
        (sum, item) => sum && this.validate(item, data[item]),
        true
    );
    if (isValid) {
      console.log(data);
    }
};

С помощью метода reduce переберём все обязательные поля. При каждой итерации происходит вызов метода validate, в который передаём пару name, value. Внутри метода происходит проверка на корректность введённых данных, по результатам которой возвращается булев тип. Если хотя бы одна пара значений не пройдёт валидацию, то переменная isValid станет false, и данные в консоль не выведутся, то есть форма не будет обработана. Здесь рассмотрен простой случай — проверка на непустую форму. Метод validate:



validate = (name, value) => {
    if (!value.trim()) {
      this.setState(
        ({ errors }) => ({
          errors: {
            ...errors,
            [name]: 'поле не должно быть пустым',
          },
        }),
        () => false
      );
    } else {
      return true;
    }
};

Оба метода handleSubmit и handleInput необходимо передать во WrappedComponent:


render() {
    return (
        <WrappedComponent
            {...this.state}
            {...this.props}
            handleInput={this.handleInput}
            handleSubmit={this.handleSubmit}
        />
    );
}

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


Заключение


Итак, мы рассмотрели базовый пример создания HOC для обработки формы. При создании формы использовались только простые инпуты, без сложных элементов, таких как выпадающие списки, чекбоксы, радиобаттоны и прочие. При их наличии, возможно, придётся создавать дополнительные методы обработки событий.


Вопросы и замечания пишите в комментариях к статье или мне на почту.

Готовый пример можно найти здесь: pure react form.

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

Поделиться публикацией

Комментарии 12

    +2
    Вместо Redux-Form, React-Redux-Form используйте react-final-form или formik. Они не используют Redux и имеют довольно удобное и гибкое API, валидация и проч. плюшки в комплекте.
      0
      Спасибо за комментарий, о react-final-form не слышал ранее, обязательно посмотрю, а Formik знаю, даже успел пощупать его, удобная библиотека, у меня в закладках. Но основная цель статьи была показать, как можно использовать концепцию HOC на примере формы.
      0
      Тема глубже, попробуйте реализовать добавление маски и форматирования в пропсы филда формы !?)
        0
        спасибо за комментарий, я эту тему уже развиваю, но здесь всё не поместилось.
        На одном из проектов мне приходило от сервера описание формы в виде массива с объектами, в которых были тип каждого инпута, атрибуты и прочее. Надо было слать предварительный запрос для создания формы, а потом из полученных данных её собирать. Я постараюсь написать об этом следующую статью.
        +1
        Давать идентификатор в автоматическом ком режиме каждому опасно. Вы уже помечтили инпут внутрь тэга матки и этого достаточно чтобы их связать
          0
          спасибо за комментарий, согласен с вами, но я читал статью, в которой говорилось, что в вашем кастомном компоненте надо стараться предусмотреть возможность передать все html атрибуты, которые могут понадобиться, поэтому id я посчитал важным и передал. В целом, его можно не заполнять
          0
          const isValid = Object.keys(data).reduce(
                  (sum, item) => sum && this.validate(item, data[item]),
                  true
              );

          Array.prototype.every? :)

            0
            спасибо за комментарий, я про every него забыл, вполне подойдёт
            0
            Мне кажется метод validate работает не так как ожидается. Указание второго параметра в setState это callback который будет вызыван асинхронно в тот момент когда стейт изменится. В этот callback можно написать какyю-то дополнительную логику но возвращаемое значение будет проигнарировано. Поэтому в строке () => false нет смысла. Но почему же тогда все работает?! Похоже что в случае, когда строка пустая метод возвращает undefined, который в логическом выражении неявно приводится к false. Мне кажется следут явно написать return false, это сделает код более понятным.
              0
              спасибо за комментарий, согласен, там стоит добавить return для более явного поведения
              0

              Спасибо за статью, поясните пожалуйста, зачем нужна ещё одна обёртка, почему не передавать initialState, requiredFields во Wrapper?

                0
                Всегда пожалуйста, вам тоже спасибо за комментарий и прошу прощения, что долго не отвечал.
                Это сделано для больше для удобства, в целом, можно передать начальные значения и во Wrapper, а потом перед возвращением нового класса обработать эти значения и передать в конструктор. Кроме удобства это делается для разделения ответственности, как правило HOC получает только компонент и возвращает компонент, а вся логика обработки дополнительных значений вынесена на уровень выше, например, если посмотреть тот же connect из react-redux, он реализован примерно также. Кроме этого, на официальном сайте говорится, что такой паттерн предпочтительней
                https://reactjs.org/docs/higher-order-components.html

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое