Грани полиморфизма в React: паттерн asChild
- суббота, 15 ноября 2025 г. в 00:00:03
Привет, мы продолжаем разбирать полиморфизм в React. В прошлой серии мы разобрали паттерн as — мощный, типобезопасный, но с проблемами в композиции. Сегодня разберем, как решить эту проблему с помощью паттерна asChild. Спойлер: это сделает ваш код чище, композируемее и приятнее для глаз, но придется пожертвовать поддерживаемостью.
Если в паттерне as мы передавали компонент как пропс, то в asChild мы используем привычный children + немного магии:
<ClickEffected clickEffect={sendMetric} asChild>
<SnackButton onClick={() => console.log("my click1")}>
As child
</SnackButton>
</ClickEffected>
// Отрендерится только SnackButton, но с видоизмененным onClickСуть в том, что когда asChild={true}, то наш компонент не создает новый тег, а «клонирует» переданный ему child, мерджа пропсы. Это как HOC, но через JSX.
function ClickEffected({
asChild,
clickEffect,
...props
}: PropsWithChildren<{
clickEffect?: MouseEventHandler;
onClick?: MouseEventHandler;
asChild?: boolean;
}>) {
const handleClick: MouseEventHandler = (e) => {
props.onClick?.(e);
clickEffect?.(e);
};
const Tag = asChild ? Slot : "button";
return createElement(Tag, { ...props, onClick: handleClick });
}Что происходит в этом компоненте:
В пропсы мы кладем полезную нагрузку (в этом случае — clickEffect) и пропс asChild: boolean.
Указываем, что у этого компонента будут children.
В теле функции создаем видоизмененные пропсы для children (переопределяем onClick).
Создаем новый элемент, в который в качестве тега прокидываем некий загадочный Slot.
На самом деле, не такой уж он и загадочный:
function Slot({
children,
...props
}: React.HTMLAttributes<HTMLElement> & {
children?: React.ReactNode;
}) {
if (React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
...children.props,
});
}
return null;
}Самое интересное здесь происходит на строках 7-12, где мы берем children и клонируем их, видоизменяя пропсы. Это позволяет нам «растворять» родительский компонент, отрисовывая вместо себя children.
Для боевого использования такой реализации Slot катастрофически не хватает — в качестве children в React можно передать кучу всякого кода, поэтому одной лишь проверки на то, что children валидный, нам не хватит, если мы хотим использовать Slot серьезно.
К счастью, есть выход, как добавить его быстро. Варианты: скопировать код или установить в свой проект @radix-ui/react-slot.
Сравните два подхода:
Через as (проблемный):
<ClickEffected
as={(props) => <HrefParameters
as={SnackButton}
href='/:id'
params={{ id: '42' }}
{...props}
/>}
clickEffect={sendMetric}
type='primary'
/>;Через asChild (чисто):
<HrefParameters href="/:id" params={{ id: "42" }} asChild>
<ClickEffected clickEffect={sendMetric} asChild>
<SnackButton type="primary" shape="round">
Супер-кнопка
</SnackButton>
</ClickEffected>
</HrefParameters>Видите разницу? Вместо «вертикального» нагромождения пропсов получаем естественную композицию через вложенность.
Однако тут закралась опасность — теперь пропсы передаются в компонент неявно. И мы можем не заметить, что супер-кнопка из примера выше уже не кнопка, а ссылка, так как HrefParameters передал по цепочке вложенности href неявно.
Кроме того, TypeScript никак не валидирует наш ввод. Мы может стрелять себе в ногу сколько угодно раз, передавая в компоненты пропсы, которые они не могут принимать (например, вместо кнопки можем положить туда span, который никак не работает с href).
ClickEffected — реальный код из нашего прода. С помощью него мы отправляем метрики, когда дочерние компоненты самостоятельно управляют своими onClick. Просто посмотрите, как это удобно композировать и использовать:
<ClickEffected
clickEffect={createSendMetricHandler(ACTIONS.createEntity)}
asChild
>
// Тут логика клика полностью на стороне компонента
// Который в распределенных системах может лежать в библиотеке
// И у нас просто нет к нему доступа
<CreateEntityButton />
</ClickEffected>Плюсы:
отлично композируется — вложенность вместо конфликта пропсов;
естественный JSX — читается как обычная разметка;
универсальность — работает с любыми компонентами.
Минусы:
неявная передача параметров — не всегда понятно, какие пропсы куда провалятся;
сложнее дебажить — нужно понимать магию Slot и cloneElement.
меньше типобезопасности — TypeScript не всегда может проверить совместимость пропсов.
Используйте as, если:
нужна максимальная типобезопасность;
компонент используется изолированно;
важна явность передаваемых пропсов.
Переходите на asChild, когда:
компоненты активно композируются;
хочется более читаемого JSX;
готовы к небольшой магии под капотом.
Оба этих подхода страдают от одной и той же болезни, вызванной природой полиморфизма — мы пытаемся выполнять какую-то логику на уровне отрисовки компонентов, портим себе слой вью и ухудшаем читаемость. Но выход есть! В следующих статьях мы поговорим про паттерн FACC и полиморфные декораторы, которые решают и эту проблему.
А какой подход предпочитаете вы? Сталкивались ли с проблемами композиции в своих проектах? Делитесь в комментах — обсудим!
P.S. Все примеры, как и в прошлый раз, из нашего боевого опыта. Если нужно больше конкретики по реализации — welcome в комментарии!