При создании react-приложений часто появляется необходимость расширить функционал уже существующего компонента или переиспользовать общий кусок логики между компонентами, желательно минимально не вмешиваясь в реализацию целевого компонента. У большинства разработчиков в таком случае мысль в первую очередь обращается к использованию HOC (hight order component или по-русски компонент высшего порядка) или же кастомных хуков. Однако у меня нет никакого желания пересказывать вам уже всем давно известные паттерны, которые вы, вероятно, знаете даже лучше меня (если все же вы не знакомы с ними по какой-то причине, то информации на этот счет огромное кол-во, вы легко найдете замечательные материалы).

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

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

Суть проблемы

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

Как должен выглядеть дизайн интерфейса нашего компонента формы:

  1. Форма должна уметь свободно взаимодействовать с любыми полями формы, например если мы захотим использовать компонент Select из какой-нибудь библиотеки, то его интеграция не должна вызывать хоть малейших трудностей;

  2. Форма должна cоотвествовать семантике и реализовывать привычное поведение тега form из HTML5

  3. Состояние компонента должно храниться и управляться снаружи из соответствующего кастомного хука (это уже мое желание, т.к. я считаю такой способ работы с состоянием в данном случае более удобным чем альтернативы)

Исходная структура проекта:

├── components/
│   ├── Form/ (для начала обратим все внимание сюда)
│   │   ├── Form.hooks.ts
│   │   └── Form.tsx
│   ├── Input/
│   │   └── Input.tsx
│   ├── Select
│   │   └── Select.tsx
│   └── ...other components (сотня-другая компонентов, в том числе вариаций полей формы)
├── App.tsx
└── index.ts

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

/** src/components/Form/Form.tsx */

type FormProps = {
  children?: React.ReactNode;
  /** ...и все остальные пропсы, которые нужны для функционирования компонента */
}

/** Сам компонент формы, который представляет из себя на данный момент просто обертку, в которую отрисуются нужные поля */
export const Form: React.FC<FormProps> = ({ children, ...restProps }) => {
  /**
   * ...тут например какая-то логика, которая нужна в работе компоненту
   */
  return <form {...restProps}>{children}</form>;
};
/** src/components/Form/Form.hooks.tsx */

type IUseFormStateProps = {
  /** ...нужные props для хука */
}

/** Кастомный хук для работы с состоянием компонента формы */
export const useFormState = ({ initialState = {} }: IUseFormStateProps) => {
  const [state, setState] = useState(initialState)

  const updateValues = useCallback(({name, value}: { name: string; value: any }) => {
    setState(((prevState) => ({...prevState, ...{[name]: value}})))
  }, []);

  return {
    state,
    handlers: {
      updateValues,
    },
  };
};

И конечно как будем использовать данный компонент:

/** src/App.tsx */

function App() {
  const { state, handlers } = useFormState({});

  return (
    <div className="App">
      <Form>
        <Input
          name="username"
          value={state.username}
          onChange={(value) =>
            handlers.updateValues({ name: "username", value })
          }
        />
        <Input
          name="password"
          value={state.password}
          onChange={(value) =>
            handlers.updateValues({ name: "password", value })
          }
        />
        <Select
          name={"gender"}
          list={[...]}
          value={state.gender}
          onChange={(value) => handlers.updateValues({ name: "gender", value })}
        />
      </Form>
    </div>
  );
}

export default App;

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

Такой подход это же огромное пространство для ошибок и неоднозначного кода! Проблем можно предсказать огромное кол-во, например написание сложной логики в обработчиках onChange, сложная вложенность в state и соответствующие длинные пути при попытке достать нужное value и многое другое.

Наш долг, как ответственного разработчика, помочь избежать этих проблем.

Какие могут быть возможные пути решения? Можно вынести всю общую логику по связи состояния формы и ее полей (инпуты, селекты, чекбоксы) в одну обертку, например используя HOC, который будет получать нужные данные из контекста формы. Выглядеть это может например вот так:

/** src/components/Form/Form.tsx */

/**
 *	Добавим в наш компонент формы React.Context,
 *	чтобы можно было легко получить доступ до данных
 *  ниже по дереву компонентов
 */
type IFormContext = {
  /** ...обработчики, состояние и все-все, что должны получить дочерние компоненты */
};

export const FormContext = React.createContext<IFormContext>({
  state: {},
  handlers: {
    updateValues: () => console.error("not initialized"),
  },
});

type FormProps = {
  formEntity: IFormContext;
  children?: React.ReactNode;
  /** ... */
};

export const Form: React.FC<FormProps> = ({
  children,
  formEntity,
  ...restProps
}) => {
  /**
   * ...
   */
  return (
    <FormContext.Provider value={formEntity}>
      <form {...restProps}>{children}</form>
    </FormContext.Provider>
  );
};
/** src/components/Form/withFormState.tsx */

/** Как могла бы выглядеть реализация самого HOC'a */
export const withFormState = <
  T extends WithFormStateProps = WithFormStateProps
>(
  WrappedComponent: React.ComponentType<T>,
  name: string
) => {
  const { state, handlers } = useContext(FormContext);
  const value = state[name]?.value || null;

  const hocComponent = ({ ...props }) => (
    <WrappedComponent {...props} name={name} value={value} />
  );

  return hocComponent;
};

И как-то так это могло бы использоваться:

/** src/App.tsx */

function App() {
  const formEntity = useFormState({});

  const UsernameField = withFormState(Input, "name");
  const PasswordField = withFormState(Input, "password");
  const GenderField = withFormState(Select, "gender");

  return (
    <div className="App">
      <Form formEntity={formEntity}>
        <UsernameField />
        <PasswordField />
        <GenderField />
      </Form>
    </div>
  );
}

export default App;

М-да, изящества в таком подходе маловато. Тогда может заранее подготовим поля формы к использованию? Например сохраним результат вызова HOC’a в новый компонент и будем работать уже с ним.

├── components/
│   ├── Form/
│   │   ├── Form.hooks.ts
│   │   └── Form.tsx
│   ├── Input/
│   │   ├── Input.tsx
│   │   └── ! EnhancedInput.tsx (сохраним сюда подготовленный для работы с формой инпут)
│   ├── Select/
│   │   ├── Select.tsx
│   │   └── ! EnhancedSelect.tsx (и сюда...)
│   └── ...other components (и сделаем так для каждого компонента формы в проекте?)
├── App.tsx
└── index.ts
/** src/components/Input/EnhancedInput.tsx */

type EnhancedInputProps = {
  name: string;
} & InputProps;

/** Как например эти Enchanced компоненты могли бы выглядеть */
export const EnhancedInput: React.FC<EnhancedInputProps> = ({ name, ...restProps }) => {
  const ResultComponent = withFormState(Input, name)
    
  return <ResultComponent {...restProps} />;
};

Если такой подход и лучше, то незначительно. Неужели мы хотим делать обертки для каждого компонента, который будет частью формы? Инпуты, селекты, чекбоксы уже и так есть в кодовой базе, мы не должны плодить ненужные усложнения в проекте!

Мысли же о пользе кастомных хуков в данной ситуации заведут нас примерно в такой же тупик. Так же что делать?

Решение о котором почему-то редко упоминают

Под react созданы десятки библиотек компонентов разной степени успешности, а удобная работа с формой одна из популярнейших проблем в мире веб-разработки. Так как же решили данную задачку крупные проекты, например ant design? Давайте посмотрим.

Самый первый пример (с небольшими изменениями) использования компонента Form из библиотеки antd:

import { Button, Checkbox, Form, Input } from 'antd';
import React from 'react';

const AntdExample: React.FC = () => {
  const onFinish = (values: any) => {
    console.log('Success:', values);
  };

  const onFinishFailed = (errorInfo: any) => {
    console.log('Failed:', errorInfo);
  };

  return (
    <Form
      onFinish={onFinish}
      onFinishFailed={onFinishFailed}
    >
      <Form.Item
        label="Username"
        name="username"
      >
        <Input />
      </Form.Item>

      <Form.Item
        label="Password"
        name="password"
      >
        <Input.Password />
      </Form.Item>

      <Form.Item name="remember">
        <Checkbox>Remember me</Checkbox>
      </Form.Item>

      <Form.Item>
        <Button type="primary" htmlType="submit">
          Submit
        </Button>
      </Form.Item>
    </Form>
  );
};

export default AntdExample;

Обратите внимание, как изящно скрывается от глаз логика привязки value и onChange (и много чего еще на самом деле!) к полям формы через композицию компонентов Form.Item и любого из инпутов! Никакой ручной подстановки значений, просто свяжи нужные части через children и все готово! Но где же привычное прокидывание props? Если где-то магически подставляются новые props, то как они не мешают старым проброшенным props руками из разметки? Как это работает вообще?

Давайте я покажу упрощенную реализацию и обсудим самые главное моменты.

В основе всего лежит в первую очередь конечно же контекст. Обращаясь к контексту, Form.Item в базовом виде делает всего 2 вещи:

  1. Вытаскивает из контекста нужное значение поля по идентификатору (в нашем случае это name)

  2. Инжектирует нужные данные, например value, onChange и т.д., в целевой компонент (ага, тот самый, который является ребенком компонента Form.Item), через его копирование с нужными параметрами (Да, вы правильно поняли. Вы же не забыли о React.CloneElement?)

type FormItemProps {
    name: string;
    className?: string;
		/** Если мы хотим обращаться к props у children, то значение должно быть именно ReactElement  */
    children?: ReactElement;
}

/** Наш упрощенный аналог Form.Item из antd */
export const FormItem = ({ children, className, name }: ItemProps) =>  {
		/** Достаем все что нужно из контекста нашей формы */
    const { state, handlers } = useContext(FormContext);

    const value = state?.[name]?.value || null;
    const handleChange = (value) => handlers.updateValues({ name: name, value: value });

		/** Готовим props, которые хотим подмешать к нашему целевому компоненту */
    const injectionProps = {
        value,
        onChange: handleChange,
    };

		/** Делаем слияние новых и старых props, ведь мы хотим оставить возможность пробросить props в разметке снаружи */
    const childProps = { ...(children.props || {}), ...injectionProps };

		/** Клонируем элемент, с уже обновленными props */
		const clonedElement = React.cloneElement(children, childProps)

  /**
   *	Возвращаем привычную разметку, добавив обертку,
   *	к которой можно привязать общие стили полей формы,
   *  внутри нее вывести сообщение о текущем статусе валидации поля
   *	и т.д.
   */
    return <div className={className}>{clonedElement}</div>;
}

И теперь получаем возможность организовать работу с нашим компонентом формы так, чтобы коллеги не захотели выгнать нас из команды:

/** src/App.tsx */

/** Пример использования формы, но уже с новым паттерном */
function App() {
  const formEntity = useFormState({});

  return (
    <div className="App">
      <Form formEntity={formEntity}>
        <FormItem name="username">
          <Input />
        </FormItem>
        <FormItem name="password">
          <Input />
        </FormItem>
        <FormItem name="gender">
          <Select list={[...]} />
        </FormItem>
      </Form>
    </div>
  );
}

export default App;

Итоги

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

Конечно впереди еще много нюансов, которые нам потребуется закрыть перед тем как наш компонент будет полностью готов к использованию (например потребуется еще поколдовать над типизацией, обработкой исключений при взаимодействии с некорректным children, добавить необходимый функционал и т.п.), но сам подход выглядит очень привлекательно, на мой взгляд. Я надеюсь, что моя статья была вам полезна и я смог расширить ваш арсенал для построения react-приложений еще на один прием.

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


  1. strannik_k
    12.07.2022 07:03

    У вас типичный и сомнительный вариант с использованием обертки и клонирования элемента ради вынесения общих props-ов. Не понимаю, почему всех тянет так делать. Задача ведь состоит в том, чтобы избавиться от дублирования пропсов. А значит надо для начала посмотреть, что можно сделать с пропсами, а не с элементом.

    Вот более удобный вариант, который к сожалению мало кто видит:

    <Input {...commonInputProps(name)} />

    В функции commonInputProps уже нужная логика с сохранением/мемоизацией куда нужно по имени поля и возврат объекта с данными именно для этого поля.


    1. Yakov_Botov Автор
      12.07.2022 09:39
      +1

      Отличное замечание! Однако отмечу, что задача изначально стояла не только вокруг управления пропсами, но и вокруг интеграции общей обертки (например как в вашем случае лучше решить кейс с выводом сообщения о текущем статусе валидации для поля формы?). Я показал в статье альтернативу (не замену устойчивым паттернам), которая может быть полезна на стыке возможностей хуков и хоков, чтобы объединить вместе работу с пропсами и одновременное создание обертки элемента для различных нужд


      1. strannik_k
        12.07.2022 10:39

        Не обратил внимание при просмотре статьи на задачу вокруг интеграции общей обертки.

        например как в вашем случае лучше решить кейс с выводом сообщения о текущем статусе валидации для поля формы

        Для этого уже действительно лучше завести отдельный компонент, как у вас FormItem. Но это уже другая задача и нет необходимости объединять разный функционал в одном компоненте. FormItem так может разрастись и усложниться со временем.


    1. Alexandroppolus
      12.07.2022 21:31
      +3

      Вот более удобный вариант, который к сожалению мало кто видит:

      <Input {...commonInputProps(name)} />

      Да не сказать чтобы мало.. Этот способ представлен и в Формике, и в react-hook-form, причем одинаково - useForm/useFormContext возвращает, помимо прочего, функцию register, которая именно так и применяется - <Input {...register(name)} />. При этом register уже завязана на все необходимые контексты.

      Описанный автором подход с оберткой FormItem, кроме подсветки ошибок и иного визуального, позволяет оптимизировать перерендеры. Например, если в контекст FormContext сложить не иммутабельный стейт, а мобиксовый стор, то при печатании букв не будет перерендера всей формы, а только лишь отдельного FormItem (при условии, что в самой форме не упоминается значение поля).


      1. strannik_k
        13.07.2022 07:05
        +1

        Видимо я ошибался. Теперь начинаю видеть преимущества подхода, особенно в плане оптимизации. А недостаток в виде раздувания FormItem обходится с помощью вынесения логики из него в хуки, сторы. Спасибо, что открыли мне глаза!)

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

        С формиком и react-hook-form не работал. И на глаза практически не попадался подход с вынесением общих пропсов в функции. А вот клонирование часто встречалось.


  1. mytecor
    14.07.2022 10:18

    Почему нельзя просто передать пропом компонент для отрисовки?

    <FormItem 
      name="password"
      component={Input}
    />