Polymorphism & asChild — 같은 컴포넌트, 다른 태그
이 문서가 답하는 질문:
<Button>을 어떨 때는<button>으로, 어떨 때는<a href>로 렌더하고 싶다. wrapper<div>없이, 같은 스타일을 유지하면서 어떻게 태그를 바꾸는가? 한 줄 답 (Pyramid Top): 답은 두 갈래다 — polymorphicasprop (타입 폭발, forwardRef 지옥)과asChild+ 자식 cloneElement (Radix가 정착시킨 사실상 표준). 후자가 이긴다.
Why — 같은 디자인, 다른 시맨틱
다음 두 줄을 보자.
<Button variant="primary">로그인</Button>
<a href="/dashboard" className="...button-primary...">대시보드</a>같은 시각 디자인이지만 하나는 <button>(클릭 핸들러), 하나는 <a>(네비게이션). 시맨틱HTML과 접근성 측면에서 둘 다 필요하다. 그런데 디자인 시스템이 Button 한 컴포넌트만 export하고 싶다면?
| 풀려는 문제 | 이전의 해법 | 한계 |
|---|---|---|
| 같은 시각 디자인을 여러 태그로 | <Button> + <LinkButton> 각각 export | 사실상 같은 코드를 두 번 작성 |
라우터 라이브러리의 <Link>와 통합 | Button 내부에서 as={NextLink} 처리 | 모든 라우터마다 분기, 타입 복잡 |
<button> 안에 <a>를 넣지 않기 | 사용자에게 주의 부탁 | HTML invalid (interactive nested) |
| 시각 컴포넌트와 시맨틱을 분리 | wrapper 부모 만들기 | 스타일이 부모로 안 가, 디자인 깨짐 |
How — 두 길과 왜 후자가 이기는가
① Polymorphic as prop — 타입 폭발
// 단순한 시작
type ButtonProps<T extends ElementType = 'button'> = {
as?: T
children: ReactNode
} & ComponentPropsWithoutRef<T>
function Button<T extends ElementType = 'button'>({ as, ...rest }: ButtonProps<T>) {
const Comp = as ?? 'button'
return <Comp {...rest} />
}
<Button>basic</Button>
<Button as="a" href="/x">link</Button> // <a href>
<Button as={Link} to="/x">router</Button> // <Link to>타입은 동작하지만 세 가지 함정이 동시에 터진다:
- forwardRef와 generic이 충돌한다.
forwardRef<HTMLButtonElement>를 generic으로 일반화하려면 강력한 캐스팅이 필요하다. - ref 타입이 잘못된다:
as="a"인데ref<HTMLButtonElement>를 받는 식. - 타입 추론이 무거워진다: tsserver가 느려지고, 에러 메시지가 100줄짜리 generic으로 변한다.
이 패턴을 만들었던 라이브러리들(Chakra v1, MUI styled API)이 공식적으로 후회를 표명한 이력이 있다.
② asChild + Slot — Radix가 정착시킨 표준
import { Slot } from '@radix-ui/react-slot'
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp ref={ref} className={cn(buttonStyles(), className)} {...props} />
})사용:
<Button>plain</Button>
<Button asChild>
<a href="/dashboard">대시보드</a>
</Button>
<Button asChild>
<Link to="/dashboard">router link</Link>
</Button>핵심은 Slot이다. Slot은 자식 element 하나를 받아 그 element에 props/className/ref를 머지한다. React의 cloneElement를 기반으로 한다.
// Slot의 본질 (의사 코드)
function Slot({ children, ...slotProps }) {
return React.cloneElement(React.Children.only(children), {
...slotProps,
...children.props,
className: cn(slotProps.className, children.props.className),
ref: composeRefs(slotProps.ref, children.ref),
})
}이게 왜 이기는가:
- ❌
asprop이 외부에서 어떤 ElementType을 받을지 모름 → 타입 generic이 필요. - ✅
asChild는 외부가 이미 element를 넘김 → 타입은 그냥boolean하나. - ❌ wrapper
<button><a></a></button>없음. invalid HTML 회피. - ✅ 자식 element가 그대로 DOM에 존재하므로 시맨틱이 정확.
What — 구체 사양
@radix-ui/react-slot의 API
import { Slot, Slottable } from '@radix-ui/react-slot'
// 단일 자식 머지
<Slot className="btn">
<a href="/x">link</a>
</Slot>
// 결과: <a href="/x" className="btn">link</a>
// 자식 사이에 슬롯이 아닌 콘텐츠가 섞여 있을 때 — Slottable
<Slot className="btn">
<a href="/x">
<Slottable>link</Slottable>
<ChevronIcon />
</a>
</Slot>우리 Button의 완전한 구현
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { Slot } from '@radix-ui/react-slot'
import { button } from 'styled-system/recipes'
import type { ButtonVariantProps } from 'styled-system/recipes'
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
ButtonVariantProps & {
asChild?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, variant, size, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
ref={ref}
className={cn(button({ variant, size }), className)}
{...props}
/>
)
}
)
Button.displayName = 'Button'사용 시나리오 표
| 시나리오 | 코드 | DOM 결과 |
|---|---|---|
| 일반 버튼 | <Button>로그인</Button> | <button class="...">로그인</button> |
| 링크 버튼 | <Button asChild><a href="/x">go</a></Button> | <a href="/x" class="...">go</a> |
| 라우터 링크 | <Button asChild><Link to="/x">go</Link></Button> | <a href="/x" class="...">go</a> (Link 구현에 따름) |
| 비활성화 링크 | <Button asChild disabled><a>x</a></Button> | <a aria-disabled class="...">x</a> (handler 차단은 별도) |
| 외부 className 합성 | <Button asChild className="ml-2"><a/></Button> | className이 머지됨 |
compound (asChild + Slottable + icon)
<Button asChild>
<a href="/x">
<Icon name="chevron-left" />
<Slottable>뒤로</Slottable>
</a>
</Button>→ <a> 안에 icon + 텍스트가 그대로. wrapper 없음.
What-if — 잘못 쓰면 어떻게 깨지는가
-
함정 1 —
asChild가 자식을 하나만 받는다는 사실을 잊음- 증상:
<Button asChild><Icon/><span>x</span></Button>→React.Children.only에러. - 대응: 자식을 하나의 element로 감싸라.
<Button asChild><a><Icon/><span>x</span></a></Button>.
- 증상:
-
함정 2 —
forwardRef를 안 쓴 자식- 증상:
<Button asChild><MyLink to="/x" /></Button>에서 ref가 자식까지 도달하지 않음. focus 관리, tooltip 등이 깨짐. - 대응:
MyLink를forwardRef로 정의. 또는 React 19+의 ref-as-prop 활용.
- 증상:
-
함정 3 — className의 우선순위
- 증상:
<Button asChild className="px-8"><a className="px-2">x</a></Button>→ 어느 padding이 이기는가? - 대응: Slot은 두 className을 단순 concat한다. 우선순위는 CSS 소스 순서. Tailwind를 쓴다면
tailwind-merge또는cn()유틸로 머지하라. Panda는cx().
- 증상:
-
함정 4 — 이벤트 핸들러가 두 곳에 있을 때
- 증상:
<Button onClick={a} asChild><button onClick={b}/></Button>— Slot은 둘 다 호출한다 (자식 핸들러를 부모 핸들러 다음에). - 대응: 의도된 동작이지만 알고 써야 한다. 명시적으로 한 쪽에만 두는 게 안전.
- 증상:
-
함정 5 — polymorphic
asprop으로 회귀- 증상: 팀원이 “MUI에서는
as로 했는데”라며 추가 요청. - 대응:
asprop의 타입 폭발 사례를 문서로 남기고,asChild를 컨벤션으로 못 박는다.
- 증상: 팀원이 “MUI에서는
-
함정 6 — Next.js
<Link>통합의 두 시대- Next 12 이하:
<Link><a /></Link>패턴 →<Button asChild><Link><a>x</a></Link></Button>필요. - Next 13+:
<Link>자체가 anchor 렌더 →<Button asChild><Link href="/x">x</Link></Button>단순.
- Next 12 이하:
Insight — Slot은 왜 React에 표준으로 안 들어왔나
흥미롭게도 React 팀은 cloneElement를 권장하지 않는다. 공식 문서는 “props를 직접 전달하라”고 말한다. 그런데도 Slot이 사실상 표준이 된 이유는:
“디자인 시스템 측은 이미 컴포넌트의 시각을 책임지고 있고, 사용자는 시맨틱(태그)을 책임진다. 그 두 책임의 합성점이 cloneElement뿐이다.”
Radix Primitives 팀(Jenna Smith, Pedro Duarte 등)은 2021년경 asChild 패턴을 정착시켰고, 2022년 shadcn/ui가 폭발적으로 인기를 끌면서 이 패턴은 React 생태계의 사실상 표준이 되었다. 현재 Headless UI, Ariakit, Reka UI (Vue), Melt UI (Svelte) 모두 비슷한 API를 채택했다.
부가 효과: asChild 덕분에 디자인 시스템이 라우터를 모르게 되었다. Button은 그저 자식 element에 스타일을 입힐 뿐, 그게 <a>이든 <Link>이든 <NavLink>이든 신경 쓰지 않는다. 관심사의 분리가 깔끔하게 달성된 보기 드문 사례다.
요약
- 같은 디자인을 다른 태그로 렌더하는 두 길 —
asprop과asChild. asprop은 타입 폭발 + forwardRef 지옥으로 사실상 폐기.asChild+@radix-ui/react-slot이 사실상 표준 (shadcn/ui, Radix, Headless UI 모두 채택).- 자식 하나만 받기, forwardRef 챙기기, className 머지 (
tailwind-merge/cx)는 필수 점검 항목. - 라우터 라이브러리 통합이 깔끔해지는 부가 효과. 디자인 시스템이 라우터를 모르게 된다.