라이브러리, 프레임워크•2026.02.01
as prop 패턴이 있습니다.asChild prop과 Slot 패턴에 대해 설명합니다. 또한 그 과정에서 forwardRef와 같은 React API와 clsx 같은 라이브러리가 어떻게 함께 작동하는지 살펴보고, 마지막으로 Base UI와 그 render prop을 통해 미래를 전망해 보겠습니다.asChild PropButton 컴포넌트를 버튼 대신 앵커(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>// ❌ 에러! 문자열은 복제할 수 없습니다.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을 덮어쓰게 됩니다.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>text-red, 자식은 text-blue), 나중에 오는 부모의 클래스 이름이 우선순위를 갖게 됩니다.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이 더 높은 우선순위를 갖는지 사용자에게 명확하게 전달(경고 또는 문서)하는 것이 중요합니다.
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도 고려했나요?Slot 컴포넌트를 확인해 보는 것이 좋습니다.Slot@radix-ui/react-slot 라이브러리를 설치합니다.pnpm add @radix-ui/react-slotconst 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} />}Slot 컴포넌트로 대체하면 다음과 같습니다.import { Slot } from '@radix-ui/react-slot'const Button = ({ asChild, ...props }: ButtonProps) => { const Comp = asChild ? Slot : 'button' return <Comp {...props} />}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> );}forwardRefrefs가 어떻게 병합될 수 있는지 살펴보겠습니다.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를 병합하기 위한 간단한 유틸리티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>Slot 컴포넌트로도 동일한 결과를 얻을 수 있습니다.// React 18에서의 Radix UIconst Button = React.forwardRef(({ asChild, ...props }: ButtonProps, ref) => { const Comp = asChild ? Slot : 'button' return <Comp {...props} ref={ref} />})forwardRef 함수는 지원 중단(Deprecated)되었습니다. 함수 컴포넌트에서 ref를 prop으로 직접 접근할 수 있으므로, Radix Slot 코드를 다음과 같이 업데이트하여 ref를 전달할 수 있습니다.// React 19에서의 Radix UIconst 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>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/3ldogub3bt22bmergeProps 함수를 사용하여 더 세밀하게 커스터마이징할 수도 있습니다.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>asChild prop이 실제로는 내부적으로 Slot 컴포넌트를 트리거한다는 점Slot 컴포넌트의 병합 로직이 추상화되어 있다는 점render라는 prop 이름이 더 의미론적입니다 (무엇이 렌더링되는지 나타냄).useRender입니다.useRender.ComponentProps 타입 유틸리티가 더 나은 타입 추론을 제공할 가능성이 높습니다.mergeProps를 통해 병합 동작을 명시적으로 제어할 수 있습니다.asChild 패턴은 이미 생태계(Radix, shadcn/ui 등)에서 어느 정도 표준이 되었기에, 더 많은 하위 라이브러리와 개발자들이 이를 따라잡는 데는 시간이 걸릴 것입니다.아직 댓글이 없습니다.
첫 번째 댓글을 작성해보세요!

기술의 사춘기: 강력한 AI의 위험에 맞서고 극복하기
Inkyu Oh • AI & ML-ops

UX는 당신의 해자입니다 (그리고 당신은 이를 무시하고 있죠)
Inkyu Oh • UI/UX