Недооцененная альтернатива для HOC’ов и кастомных хуков в react и при чем здесь React.CloneElement?

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

При создании 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-приложений еще на один прием.

Источник: https://habr.com/ru/post/676384/


Интересные статьи

Интересные статьи

Год назад команда реакта представила серверные компоненты (не путать с SSR). Если вкратце, то суть в том, что компонент создается на сервере, сериализуется в хитрый json, отправляется клиенту по http,...
Привет, Хаброжители! Хотите создавать эффективные приложения с помощью React? Тогда эта книга написана для вас. Познакомьтесь c лучшими практиками и шаблонами создания современного кода. Вам не по...
Система машинного обучения, изначально созданная для ускорения вычислений, сегодня делает удивительные успехи на границах экспериментальной квантовой физики. Квантовый фи...
Сегодня поговорим об измерении производительности рендеринга React-компонентов с использованием API React Profiler. Ещё мы будем оценивать взаимодействия с компонентом, применяя новый эксперимент...
В материале, первую часть перевода которого мы публикуем сегодня, речь пойдёт о том, как JavaScript-движок V8 выбирает оптимальные способы представления различных JS-значений в памяти, и о том, к...