Введение


За время работы на 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.

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

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


  1. BUY33
    30.08.2018 20:08
    +1

    Вместо Redux-Form, React-Redux-Form используйте react-final-form или formik. Они не используют Redux и имеют довольно удобное и гибкое API, валидация и проч. плюшки в комплекте.


    1. Pogman25 Автор
      30.08.2018 22:30

      Спасибо за комментарий, о react-final-form не слышал ранее, обязательно посмотрю, а Formik знаю, даже успел пощупать его, удобная библиотека, у меня в закладках. Но основная цель статьи была показать, как можно использовать концепцию HOC на примере формы.


  1. asksimon
    31.08.2018 17:31

    Тема глубже, попробуйте реализовать добавление маски и форматирования в пропсы филда формы !?)


    1. Pogman25 Автор
      31.08.2018 17:33

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


  1. apapacy
    31.08.2018 20:56

    Давать идентификатор в автоматическом ком режиме каждому опасно. Вы уже помечтили инпут внутрь тэга матки и этого достаточно чтобы их связать