Повторное использование форм на React

    Привет!

    У нас в БКС есть админка и множество форм, но в 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`, конвертировать и делать запросы. Присутствует повторение кода, от которого хочется избавиться. Мы пробуем найти новое решение, при котором форма в зависимости от пропсов будет брать нужный ключ из стора с полями, экшены, схемы и конфиг. В одном из следующих постов мы расскажем, что из этого получилось.
    • +17
    • 3,9k
    • 2
    ФГ БКС
    45,99
    Компания
    Поделиться публикацией

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

      0

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

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

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

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