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

asChild: Композиция через children

Если в паттерне 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.

Что за загадочный 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>

Итоги по паттерну asChild

Плюсы:

  • отлично композируется — вложенность вместо конфликта пропсов;

  • естественный JSX — читается как обычная разметка;

  • универсальность — работает с любыми компонентами.

Минусы:

  • неявная передача параметров — не всегда понятно, какие пропсы куда провалятся;

  • сложнее дебажить — нужно понимать магию Slot и cloneElement.

  • меньше типобезопасности — TypeScript не всегда может проверить совместимость пропсов.

as vs asChild: что выбрать?

Используйте as, если:

  • нужна максимальная типобезопасность;

  • компонент используется изолированно;

  • важна явность передаваемых пропсов.

Переходите на asChild, когда:

  • компоненты активно композируются;

  • хочется более читаемого JSX;

  • готовы к небольшой магии под капотом.

Оба этих подхода страдают от одной и той же болезни, вызванной природой полиморфизма — мы пытаемся выполнять какую-то логику на уровне отрисовки компонентов, портим себе слой вью и ухудшаем читаемость. Но выход есть! В следующих статьях мы поговорим про паттерн FACC и полиморфные декораторы, которые решают и эту проблему.

А какой подход предпоч��таете вы? Сталкивались ли с проблемами композиции в своих проектах? Делитесь в комментах — обсудим!

P.S. Все примеры, как и в прошлый раз, из нашего боевого опыта. Если нужно больше конкретики по реализации — welcome в комментарии!

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