시작말
재사용 가능한 공통 컴포넌트, 확장성 있는 컴포넌트란 무엇일까?에 대해 고민하다가
잘 만들어진 오픈소스 UI 라이브러리 코드들을 보면 그 답을 알 수 있지 않을까 해서 Radix UI와 Headless UI의 라이브러리 코드를 살펴보기로 했다.
소스코드에서 공통적으로 사용하는 컴포넌트 설계 패턴이 있는데 4가지의 핵심 React Composition 패턴에 대해 정리해보려고 한다.
1. Compound Component 패턴
핵심 개념은 간단하다.
부모 컴포넌트가 상태를 관리하고, 자식 컴포넌트들은 Context를 통해 그 상태를 공유하는 것이다.
Radix UI의 Select 컴포넌트에서 이 패턴을 확인할 수 있었다.
// Context 생성
const SelectContext = React.createContext({});
// Root 컴포넌트에서 Context.Provider로 값을 내려줌
const SelectRoot = (props) => {
const { children, size, ...rootProps } = props;
return (
<SelectPrimitive.Root {...rootProps}>
<SelectContext.Provider value={React.useMemo(() => ({ size }), [size])}>
{children}
</SelectContext.Provider>
</SelectPrimitive.Root>
);
};
// 자식 컴포넌트에서 Context를 소비
const SelectTrigger = React.forwardRef((props, forwardedRef) => {
const context = React.useContext(SelectContext);
// context.size를 사용해서 렌더링...
);
사용하는 쪽에서는 이렇게 쓴다.
<Select.Root size="2">
<Select.Trigger placeholder="Pick one" />
<Select.Content>
<Select.Item value="a">Option A</Select.Item>
<Select.Item value="b">Option B</Select.Item>
</Select.Content>
</Select.Root>
이렇게 하면 Root에서 size="2"만 한 번 설정하면, Trigger, Content 등 모든 자식이 자동으로 같은 size를 받아서 일관된 UI가 적용된다.
각 서브 컴포넌트에 일일이 size를 전달할 필요가 없다.
자식들은 prop drilling 없이 Context로 상태를 공유한다.
Namespace Export 패턴
Compound Components와 함께 자주 사용되는 패턴이 있다.
서브 컴포넌트들을 하나의 네임스페이스로 묶어서 export하는 방식이다.
export {
SelectRoot as Root,
SelectTrigger as Trigger,
SelectContent as Content,
SelectItem as Item,
SelectGroup as Group,
SelectLabel as Label,
SelectSeparator as Separator,
};
이렇게 내보내면 사용하는 쪽에서는 아래처럼 쓸 수 있다.
import * as Select from '@radix-ui/themes/components/select';
<Select.Root>
<Select.Trigger>...</Select.Trigger>
<Select.Content>...</Select.Content>
</Select.Root>
이 방식이 좋은 이유는 다음과 같다.
Select.이라는 접두사가 붙으니 어떤 컴포넌트 그룹인지 즉시 알 수 있다.- IDE 자동완성이
Select.을 치면 모든 서브 컴포넌트가 뜬다. - 네이밍 충돌이 없다 —
Trigger만 쓰면 어떤 Trigger인지 모르지만Select.Trigger는 명확하다. displayName설정도'Select.Trigger'처럼 일관되게 할 수 있어서 DevTools 디버깅이 편하다.
Headless UI의 Menu 컴포넌트에서도 Compound Component 패턴을 확인할 수 있었다.
import { MenuContext, useMenuMachine, useMenuMachineContext } from './menu-machine-glue'
// Root 컴포넌트에서 Context.Provider로 상태를 내려줌
function MenuFn(props, ref) {
let machine = useMenuMachine({ id, __demoMode }) // 상태 머신 생성
return (
<FloatingProvider> // 위치 계산 Context
<MenuContext.Provider value={machine}> // 상태 머신 Context
<OpenClosedProvider value={...}> // 열림/닫힘 상태 Context
{render({ ourProps, theirProps, slot, ... })}
</OpenClosedProvider>
</MenuContext.Provider>
</FloatingProvider>
)
}
// 자식 컴포넌트에서 Context를 소비
function ButtonFn(props, ref) {
let machine = useMenuMachineContext('Menu.Button')
// machine을 사용해서 메뉴 열기/닫기 등 처리...
}
여기서는 Object.assign으로 서브 컴포넌트를 Root에 붙여서 export한다.
let MenuRoot = forwardRefWithAs(MenuFn) as _internal_ComponentMenu
export let Menu = Object.assign(MenuRoot, {
Button: MenuButton,
Items: MenuItems,
Item: MenuItem,
Section: MenuSection,
Heading: MenuHeading,
Separator: MenuSeparator,
})
두 라이브러리의 export 방식은 다르지만 컴포넌트.서브컴포넌트로 접근할 수 있게 한다는 목적은 동일하다.
2. Controlled / Uncontrolled Component 패턴
이 패턴은 누가 상태를 관리하는지를 결정하는 패턴이다. 상태의 주도권을 부모에게 줄 수도, 컴포넌트 내부에 맡길 수도 있게 설계한다.
- Controlled: 부모가
value+onChange를 직접 관리 - Uncontrolled: 컴포넌트가
defaultValue로 내부 상태를 스스로 관리
// Controlled — 부모가 상태를 관리
<Dialog open={isOpen} onOpenChange={setIsOpen} />
// Uncontrolled — 컴포넌트 내부에서 상태를 관리
<Dialog defaultOpen />
// 내부에서는 이렇게 동작한다: const [open, setOpen] = useState(defaultOpen);
좋은 UI 라이브러리는 두 방식 모두 지원한다.
Radix UI의 Checkbox 컴포넌트를 보면 이 구조가 잘 드러난다.
import { useControllableState } from 'radix-ui/internal';
const Checkbox = React.forwardRef((props, forwardedRef) => {
const {
checked: checkedProp, // 부모가 제어할 때 쓰는 값 (있으면 controlled)
defaultChecked: defaultCheckedProp, // 초기값
onCheckedChange, // 값이 바뀔 때 부모에게 알려주는 콜백
...checkboxProps
} = extractProps(props, checkboxPropDefs, marginPropDefs);
// ⭐ 이 한 줄이 controlled/uncontrolled을 모두 처리
const [checked, setChecked] = useControllableState({
prop: checkedProp,
defaultProp: defaultCheckedProp,
onChange: onCheckedChange,
});
return (
<CheckboxPrimitive.Root
checked={checked}
onCheckedChange={setChecked}
// ...
/>
);
});
사용하는 쪽에서는 상황에 따라 선택하면 된다.
// Uncontrolled — defaultChecked만 전달, 이후 상태는 컴포넌트 내부에서 관리
<Checkbox defaultChecked />
// Controlled — checked + onCheckedChange 전달, 상태를 부모가 직접 관리
const [checked, setChecked] = useState(false);
// ...
<Checkbox checked={checked} onCheckedChange={setChecked} />
여기서 핵심은 useControllableState라는 훅이다. 이 훅이 내부적으로 하는 일은 다음과 같다.
prop(=checked)이 넘어오면 → 그걸 그대로 사용 (Controlled)prop이undefined이면 → 내부useState(defaultProp)을 사용 (Uncontrolled)- 어떤 모드든 값이 바뀌면
onChange콜백을 호출
매번 "controlled인지 uncontrolled인지" 분기하는 코드를 작성하지 않아도, 훅 하나로 추상화하면 모든 컴포넌트에서 일관되게 적용할 수 있다.
Headless UI에서도 controlled/uncontrolled 패턴을 구현한 useControllable이라는 훅이 있었다.
export function useControllable<T>(
controlledValue: T | undefined,
onChange?: (value: T) => void,
defaultValue?: T
) {
let [internalValue, setInternalValue] = useState(defaultValue)
let isControlled = controlledValue !== undefined // 부모로부터 받은 상태가 있으면 controlled
return [
// controlled 컴포넌트면 controlledValue를 사용하고, 그렇지 않으면 internalValue를 사용한다.
(isControlled ? controlledValue : internalValue)!,
useEvent((value) => {
if (isControlled) {
return onChange?.(value) // controlled: onChange만 호출
} else {
flushSync(() => setInternalValue(value)) // uncontrolled: 내부 상태도 업데이트
return onChange?.(value) // uncontrolled: onChange 호출
}
}),
] as const
}
사용하는 쪽에서는 상황에 따라 선택한다.
// Uncontrolled (자체 상태 관리)
<Listbox defaultValue="옵션1">
// Controlled (부모가 상태 관리)
<Listbox value={selected} onChange={setSelected}>
두 라이브러리 모두 같은 문제를 같은 방식으로 해결하고 있다.
"prop이 넘어오면 controlled, 아니면 uncontrolled"이라는 핵심 로직을 커스텀 훅으로 추상화하고, 모든 컴포넌트에서 이 훅을 재사용하는 구조다.
3. Polymorphic Component 패턴 (as prop / asChild)
컴포넌트가 렌더링하는 HTML 요소를 바꿀 수 있게 하는 패턴이다.
Radix UI에서는 asChild 방식을 사용하고, Headless UI에서는 as prop 방식을 사용한다. 접근 방식은 다르지만 목적은 같다.
컴포넌트의 동작은 유지하면서 렌더링되는 HTML 요소를 유연하게 바꾸는 것이다.
Headless UI의 as prop 방식
Headless UI에서는 모든 컴포넌트가 as prop을 받을 수 있다. Button 컴포넌트를 보면:
let DEFAULT_BUTTON_TAG = 'button' as const
function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: ButtonProps<TTag>,
ref: Ref<HTMLElement>
) {
let render = useRender()
// ...
return render({
// ...
defaultTag: DEFAULT_BUTTON_TAG, // as가 없으면 기본값 'button'으로 렌더링
name: 'Button',
})
}
as prop이 어디서 쓰지는지 보려면, render.ts의 _render 함수를 보면 된다.
function _render(props, slot, tag, name, mergeRefs) {
let {
as: Component = tag, // as가 있으면 전달된 요소를, 없으면 defaultTag를 사용
...
} = omit(props, ['unmount', 'static'])
// ...
return createElement(Component, { ...props }, children)
}
// JSX 결과:
// <Component {...props}>
// {children}
// </Component>
as prop의 값으로 Component가 결정된다. 기본값은 defaultTag이고, 사용자가 as를 전달하면 그것으로 대체된다.
사용하는 쪽에서는 이렇게 쓴다.
// <button>으로 렌더링
<Button>Click me</Button>
// <a>로 렌더링
<Button as="a" href="/page">Click me</Button>
Radix UI의 asChild 방식
Radix UI의 BaseButton 컴포넌트를 보면 asChild 방식을 사용한 Polymorphic Component 패턴을 확인할 수 있다.
const BaseButton = React.forwardRef<BaseButtonElement, BaseButtonProps>((props, forwardedRef) => {
// asChild가 true면 <Slot.Root>을 렌더링하고, false면 기본 <button>을 렌더링
const Comp = asChild ? Slot.Root : 'button';
// ...
return (
<Comp {...baseButtonProps} ...>
{child}
</Comp>
);
이때 Slot.Root는 props 전달(forwarding) 컴포넌트로,
자체적으로 DOM 요소를 렌더링하지 않고, 자식 요소에 부모의 props를 병합(merge)해서 전달한다.
즉, asChild가 true면 자식 요소에 props를 전달하여 자식 요소만 렌더링하고, false면 기본 <button>을 렌더링한다.
// React.cloneElement로 자식을 복제하면서 부모(Slot)의 props를 자식의 props와 병합한다. (className, style, 이벤트 핸들러 등)
function Slot({ children, ...slotProps }) {
const child = React.Children.only(children); // 자식이 하나여야 함
return React.cloneElement(child, {
...mergeProps(slotProps, child.props), // props 병합
});
}
사용하는 곳에서는 이렇게 쓴다.
// 기본: <button>으로 렌더링
<Button>Click me</Button>
// asChild: <a>로 렌더링하되 Button의 스타일/동작을 물려받음
<Button asChild>
<a href="/page">Navigate</a>
</Button>
as prop vs asChild
두 방식의 목적은 같지만 동작 방식에 차이가 있다.
as prop은 렌더링할 요소를 문자열이나 컴포넌트로 직접 지정하는 방식이다. as="a", as={Link} 처럼 쓴다.
간단하고 직관적이지만, as로 전달한 요소의 타입에 맞는 props를 정확하게 추론하기가 어렵다는 문제가 있다.
예를 들어 as="a"를 전달했을 때 href가 필수인지를 타입 레벨에서 강제하려면 복잡한 제네릭이 필요하다.
Headless UI는 forwardRefWithAs 같은 유틸리티로 이 문제를 처리하고 있지만, 타입 정의가 복잡해지는 것은 피할 수 없다.
asChild는 자식 요소 자체를 렌더링 대상으로 삼는 방식이다.
렌더링할 요소를 prop으로 전달하는 것이 아니라, 자식으로 넣으면 Slot이 그 자식에게 부모의 props를 병합해준다.
이 방식의 장점은 자식 요소가 이미 자신의 타입을 가지고 있기 때문에 타입 추론 문제가 자연스럽게 해결된다는 점이다.
<a href="/page">를 자식으로 넣으면 href의 타입 체크는 <a> 태그 자체가 담당한다.
정리하면 as prop은 "어떤 요소로 렌더링할지를 prop으로 알려주는 방식"이고, asChild는 "렌더링할 요소를 자식으로 직접 넣는 방식"이다.
4. Composition 패턴 (합성 패턴)
마지막은 React의 근본 철학이기도 한 "상속 대신 조합으로 컴포넌트를 구성한다"는 Composition 패턴이다.
Radix UI에서는 Wrapper Component 방식을, Headless UI에서는 훅 합성과 Provider 레이어링 방식을 찾아볼 수 있었다.
Radix UI: Wrapper Component
저수준 컴포넌트(Primitive)를 감싸서, 스타일과 제약을 입힌 고수준 컴포넌트를 만드는 방식이다.
장점은 사용자에게는 단순한 API를 제공한다는 점이다.
const Progress = React.forwardRef((props, forwardedRef) => {
const { className, color, radius, duration, ...progressProps } = extractProps(props, ...);
return (
// Radix Primitive를 감싸고
<ProgressPrimitive.Root
data-accent-color={color} // 테마 시스템 연결을
data-radius={radius} // 내부에서 처리
className={classNames('rt-ProgressRoot', className)} // 스타일 추가
{...progressProps}
asChild={false} // 제약 추가
>
// 내부 구조를 사용자가 직접 조립할 필요 없이 자동으로 구성
<ProgressPrimitive.Indicator className="rt-ProgressIndicator" />
</ProgressPrimitive.Root>
);
});
사용자 입장에서 비교하면 차이가 확실하다.
// Primitive를 직접 쓰면 이렇게 해야 하지만
<ProgressPrimitive.Root value={50} max={100}>
<ProgressPrimitive.Indicator style={{ transform: `translateX(-${100 - 50}%)` }} />
</ProgressPrimitive.Root>
// Wrapper로 덕분에 이렇게만 쓰면 된다.
<Progress value={50} color="blue" size="2" />
Primitive는 동작(behavior)만 제공하고, Themes는 그 위에 스타일 + 제약 + 편의를 합성한다.
상속이 아니라 감싸기(wrapping)로 기능을 확장하는 것이 핵심이다.
Headless UI: 훅 합성 (Hook Composition)
Headless UI는 훅 합성을 통해 여러 개의 작은 훅을 조합해서 하나의 복잡한 동작을 만든다.
Switch 컴포넌트가 이 패턴을 잘 보여준다.
function SwitchFn(props, ref) {
// 1. 상태 훅
let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked ?? false)
// 2. 인터랙션 훅 — 각각 독립적인 관심사
let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) // 포커스 링
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) // 호버 감지
let { pressed: active, pressProps } = useActivePress({ disabled }) // 눌림 상태
// 3. 접근성 훅
let labelledBy = useLabelledBy() // aria-labelledby 자동 수집
let describedBy = useDescribedBy() // aria-describedby 자동 수집
// 4. 모든 훅의 결과를 mergeProps로 하나로 합친다
let ourProps = mergeProps(
{
id, ref: switchRef, role: 'switch',
'aria-checked': checked,
'aria-labelledby': labelledBy,
'aria-describedby': describedBy,
onClick: handleClick,
onKeyUp: handleKeyUp,
},
focusProps,
hoverProps,
pressProps
)
return (
<>
{render({ ourProps, theirProps, slot, defaultTag: DEFAULT_SWITCH_TAG, name: 'Switch' })}
</>
)
}
하나의 Switch 컴포넌트가 6가지 독립적인 관심사를 합성하고 있다.
각 훅은 자기 관심사만 처리하고, mergeProps가 이벤트 핸들러들을 안전하게 병합한다.
이 구조 덕분에 훅 하나를 수정해도 다른 훅에 영향이 없다.
Headless UI: Provider 합성 (Context Composition)
가장 복잡한 예시는 Dialog 컴포넌트다. Dialog가 열릴 때 필요한 동작들을 보면:
- 배경 요소를 비활성화(inert)해야 하고
- 외부 클릭 시 닫아야 하고
- Escape 키로 닫아야 하고
- 스크롤을 잠가야 하고
- 포커스를 Dialog 안에 가둬야 한다.
이 모든 것이 각각 독립적인 훅으로 분리되어 있다.
function InternalDialog(props, ref) {
// 각 훅은 자기 관심사만 처리한다
useInertOthers(enabled, { allowed: [...], disallowed: [...] }) // 배경 비활성화
useOutsideClick(isTopLayer, containers, () => close()) // 외부 클릭 감지
useEscape(isTopLayer, window, () => close()) // Escape 키 감지
useScrollLock(enabled, ownerDocument, containers) // 스크롤 잠금
useOnDisappear(enabled, dialogRef, close) // 요소 사라짐 감지
// 그리고 Provider 레이어로 기능을 합성한다
return (
<ResetOpenClosedProvider> // 열림/닫힘 상태 초기화
<ForcePortalRoot force={true}> // 포탈 강제 활성화
<Portal> // DOM 트리 밖에 렌더링
<DialogContext.Provider> // Dialog 상태 공유
<PortalGroup> // 중첩 포탈 그룹화
<ForcePortalRoot force={false}>
<DescriptionProvider> // aria-describedby 자동 관리
<PortalWrapper> // 중첩 포탈 래핑
<FocusTrap> // 포커스 가두기
<CloseProvider> // close() 함수 하위 컴포넌트에 제공
{render({ ... })}
</CloseProvider>
</FocusTrap>
</PortalWrapper>
</DescriptionProvider>
</ForcePortalRoot>
</PortalGroup>
</DialogContext.Provider>
</Portal>
</ForcePortalRoot>
</ResetOpenClosedProvider>
)
}
이 구조가 핵심적으로 보여주는 것은, 하나의 복잡한 컴포넌트를 만들 때 거대한 클래스를 상속하는 것이 아니라, 작은 빌딩 블록(훅 + Provider)을 레이어로 쌓아서 합성한다는 점이다.
이 구조의 장점은 각 레이어가 독립적으로 테스트 가능하고, 다른 컴포넌트에서 재사용 가능하다는 것이다.
실제로 FocusTrap은 Dialog 외에도 Popover에서, Portal은 Menu, Listbox, Dialog 등 여러 컴포넌트에서 재사용되고 있다.
맺음말
Radix UI와 Headless UI의 소스코드를 읽으면서, 잘 설계된 라이브러리들이 같은 문제를 어떤 패턴으로 풀고 있는지 비교해볼 수 있었다.
- Compound Components: Context로 상태를 공유해서 prop drilling 없이 일관된 UI를 구성
- Controlled / Uncontrolled:
useControllableState나useControllable같은 훅 하나로 상태 주도권을 유연하게 처리 - Polymorphic Component:
asprop 또는asChild로 렌더링되는 HTML 요소를 유연하게 변경 - Composition: Wrapper Component, 훅 합성, Provider 합성으로 작은 단위를 조합해서 복잡한 컴포넌트를 구성
두 라이브러리의 구현 방식은 다르지만, 근본적인 설계 철학은 동일했다.
하나의 컴포넌트에 모든 것을 집어넣는 것이 아니라, 관심사를 분리하고, 작은 단위를 조합해서 복잡한 동작을 만든다는 것이다.
앞으로 컴포넌트를 설계할 때 이 패턴들을 의식적으로 적용해봐야겠다.