При создании react-приложений часто появляется необходимость расширить функционал уже существующего компонента или переиспользовать общий кусок логики между компонентами, желательно минимально не вмешиваясь в реализацию целевого компонента. У большинства разработчиков в таком случае мысль в первую очередь обращается к использованию HOC (hight order component или по-русски компонент высшего порядка) или же кастомных хуков. Однако у меня нет никакого желания пересказывать вам уже всем давно известные паттерны, которые вы, вероятно, знаете даже лучше меня (если все же вы не знакомы с ними по какой-то причине, то информации на этот счет огромное кол-во, вы легко найдете замечательные материалы).
Сегодня я бы хотел рассказать об альтернативе для вышеупомянутых паттернов, которую незаслуженно обходят стороной во многих обзорах полезных практик при построении react-приложений. Решение довольно специфичное, но в некоторых кейсах может помочь вам очень элегантно организовать код.
Однако чтобы вы смогли по достоинству оценить подход, я предлагаю начать не с его презентации, а с погружения в проблему, решение которой как раз и раскроет перед вами весь потенциал паттерна.
Суть проблемы
Представим такую задачу, а именно нам требуется создать компонент формы для ui-kit с нуля. Так же предлагаю поразмышлять над этой задачкой больше с точки зрения удобства использования нашего компонента.
Как должен выглядеть дизайн интерфейса нашего компонента формы:
Форма должна уметь свободно взаимодействовать с любыми полями формы, например если мы захотим использовать компонент Select из какой-нибудь библиотеки, то его интеграция не должна вызывать хоть малейших трудностей;
Форма должна cоотвествовать семантике и реализовывать привычное поведение тега form из HTML5
Состояние компонента должно храниться и управляться снаружи из соответствующего кастомного хука (это уже мое желание, т.к. я считаю такой способ работы с состоянием в данном случае более удобным чем альтернативы)
Исходная структура проекта:
├── 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 вещи:
Вытаскивает из контекста нужное значение поля по идентификатору (в нашем случае это name)
Инжектирует нужные данные, например 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 clonedElement = React.cloneElement(children, injectionProps) /** * Возвращаем привычную разметку, добавив обертку, * к которой можно привязать общие стили полей формы, * внутри нее вывести сообщение о текущем статусе валидации поля * и т.д. */ 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 type="..." /> </FormItem> <FormItem name="password"> <Input type="..." /> </FormItem> <FormItem name="gender"> <Select list={[...]} /> </FormItem> </Form> </div> ); } export default App;
Итоги
Мы проделали совсем немного работы, но улучшили опыт взаимодействия с нашим компонентом кардинальным образом.
Конечно впереди еще много нюансов, которые нам потребуется закрыть перед тем как наш компонент будет полностью готов к использованию (например потребуется еще поколдовать над типизацией, обработкой исключений при взаимодействии с некорректным children, добавить необходимый функционал и т.п.), но сам подход выглядит очень привлекательно, на мой взгляд. Я надеюсь, что моя статья была вам полезна и я смог расширить ваш арсенал для построения react-приложений еще на один прием.
