Привет!

У нас в БКС есть админка и множество форм, но в React-сообществе нет общепринятого метода — как их проектировать для переиспользования. В официальном гайде Facebook’a нет подробной информации о том, как работать с формами в реальных условиях, где нужна валидация и переиспользование. Кто-то использует redux-form, formik, final-form или вообще пишет свое решение.


В этой статье мы покажем один из вариантов работы с формами на React. Наш стек будет вот таким: React + formik + Typescript. Мы покажем:

  • Что компонент должен делать.
  • Конфиг, поля и валидация на уровне пропсов.
  • Как сделать форму переиспользуемой.
  • Оптимизацию перерендера.
  • Чем наш способ неудобен.

При новой бизнес-задаче мы узнали, что нам нужно будет сделать 15-20 похожих форм, и гипотетически их может стать еще больше. У нас была одна форма-динозавр на конфиге, которая работала с данными из `store`, отправляла actions на сохранение и выполнение запросов через `sagas`. Она была замечательной, выполняла бизнес-велью. Но уже была нерасширяемой и непереиспользуемой, только при плохом коде и добавлении костылей.

Задача поставлена: переписать форму для того, чтобы ее можно было переиспользовать неограниченное количество раз. Хорошо, вспоминаем функциональное программирование, в нем есть чистые функции, которые не используют внешние данные, в нашем случае `redux`, только то, что им присылают в аргументах (пропсах).

И вот что получилось.

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

interface IFormProps {
  // сообщает форме когда ей показывать лоадер и дизейблить кнопки
  IsSubmitting?: boolean;
  // текс для кнопки отправки
  submitText?: string;
  //текст для кнопки отмены
  resetText?: string;
  // стоит ли валидировать при изменении поля (пропс для формика)
  validateOnChange?: boolean; 
  // стоит ли валидировать при blur’e поля (пропс для формика)
  validateOnBlur?: boolean;
  // конфиг, на основе которого будут рендериться поля.
  config: IFieldsFormMetaModel[];
  // значения полей.
  fields: FormFields; 
  // схема для валидации
  validationSchema: Yup.MidexSchema;
  // колбек при сабмите формы
  onSubmit?: () => void;
  // колбек при клике на reset кнопку
  onReset?: (e: React.MouseEvent<HTMLElement>) => void;
  // изменение конкретного поля
  onChangeField?: (
    e: React.SyntaticEvent<HTMLInputElement, name: string; value: string
  ) => void; 
  // присылает все поля на изменение + валидны ли они
  onChangeFields?: (values: FormFields, prop: { isValid }) => void; 
}

Использование Formik


Мы используем компонент <Formik />.

render() {
  const {
    fields, validationSchema, validateOnBlur = true, validateOnChange = true,
  } = this.props;

  return (
    <Formik
      initialValues={fields}
      render={this.renderForm}
      onSubmit={this.handleSubmitForm}
      validationSchema={validationSchema}
      validateOnBlur={validateOnBlur}
      validateOnChange={validateOnChange}
      validate={this.validateFormLevel}
    />
  );
}

В prop'e формика `validate` мы вызываем метод `this.validateFormLevel`, в котором компоненту-контейнеру даем возможность получить все измененные поля и проверить, валидны ли они.

private validateFormLevel = (values: FormFields) => {
  const { onChangeFields, validationSchema } = this.props;

  if (onChangeFields) {
    validationSchema
      .validate(values)
      .then(() => {
        onChangeFields(values, { isValid: true });
       })
      .catch(() => {
         onChangeFields(values, { isValid: false });
       });
   }
}

Здесь приходится вызывать еще раз валидацию для того, чтобы дать понять контейнеру, валидны ли поля. При сабмите формы мы просто вызываем prop `onSubmit`:

private handleSubmitForm = (): void => {
  const { onSubmit } = this.props;

  if (onSubmit) {
    onSubmit();
  }
}

С пропсами 1-5 все должно быть понятно. Перейдем к ‘config’, ‘fields’ и ‘validationSchema’.

Пропс ‘config’


interface IFieldsFormMetaModel {
  /** Имя секции */
  sectionName?: string;
  sectionDescription?: string;
  fieldsForm?: Array<{
    /** Название поля формы */
    name?: string; // по значению этого поля будет будет находить ключ из prop ‘fields’
    /** Является ли поле checked */
    checked?: boolean;
    /** enum, возможные варианты для отображения поля */
    type?: ElementTypes;
    /** Текст для лейбла */
    label?: string;
    /** Текст под полем */
    helperText?: string;
    /** Признак обязательности заполнения элемента формы */
    required?: boolean;
    /** Признак доступности поля для изменения */
    disabled?: boolean;
    /** Минимальное кол-во элементов в поле */
    minLength?: number;
    /** Объект с начальным значением куда входит само значение и его описание */
    initialValue?: IInitialValue;
    /** Массив значений для выпадающих списков */
    selectItems?: ISelectItems[]; // значения для select, dropdown и подобных
  }>;
}

На основе этого интерфейса создаем массив объектов и рендерим по такой схеме “раздел” -> “поля раздела”. Так мы можем показывать несколько полей для раздела или в каждом по одному, если нужен заголовок и примечание. Как устроен рендер, покажем немного позже.
Короткий пример конфига:

export const config: IFieldsFormMetaModel[] = [
  {
    sectionName: 'Общая информация',
    fieldsForm: [{
      name: 'subject',
      label: 'Тема',
      type: ElementTypes.Text,
    }],
  },
  {
    sectionName: 'Напоминание',
    sectionDescription: 'Напоминание для сотрудника',
    fieldsForm: [{
      name: 'reminder',
      disabled: true,
      label: 'Сотруднику',
      type: ElementTypes.CheckBox,
      checked: true,
    }],
  },
];

На основе бизнес-данных задаются значения для ключей `name`. Эти же значения используются в ключах prop `fields` для передачи первоначальных или измененных значений для формика.

Для примера выше `fields` может выглядеть так:

const fields: SomeBusinessApiFields = {
  subject: 'Встреча с клиентом',
  reminder: 'yes',
}

Для валидации нам нужно передавать Yup Schema. Форме мы отдаем схему с пропсами контейнера, описывая там взаимодействия с внешними данными, например, запросами.

Форма никак не может повлиять на схему, пример:

export const CreateClientSchema: (
  props: CreateClientProps,
) => Yup.MixedSchema =
  (props: CreateClientProps) => Yup.object(
    {
      subject: Yup.string(),
      description: Yup.string(),
      date: dateSchema,
      address: addressSchema(props),
    },
  );

Рендер и оптимизация полей


Для рендера мы сделали мапу, для быстрого поиска по ключу. Выглядит лаконично и поиск быстрее, чем по `switch`.

fieldsMap: Record<
  ElementTypes,
  (
    state: FormikFieldState,
    handlers: FormikHandlersState,
    field: IFieldsFormInfo,
  ) => JSX.Element
  > = {
    [ElementTypes.Text]: (
      state: FormikFieldState,
      handlers: FormikHandlersState,
      field: IFieldsFormInfo
    ) => {
      const { values, errors, touched } = state;

      return (
        <FormTextField
          key={field.name}
          element={field}
          handleChange={this.handleChangeField(handlers.setFieldValue, field.name)}
          handleBlur={handlers.handleBlur}
          value={values[field.name]}
          error={touched[field.name] && errors[field.name] || ''}
        />
      );
    },
    [ElementTypes.TextSearch]: (...) => {...},
    [ElementTypes.TextArea]: (...) => {...},
    [ElementTypes.Date]: (...) => {...},
    [ElementTypes.CheckBox]: (...) => {...},
    [ElementTypes.RadioButton]: (...) => {...},
    [ElementTypes.Select]: (...) => {...},
  };

Каждый компонент-поле является stateful. Он находится в отдельном файле и обернут в `React.memo`. Все значения передаются через props, минуя `children`, чтобы избежать лишнего перерендера.

Заключение


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

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


  1. VolCh
    31.07.2019 21:09

    Почему выбрали Formik а не final-form?


    1. IFonin Автор
      01.08.2019 10:54
      +2

      1) Нам очень нравится валидировать через схемы, которых нету у final-form.
      2) На момент выбора библиотеки для формы у большинства разработчиков был опыт с formik, делать нужно было быстро поэтому выбрали привычное всем.
      3) Мы хотели попробовать final-form, но уже было достаточное количество форм на formik, а мы придерживаемся единого стиля в коде. Бизнес не дал бы времени на переделывание всего по новой.