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