[번역] React Slot/asChild 합성 패턴

Boda - 2026년 1월 11일


React에서는 재사용 가능한 컴포넌트를 만들기 위한 여러 합성(Composition) 패턴이 사용되어 왔습니다. 예를 들어, 고차 컴포넌트(HOCs)as prop 패턴이 있습니다.
이 입문 포스트에서는 Radix UI에 의해 대중화된 최근의 asChild prop과 Slot 패턴에 대해 설명합니다. 또한 그 과정에서 forwardRef와 같은 React API와 clsx 같은 라이브러리가 어떻게 함께 작동하는지 살펴보고, 마지막으로 Base UI와 그 render prop을 통해 미래를 전망해 보겠습니다.



asChild Prop

Button 컴포넌트를 버튼 대신 앵커(Anchor) a 엘리먼트로 렌더링하고 싶다고 가정해 봅시다.
<Button>click here</Button>
// 출력: <button>click here</button>

<Button asChild>
<a href="/about">About</a>
</Button>
// 출력: <a href="/about">About</a>

<Button asChild={false}>
<a href="/about">About</a>
</Button>
// 출력: <button><a href="/about">About</a></button>
왜 그냥 별도의 앵커 컴포넌트(예: Link 컴포넌트)를 만들지 않나요? 위 예시는 단순화된 것이지만, 더 복잡한 사례에서는 Button 컴포넌트로부터 상속받아야 할 속성(스타일, 분석 데이터 등)이 있을 수 있으며, 우리는 단지 그것이 다르게 렌더링되는 능력만 확장하고 싶은 것입니다.
asChild prop의 간단한 React 구현은 다음과 같을 수 있습니다.
import * as React from "react";

type ButtonProps = React.ComponentProps<'button'> & {
asChild?: boolean
}

const Button = ({ asChild, children, ...props }: ButtonProps) => {
if (asChild) {
// 자식 엘리먼트를 복제하고 props를 병합합니다.
return React.cloneElement(children as React.ReactElement, props)
}
// 기본값: 버튼으로 렌더링
return <button {...props}>{children}</button>
}
참고: React에서 children prop은 무엇이든 될 수 있습니다! 정적 콘텐츠, 함수, 여러 엘리먼트 등 다양합니다.
<Button>Click me</Button>
// children = "Click me"

<Button><span>Hello</span></Button>
// children = <span>Hello</span>

<Button>
{(data) => <div>{data.name}</div>}
</Button>
// children = (data) => <div>{data.name}</div>

<Button>
<Icon />
<span>Click</span>
</Button>
// children = [<Icon />, <span>Click</span>]
지금까지의 구현은 컴포넌트의 children을 다른 엘리먼트로 변경하면 실패하게 됩니다.
<Button asChild>
<a>Click</a>
<span>Extra</span>
</Button>
// ❌ 에러! cloneElement는 배열이 아닌 단일 ReactElement를 기대합니다.

<Button asChild>
Just text
</Button>
// ❌ 에러! 문자열은 복제할 수 없습니다.
다른 props는 어떨까요? 예를 들어 충돌하는 className은 어떻게 될까요?
<Button asChild className="text-red">
<a href="/about" className="text-sm">
About
</a>
</Button>
// ❌ 출력: <a href="/about" class="text-red">About</a>
부모 엘리먼트의 className이 자식 앵커 엘리먼트의 text-sm을 덮어버렸습니다. 이는 cloneElement(children, props) 함수가 본질적으로 다음과 같이 동작하기 때문입니다.
cloneElement(
<a href="/about" className="text-sm">About</a>,
{ className: "text-red" }
)
부모의 className prop이 나중에 오기 때문에, 이전의 충돌하는 prop을 덮어쓰게 됩니다.
충돌하는 className 문자열을 분리하여 병합하거나, clsx 라이브러리를 사용하여 이를 처리할 수 있습니다.
참고
이 기술 스택에 익숙한 분들은 tailwind-mergeClass Variance Authority 같은 라이브러리도 알고 계실 것입니다. 여기서는 범위를 벗어나므로 다루지 않겠지만, 이 라이브러리들에 대한 후속 포스트를 원하신다면 알려주세요!
import { clsx } from 'clsx';

// Button 컴포넌트 내부에서
return React.cloneElement(children as React.ReactElement, {
...props,
className: clsx(children.props.className, props.className),
})
// 출력: <a href="/about" class="text-sm text-red">click me</a>
클래스 이름의 순서에 주목하세요! 자식 엘리먼트의 클래스 이름이 먼저 오고 그 다음에 부모의 것이 옵니다.
CSS 명시도(Specificity) 규칙에 따라, 충돌하는 클래스 이름이 있을 경우(예: 부모는 text-red, 자식은 text-blue), 나중에 오는 부모의 클래스 이름이 우선순위를 갖게 됩니다.
만약 자식의 prop이 부모의 것을 이기게 하고 싶다면, cloneElement에서 props와 클래스 이름의 순서를 바꾸는 것이 빠른 해결책입니다.
return cloneElement(children as React.ReactElement, {
...props,
...children.props,
className: clsx(props.className, children.props.className),
});
// 출력: <a href="/about" class="text-red text-sm">About</a>
자식 props가 부모의 것을 이겨야 할까요, 아니면 그 반대여야 할까요? 이는 컴포넌트 설계상의 결정입니다. 나중에 Radix UI에서 보겠지만, 그들은 자식의 props가 부모의 것을 이기도록 선택했습니다. 하지만 반대로 동작하는 라이브러리도 볼 수 있을 것입니다. 어느 쪽이든, 어떤 prop이 더 높은 우선순위를 갖는지 사용자에게 명확하게 전달(경고 또는 문서)하는 것이 중요합니다.
아직 다루지 않은 다른 유스케이스들이 있습니다. 예를 들면 다음과 같습니다.
  • ref를 전달(Forwarding)하지 않고 있습니다.
const buttonRef = React.useRef<HTMLButtonElement>(null)
const anchorRef = React.useRef<HTMLAnchorElement>(null)

<Button asChild ref={buttonRef}>
<a ref={anchorRef} href="/">Link</a>
</Button>
// ❌ 두 ref 모두 작동하지 않습니다!
  • 자식의 이벤트 핸들러가 중요한 부모의 핸들러를 무시할 수 있습니다.
<Button asChild onClick={() => analyticsTracking()}>
<a onClick={() => console.log("child")}>click</a>
</Button>
// ❌ 부모의 onClick이 실행되지 않습니다.
  • className 외에 style prop 같은 다른 props도 고려했나요?
  • 기타 예외 상황들...
우리의 구현을 확장할 수도 있겠지만, 대부분의 예외 상황을 고려하여 구현해 놓은 Radix UI의 Slot 컴포넌트를 확인해 보는 것이 좋습니다.

Radix UI의 Slot

먼저, @radix-ui/react-slot 라이브러리를 설치합니다.
pnpm add @radix-ui/react-slot
다시 상기하자면, 지금까지 우리의 구현은 다음과 같습니다.
const Button = ({ asChild, children, ...props }: ButtonProps) => {
if (asChild) {
return React.cloneElement(children as React.ReactElement, {
...props,
...children.props,
className: clsx(props.className, children.props.className),
})
}
return <button {...props} />
}
이를 Radix UI의 Slot 컴포넌트로 대체하면 다음과 같습니다.
import { Slot } from '@radix-ui/react-slot'

const Button = ({ asChild, ...props }: ButtonProps) => {
const Comp = asChild ? Slot : 'button'
return <Comp {...props} />
}
Radix UI의 Slot 컴포넌트를 사용하면 다음과 같은 케이스들도 처리됩니다.
  • 이벤트 핸들러
<Button asChild onClick={() => analyticsTracking()}>
<a onClick={() => console.log("child")}>click</a>
</Button>
// 두 onClick 이벤트를 모두 처리할 수 있습니다.
부모 컴포넌트에 onClick 이벤트가 있지만 자식의 것만 실행하고 싶다면 어떻게 하나요? 보통 event.stopPropagation()을 시도할 수 있지만, Radix Slot은 이벤트 핸들러를 함께 병합하기 때문에 이벤트 전파 중단 방식은 작동하지 않습니다. 해결 방법은 부모의 로직을 조건문으로 감싸고 defaultPrevented 속성을 확인하는 것입니다.
<Button
asChild
onClick={(event) => {
if (!event.defaultPrevented) {
console.log('button clicked')
}
}}
>
<a
href="/about"
onClick={(event) => {
event.preventDefault()
console.log('anchor clicked')
}}
>
About
</a>
</Button>
  • style을 포함한 다른 props 자동 병합
<Button asChild style={{ padding: '10px' }}>
<a href="/about" style={{ border: '3px solid purple' }}>
About
</a>
</Button>
// style prop도 함께 병합됩니다.
  • Slot.Slottable을 사용한 여러 컴포넌트 children 처리
const Button = ({ asChild, children, leftElement, rightElement, ...props }) => {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp {...props}>
{leftElement}
<Slot.Slottable>{children}</Slot.Slottable>
{rightElement}
</Comp> );
}

React 18과 forwardRef

다시 커스텀 구현으로 돌아가서 refs가 어떻게 병합될 수 있는지 살펴보겠습니다.
앞서 다음과 같은 예시가 작동하지 않는다고 언급했습니다.
const buttonRef = React.useRef<HTMLButtonElement>(null)
const anchorRef = React.useRef<HTMLAnchorElement>(null)

<Button asChild ref={buttonRef}>
<a ref={anchorRef} href="/">Link</a>
</Button>
// ❌ 두 ref 모두 작동하지 않습니다!
커스텀 구현을 확장하여 ref들을 함께 병합할 수 있습니다.
// ref를 병합하기 위한 간단한 유틸리티
function composeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
return (node: T | null) => {
refs.forEach((ref) => {
// useCallback에서 사용될 때 ref는 콜백 함수일 수 있습니다.
if (typeof ref === 'function') {
ref(node)
} else if (ref != null) {
(ref as React.MutableRefObject<T | null>).current = node
}
})
}
}

const Button = React.forwardRef(({ asChild, children, ...props }: ButtonProps, forwardedRef) => {
if (asChild) {
const child = children as React.ReactElement
return React.cloneElement(child, {
...props,
...child.props,
ref: forwardedRef ? composeRefs(forwardedRef, (child as any).ref) : (child as any).ref,
className: clsx(props.className, child.props.className),
})
}
return <button ref={forwardedRef} {...props} />
})
애플리케이션에서 테스트해 보겠습니다.
function App() {
const buttonRef = React.useRef<HTMLButtonElement>(null)
const anchorRef = React.useRef<HTMLAnchorElement>(null)

React.useEffect(() => {
console.log('buttonRef:', buttonRef.current)
console.log('anchorRef:', anchorRef.current)
}, [])

return (
<Button asChild ref={buttonRef}>
<a href="#" ref={anchorRef}>
About
</a>
</Button>
)
}
useEffect 내에서 ref.current를 로깅하고 있음에 주목하세요. 만약 더 일찍, 예를 들어 console.log(forwardRef)와 같이 로깅했다면, React가 아직 렌더링 단계에 있고 DOM을 업데이트하거나 Ref를 할당하기 전이므로 {current: null}이 출력될 것입니다.
콘솔에는 다음과 같이 출력되어야 합니다.
buttonRef: <a href="#">About</a>
anchorRef: <a href="#">About</a>
만약 asChild={false}로 설정한다면 다음과 같이 출력될 것입니다.
buttonRef: <button><a href="#">About</a></button>
anchorRef: <a href="#">About</a>
Radix UI의 Slot 컴포넌트로도 동일한 결과를 얻을 수 있습니다.
// React 18에서의 Radix UI
const Button = React.forwardRef(({ asChild, ...props }: ButtonProps, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp {...props} ref={ref} />
})
React v19부터 forwardRef 함수는 지원 중단(Deprecated)되었습니다. 함수 컴포넌트에서 ref를 prop으로 직접 접근할 수 있으므로, Radix Slot 코드를 다음과 같이 업데이트하여 ref를 전달할 수 있습니다.
// React 19에서의 Radix UI
const Button = ({ asChild, ref, ...props }: ButtonProps) => {
const Comp = asChild ? Slot : 'button'
return <Comp {...props} ref={ref} />
}

<Button asChild ref={buttonRef} className="text-red">
<a href="#" ref={anchorRef} className="text-sm">
About
</a>
</Button>

미래: Base UI

앞을 내다보면, Radix UI의 제작자들은 Base UI(https://base-ui.com) 작업을 시작했습니다.
Base UI에서 Slot 패턴은 render prop과 useRender 훅으로 대체될 예정입니다.
import { useRender } from '@base-ui/react/use-render'

interface ButtonProps extends useRender.ComponentProps<'button'> {}

const Button = ({ render, ...props }: ButtonProps) => {
return useRender({
defaultTagName: 'button',
render,
props,
})
}

<Button
className="text-red"
render={<a href="#" className="text-sm" ref={anchorRef} />}>
About
</Button>
// 출력: <a href="#" class="text-sm text-red">About</a>
// buttonRef: <a href="#" class="text-sm text-red">About</a>
// anchorRef: <a href="#" class="text-sm text-red">About</a>
새로운 render prop은 사실 React 커뮤니티가 이전에 보았던 오래된 as prop 패턴의 변형입니다: https://bsky.app/profile/haz.dev/post/3ldogub3bt22b
props 병합 마법은 자동으로 처리되지만, 원한다면 Base UI에서 제공하는 mergeProps 함수를 사용하여 더 세밀하게 커스터마이징할 수도 있습니다.
import { mergeProps } from '@base-ui/react/merge-props'

const Button = ({ render, ...props }: ButtonProps) => {
return useRender({
defaultTagName: 'button',
render,
// 여기서 병합하거나
props: mergeProps<'button'>({ className: 'underline' }, props),
})
}

<Button
className="text-red"
render={(props) => (
<a
href="#"
{...mergeProps<'a'>(props, {
className: 'text-sm',
// 또는 여기서 병합
ref: composeRefs(props.ref, anchorRef),
})}
/>
)}>
About
</Button>
// 출력: <a href="#" class="text-sm text-red underline">About</a>
// buttonRef: <a href="#" class="text-sm text-red underline">About</a>
// anchorRef: <a href="#" class="text-sm text-red underline">About</a>
전반적으로 Radix UI의 접근 방식은 다음과 같은 사항을 이해해야 했기에 더 암시적(Implicit)이었습니다.
  • asChild prop이 실제로는 내부적으로 Slot 컴포넌트를 트리거한다는 점
  • Slot 컴포넌트의 병합 로직이 추상화되어 있다는 점
반면 Base UI의 접근 방식은 다음과 같은 이유로 더 명시적(Explicit)입니다.
  • render라는 prop 이름이 더 의미론적입니다 (무엇이 렌더링되는지 나타냄).
  • 기저 메커니즘이 현대적인 React 훅인 useRender입니다.
  • useRender.ComponentProps 타입 유틸리티가 더 나은 타입 추론을 제공할 가능성이 높습니다.
  • mergeProps를 통해 병합 동작을 명시적으로 제어할 수 있습니다.
이는 컴포넌트 래퍼 패턴보다는 더 명시적이고 훅 기반의 API를 지향하는 React 커뮤니티의 지속적인 진화를 반영합니다.
하지만 asChild 패턴은 이미 생태계(Radix, shadcn/ui 등)에서 어느 정도 표준이 되었기에, 더 많은 하위 라이브러리와 개발자들이 이를 따라잡는 데는 시간이 걸릴 것입니다.
그동안 개발자들에게는 두 패턴이 각각 어떻게 작동하는지 이해하는 것이 여전히 가치 있는 일입니다.
0
12

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글