Грани полиморфизма React: полиморфные декораторы
- среда, 3 декабря 2025 г. в 00:00:02
Привет, снова Костя из Cloud.ru. Мы поговорили уже про as для типобезопасного полиморфизма, asChild для композиции и FACC для вариативного дизайна. Но что, если я скажу, что есть способ комбинировать логику еще элегантнее и не смешивать ее с отрисовкой? Сегодня разбираем полиморфные декораторы — HOC на стероидах.

Вспомним наши прошлые примеры. Хотим кнопку, которая:
генерирует href по шаблону,
отправляет метрику по клику,
и все это полиморфно!
as подход (уже видели этот мрак):
<WithAnalitics
as={(props) => <HrefParameters
as={SnackButton}
href='/:id'
params={{ id: '42' }}
{...props}
/>}
elementId="main-button"
analyticsActionLabel="main click"
type="primary"
/>asChild подход (все равно громоздко, еще и небезопасно):
<HrefParameters href="/:id" params={{ id: "42" }} asChild>
<WithAnalitics elementId="main-button" analyticsActionLabel="main click" asChild>
<SnackButton type="primary">
Кнопка
</SnackButton>
</WithAnalitics>
</HrefParameters>А что, если можно так?
const EnhancedButton = /* ...Тут происходит магия декорирования */
<EnhancedButton
href="/:id"
params={{ id: "42" }}
elementId="main-button"
analyticsActionLabel="main click"
type="primary"
/>Для его создания нам стоит посмотреть на код компонента, использующего паттерн as:
function ClickEffected<
TComponent extends ElementType<{ onClick?: MouseEventHandler }>,
>({
as,
clickEffect,
...props
}: ExtendedProps<
PropsOf<TComponent>,
{
clickEffect?: MouseEventHandler;
onClick?: MouseEventHandler;
as: TComponent;
}
>) {
const handleClick: MouseEventHandler = (e) => {
props.onClick?.(e);
clickEffect?.(e);
};
return createElement(as, { ...props, onClick: handleClick });
}Что происходит в этом коде?
Мы типизируем внешний компонент и его пропсы, которые позже придут к нам через дженерик TComponent.
Создаем видоизмененные пропсы для вложенного компонента.
Вызываем createElement с новыми видоизмененными пропсами.
Чтобы сделать из любого as-поли��орфного компонента HOC, нам нужно всего лишь оторвать от него пропс as и вынести его в единственный параметр функции, которая этим хоком и станет. Все остальные пропсы и вся типизация остается на месте, сам же компонент будет брать данные из замыкания:
export function withAnalitics<T extends Pick<ButtonOutlineProps, 'onClick' | 'label'>>(
Component: ComponentType<T>,
) {
return function WithClickAnalyticsComponent({ ...props }: T & { elementId: string; analyticsActionLabel?: string }) {
const { analytics } = useAnalytics();
return (
<Component
{...props}
onClick={e => {
analytics.onClick({
elementId: props.elementId,
actionLabel: props.analyticsActionLabel || props.label || props.elementId,
});
props.onClick?.(e);
}}
/>
);
};
}Почти ничего не изменилось, но пользы от этого изменения не пересчитать, смотрите сами, как сейчас мы стали использовать компонент:
/* Было */
<WithAnalitics elementId="main-button" analyticsActionLabel="main click" as={SnackButton}>
/* Стало */
const AnaliticsButton = withAnalytics(SnackButton)
<AnaliticsButton elementId="main-button" analyticsActionLabel="main click" />Если вам кажется, что кода стало немного больше, вам не кажется, это правда так =) Зато мы получили отделение логики эффектов от отрисовки и огромные возможности для композиции без потери строгой типизации!
/* Создаем усиленную ссылку */
const EnhancedLink = withHrefParameters(withAnalitics(Link));
/* Создаем усиленную кнопку */
const EnhancedButton = withHrefParameters(withAnalitics(SnackButton));
/* Используем — чисто и типобезопасно! */
<EnhancedLink
href="/:id"
params={{ id: "42" }}
analyticsActionLabel="main-click"
/>
/* И для кнопок тоже! */
<EnhancedButton
href="/:id"
params={{ id: "42" }}
analyticsActionLabel="main-click"
type="primary"
/>Тут мы подходим к еще одному преимуществу такого подхода. HOC withHrefParameters — не просто добавляет href, но и централизует и стандартизирует работу с маршрутами и параметрами.
Такой декоратор может:
автоматизировать подстановку параметров в шаблон URL, например, /product/:id → /product/42;
централизованно следить за правилами форматирования и локализацией ссылок;
обеспечивать консистентность формирования ссылок во всех компонентах приложения;
ограничивать набор обязательных к передаче параметров, например, без id компонент не скомпилируется;
позволять использовать дополнительные уровни логики, например, интеграцию с i18n, аналитикой переходов и др.
Иными словами, подобный декоратор заставляет разработчика явно указать все необходимые параметры, не разбрасывая их по коду, а полностью контролируя этот процесс из одного места. Это действительно критично для больших команд и библиотек, где единообразие сэкономит часы отладки и снизит количество ошибок.
Одно из очевидных преимуществ такого подхода в том, что мы можем декорировать наш компонент в любой момент. Теперь мы можем как создать отдельный компонент и использовать его по всему приложению, так и использовать декоратор непосредственно перед применением в файле с использованием. При этом как декоратор, так и сам компонент могут быть библиотечными, то есть в проекте нет необходимости погружаться в их суть.
Такой подход может быть очень удобным, когда вы создаете библиотеку и хо��ите обеспечить широкий выбор модификаций для вашего (или не вашего!) набора компонентов.
В предыдущих статьях мы уже посмотрели на ад пропсов и на возможности расширения при помощи паттерна FACC. Давайте попробуем создать ту же карточку при помощи декораторов:
function BaseCard({ children, className, style }: CardProps) {
return (
<BlockBasic className={className} style={style}>
{children}
</BlockBasic>
);
}Для начала создадим карточку, которая ничего внутри себя не имеет, определяет только базовые отступы и скругления из дизайн-системы. И теперь наполним ее пропсами, но не будем включать их сразу, а дадим пользователю возможность управлять ими:
export type WithLoadingProps<T> = {
loading?: boolean;
} & Omit<T, 'loading'>;
export function withLoading<T extends PropsWithChildren>(Component: ComponentType<T>) {
return function ({ loading, children, ...rest }: WithLoadingProps<T>): ReactElement<WithLoadingProps<T>> {
return <Component {...(rest as T)}>{loaderProps?.loading ? <Loader {...loaderProps} /> : children}</Component>;
};
}
Здесь мы видим уже знакомую конструкцию — принимаем компонент, дополняем его новыми свойствами, изменяем поведение и возвращаем уже обновленный компонент. В результате наша карточка теперь может иметь внутри себя лоадер, который включается при передаче специального свойства. Только ли карточка?
Нет! Поскольку этот HOC независим, мы можем использовать его для разработки любого компонента, у которого стили лоадера подходят для вставки его в компонент, так как логика лоадера всегда одинакова.
Продолжая разрабатывать нашу карточку с разными частями, мы приходим к примерно такому результату:
export { Card, BaseCard } from './Card';
export { withHeader, withFooter, withLoading, pipe } from './hocs';Кстати, вы могли спросить, что же делать, если не хочется заморачиваться и каждый раз дособирать эту карточку руками. Как раз для того, чтобы оставить обратную совместимость и дать возможность тем, кто пользуется возможностями по дефолту и не нуждается в дополнительной кастомизации, использовать компонент Card:
export const Card = pipe(withHeader, withFooter, withLoading)(BaseCard);Этот подход менее гибок, чем паттерн FACC, так так для кастомного расширения карточки вам придется писать собственный декоратор, который заменит один из тех, что уже есть, и собирать карточку заново. Однако с ним вы сможете кастомизировать внешние компоненты, разработка которых находится не в ваших репозиториях.
Прекрасный подход, который позволяет не только отделить логику от отрисовки, но и кастомизировать компоненты, не находящиеся под нашим контролем, имеет важный недостаток относительно менее гибких as и asChild паттернов. Его очень сложно типизировать с опорой на свойства.
Когда мы пишем один декоратор, все работает относительно хорошо:
function withClickEffect<
TComponent extends ElementType<{ onClick?: MouseEventHandler }>,
>(Component: TComponent) {
return ({
clickEffect,
...props
}: ExtendedProps<
PropsOf<TComponent>,
{
clickEffect?: MouseEventHandler;
onClick?: MouseEventHandler;
}
>) => //...И такие декораторы композируются без ограничений столько, сколько мы захотим. Но это перестает работать, когда в рамках декоратора нам нужно использовать вычисляемые типы:
export function withHrefParams<
Base extends FunctionComponent<{ href?: string; onClick?: MouseEventHandler<HTMLAnchorElement> }>,
>(Component: Base) {
// Ахтунг! дженерик <Href extends string> в возвращаемом кмпоненте
return function WithHrefParams<Href extends string>(props: ExtractProps<Base> & LinkProps<Href>) {
Проблема в том, что если мы будем помещать декораторы с вычисляемыми типами один поверх другого, высока вероятность перетереть этот дженерик и потерять преимущество в типизации, именно поэтому в цепочке декораторов мы не можем размещать больше одного декоратора с вычисляемыми типами.
Поэтому на декораторах пока нельзя написать все, и это к лучшему =)
Полиморфные декораторы — мощный инструмент для доработок внешних компонентов. Они дают:
чистую композицию без JSX,
полную типобезопасность на каждом шаге,
переиспользуемость логики между разными компонентами.
В нашей практике декораторы отлично работают для:
UI-независимой логики: аналитика, логирование,
композиции бизнес-логики,
библиотечных компонентов.
А вы используете HOC в своих проектах? Сталкивались с проблемами типизации? Делитесь опытом в комментах!
P.S. Статьи с альтернативными подходами вы можете почитать тут: