컴포넌트가 렌더링하는 요소를 외부에서 제어하는 세 가지 패턴.

as

태그명을 props로 전달. 가장 단순하지만 타입이 복잡해짐.

type PolymorphicProps<E extends React.ElementType> = {
  as?: E
} & React.ComponentPropsWithoutRef<E>

function Button<T extends React.ElementType = 'button'>({
  as,
  ...props
}: PolymorphicProps<T>) {
  const Comp = as || 'button'
  return <Comp {...props} />
}

asChild + Slot

Radix UI 방식. 자식 요소로 렌더링 위임. prop 병합이 암묵적.

import { Slot } from '@radix-ui/react-slot'

function Button({ asChild, ...props }) {
  const Comp = asChild ? Slot : 'button'
  return <Comp {...props} />
}

Slot이 자동으로 처리하는 것: className 병합, 이벤트 핸들러 합성, ref 병합.

Slot 직접 구현:

const Slot = forwardRef(({ children, ...props }, ref) => {
  if (!isValidElement(children)) return null

  return cloneElement(children, {
    ...props,
    ...children.props,
    ref: ref || children.ref,
  })
})

자식 요소 타입에 따른 조건부 Props:

type ConditionalProps<T> = T extends ReactElement<any, 'a'>
  ? { href?: string; external?: boolean }
  : T extends ReactElement<any, 'button'>
    ? { type?: 'button' | 'submit' }
    : {}

render

Base UI, React Aria 방식. 명시적 prop 전달. 타입 추론이 가장 좋음.

// Element 방식
<Button render={<a href="/about" />}>Link</Button>

// Callback 방식 - state 접근 가능
<Button render={(props, state) => (
  <motion.button {...props} animate={state.isPressed ? ... : ...} />
)}>
  Animated
</Button>

render prop은 (domProps, renderProps) 시그니처. domProps는 ref 포함 DOM 속성, renderProps는 컴포넌트 상태(isPressed, isSelected 등).

선택 기준

| 패턴    | 복잡도 | 타입 안전성 | 유연성 |
| ------- | ------ | ----------- | ------ |
| as      | 낮음   | 보통        | 낮음   |
| asChild | 중간   | 보통        | 높음   |
| render  | 중간   | 높음        | 높음   |
  • 단순 태그 변경 → as
  • 기존 컴포넌트 합성 → asChild 또는 render
  • 상태 기반 커스터마이징 → render

#538