Недооцененная альтернатива для 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *