Всем привет! Сегодня я возвращаюсь с новой порцией TypeScript- и React-магии. Вместе разберем полиморфизм в React, а именно — паттерн as. Зачем он нужен, как его прикрутить без багов и почему это сделает ваши компоненты в разы круче. Как обычно — всё под катом.

В каких случаях это нужно

Представьте, что вы сделали красивую кнопку Button. Всё летает, дизайнеры плачут от счастья. И тут приходит продукт-менеджер: «А сделай так, чтобы вот эта кнопка стала ссылкой на главную».

«Не вопрос!» — говорите вы и лепите костыль:

function Button({ href, children, ...props }) {
    const Tag = href ? 'a' : 'button';
    return <Tag href={href} {...props}>{children}</Tag>;
}

Работает? Работает. Пока не попросят сделать ее Link из react-router. Или SnackButton. Или что-нибудь еще в таком же духе.

Добавлять условия на каждый случай — путь в ад. Тут-то нас и выручает паттерн as.

Паттерн as: дистрибуция на уровне компонентов

Если в типах мы раскладываем юнионы через extends, то в React мы можем заставить один компонент рендериться как другой. По сути, это та же дистрибуция, но на уровне JSX.

Наивная попытка (сразу в продакшн!):

function Polymorph({ as: Tag }) {
    return <Tag />;
}

// Использование
<Polymorph as={Link} /> // Рендерит <Link />
<Polymorph as={SnackButton} /> // Рендерит <SnackButton />

Но TypeScript начнет ругаться, как только мы попробуем добавить пропсы для нашего полиморфа: «А какие пропсы у Link? А у SnackButton?» . И будет прав. Наша наивная реализация не принимает пропсы самого компонента Tag. Да и с обычными HTML-тегами не работает.

Боевая версия с TypeScript

Ключ к успеху —ElementType и правильная обработка пропсов:

import { createElement, ElementType, ComponentPropsWithoutRef } from 'react';

type PropsOf<T extends ElementType> = ComponentPropsWithoutRef<T>;

function Polymorph<TComponent extends ElementType>({
    as: Tag,
    ...props
}: {
    as: TComponent;
} & PropsOf<TComponent>) {
    return <Tag {...props} />;
}

Что здесь происходит?

  1. TComponent extends ElementType — наш дженерик, ограниченный типом «любой React-компонент или тег».

  2. PropsOf<TComponent> — вытаскиваем пропсы этого компонента/тега.

  3. <Tag {...props} /> — создаем компонент Tag и прокидываем в него props.

Теперь наш компонент по-настоящему универсален...

// Рендерит <Link href="#" />
<Polymorph as={Link} href="#" />

// Рендерит <SnackButton type="primary" />
<Polymorph as={SnackButton} type="primary" />

// Рендерит <a autoFocus />
<Polymorph as="a" autoFocus />

... и совершенно бесполезен! Если задуматься, он не делает совершенно ничего, а только отрисовывает переданный компонент и добавляет в него его пропсы. НоPolymorph для этого совершенно не нужен. Давайте посмотрим на реальный пример использования такого компонента «в бою».

as для дизайн-систем: сохраняем дизайн, меняем семантику

Вот где паттерн as раскрывается на полную. Представьте, что у вас в дизайн-системе есть Typography с кучей вариантов:

// Было в дизайн-системе

// Всегда рендерит h1
<Typography variant="h1">Заголовок</Typography> 

// Всегда рендерит p
<Typography variant="body">Текст</Typography>

А потом приходит SEO-специалист и говорит: «Ребята, у вас на странице три <h1>, это плохо». Раньше пришлось бы городить костыли или плодить компоненты. А теперь всё просто:

// Стало с полиморфизмом

// Такой же h1, как и был
<Typography as="h1" variant="h1">Главный заголовок</Typography>

// Выглядит как h1, но семантика h2!
<Typography as="h2" variant="h1">Второстепенный заголовок</Typography> 

// Бонусы для доступности - иногда параграф не нужен
<Typography as="span" variant="body">Текст в span</Typography>

Что мы получаем (спойлер: все довольны!):

  • Дизайнеры довольны: все размеры, отступы и цвета остаются как в макете.

  • Разработчики довольны: семантика на месте, SEO-специалист не ругается.

  • TypeScript доволен: не дает передавать href в Typography или colspan в button.

Тот же подход будет работать для любых компонентов дизайн-системы:

// Кнопка, которая выглядит как кнопка, но ведет себя как ссылка
<Button as={Link} to="/about" variant="primary">
  О нас
</Button>

// Карточка, которая является ссылкой
<Card as={Link} href="/product/1" className="product-card">
  {content}
</Card>

Конфликт пропсов

Иногда случается, что пропсы нашего полиморфного компонента и целевого тега вступают в конфликт. Представьте, что у вашей универсальной кнопки есть пропс color, и вы хотите отрендерить ее как Link из react-router, у которого тоже есть пропс color — но с совершенно другим типом и назначением.

TypeScript моментально загорится красным: «А какой именно color вы имеете в виду?». И будет прав. Наша боевая версия с React.ComponentPropsWithoutRef не знает, как поступать в такой неоднозначной ситуации.

enum Color {...} 

/* Полиморфная кнопка со своим пропсом `color` */
type PolymorphButtonProps = {
  color: Color;
}

/* При попытке рендерить как `Link` */
<PolymorphButton as={Link} to="/home" color="primary">
  На главную
</PolymorphButton>
/* Тип "primary" не может быть назначен типу Color. */

Как с этим жить?

  1. Переименовывайте. Просто не называйте пропсы одинаково. Вместо color используйте variant или themeColor для пропсов дизайн-системы:

    interface PolymorphButtonProps {
      variant: 'primary' | 'secondary'; // Конфликт устранен!
    }
  2. Ограничивайте пропсы. В дженериках можно явно исключить конфликтующие пропсы из типа полиморфного компонента, используя утилиту Omit:

    type PolymorphProps<T extends ElementType> = {
      as?: T;
      // Все остальные пропсы, КРОМЕ тех, что уже есть у нас
    } & Omit<React.ComponentPropsWithoutRef<T>, keyof PolymorphButtonProps>;
  3. Внедряйте скоупы. Пропсы полиморфного компонента можно занести в одну переменную, тогда пересечение станет невозможно:

    type PolymorphProps<T extends ElementType> = {
      as?: T;
      polymorhOnlyProps: PolymorphOnlyProps;
    } & React.ComponentPropsWithoutRef<T>;
    
    /* Теперь наш пример корректо сработает */
    <PolymorphButton  polymorhOnlyProps={{color: 'primary'}} as={Link} to="/home">
      На главную
    </PolymorphButton>

Выводы про паттерн as

Это не серебряная пуля, но нередко выручает. Резюмирую:

  • + Универсальность: работает с любым компонентом или тегом.

  • + Полная типизация «родных» пропсов: TypeScript не даст вам запутаться.

  • + Идеален для дизайн-систем: помогает отделить внешний вид от семантики.

  • - Плохо композируется: при вложении компонентов возникает конфликт пропсов.

  • - Слабо читаемый: запись as={Link} выглядит неочевидно.

Особенно эффективно as выручает в дизайн-системах, где нужно сохранять единый визуал, но гибко управлять семантикой. Начните применять его в базовых компонентах (типа BoxButtonText), и наверняка ваша кодовая база станет гибче и типобезопаснее.

Ограничения паттерна as можно обойти, используя другой, менее известный паттерн asChild, читайте о нем в следующей статье ?

Кстати, все примеры из статьи из нашего реального продакшна. Есть чем дополнить? Жду в комментариях!

Комментарии (2)


  1. DmitryOlkhovoi
    07.11.2025 14:34

    С CVA сравнивали?


  1. oookkdjjjdjdj
    07.11.2025 14:34

    Удобный паттерн, если делаешь дизайн-систему. Но вне неё выглядит избыточно