합성 컴포넌트로 재사용성 높이기

글을 시작하며
취업 준비 과정에서 과제 전형으로 툴팁 컴포넌트를 구현하는 과제를 받았습니다.
이 과제를 수행하면서 배운 점과 느낌 점을 공유하려고 합니다.
Radix UI
이 과제를 받았을 때, 떠오른 UI 라이브러리가 있었습니다.
바로 전 팀 프로젝트에서 사용해 본 Radix UI입니다.
Radix UI는 Compound Component 패턴과 Headless UI 방식을 사용하는 컴포넌트 라이브러리입니다.
합성 컴포넌트 패턴이란?
합성 컴포넌트 패턴(Compound Component Pattern)은 React 컴포넌트의 구조와 기능을 더 유연하고 재사용 할 수 있게 만드는 설계 패턴입니다.
주요 특징은 다음과 같습니다.
- 여러 컴포넌트를 함께 그룹화하여, 더 복잡한 UI 컴포넌트를 구성하는 패턴
- 상태관리 로직을 외부로부터 숨긴다.
- 하위 컴포넌트들은 제한된 api(상태와 메서드)를 사용해 상태 변경이 이루어진다.
- 모든 상태는 상위 컴포넌트에서 관리하고, provider로 상태를 관리할 수 있는 api를 하위에 준다. (context api)
따라서, 합성 컴포넌트 패턴을 사용하면 UI 구성 요소를 더 유연하고 관리하기 쉽게 만들 수 있습니다.
Headless UI란?
Headless UI는 UI 컴포넌트 라이브러리의 일종으로, 스타일링이나 특정 디자인을 포함하지 않고 컴포넌트의 기능적 로직만을 제공합니다.
따라서, 기본적인 구조와 동작만을 정의하고, 스타일링은 사용자가 자유롭게 커스터마이징할 수 있도록 합니다.
따라서, Headless UI를 사용하면 기능적 요구사항을 충족하면서 디자인의 자유도를 극대화할 수 있어, 다양한 프로젝트에서 매우 유용하게 활용될 수 있습니다.
나만의 툴팁 컴포넌트 구현
1. 아키텍처
2. 사용 예시
// App.tsx
import { Tooltip } from "./components/Tooltip/Tooltip";
import styles from "./App.module.css";
<Tooltip.Root
direction="topLeft"
leaveDelay={1000}
hoverVisible
arrowColor="gray"
offset={5}
>
<Tooltip.Trigger>
<div className={styles.trigger}>topLeft, leaveDelay 1s, hoverVisible</div>
</Tooltip.Trigger>
<Tooltip.Content className={styles.content}>
<Tooltip.Arrow className={styles.arrow} />
<span>Are you sure to delete this task?</span>
<button
onClick={handleClick}
className={styles.confirmButton}
>
yes
</button>
</Tooltip.Content>
</Tooltip.Root>;
3. API Reference
1. Root
툴팁의 모든 부분이 들어있습니다.
Prop | Type | Default | Explanation |
---|---|---|---|
direction | "top" | "left" | "right" | "bottom" | "topLeft" | "topRight" | "bottomLeft" | "bottomRight" | "leftTop" | "leftBottom" | "rightTop" | "rightBottom" | - | Trigger에서 툴팁이 렌더링 될 방향 |
children | React.ReactNode | - | - |
enterDelay | number | 0 | 툴팁이 hover 될 때, delay |
leaveDelay | number | 0 | 툴팁이 hover 되고, 떠날 때 delay |
hoverVisible | boolean | false | 툴팁 Content를 hover 할 때, 사라지지 않음 |
forceInvisible | boolean | false | 툴팁 Content를 보이지 않게 함 |
arrowColor | string | "black" | 툴팁 Arrow 색상 |
margin | number | - | 툴팁 Arrow와 Trigger 사이의 gap |
offset | number | 5 | 툴팁 Arrow 크기 |
2. Trigger
툴팁을 전환하는 버튼입니다.
기본적으로 Tooltip.Content는Trigger 대해 위치를 지정합니다.
3. Content
툴팁이 열렸을 때 보여지는 컴포넌트입니다.
4. Arrow
Tooltip Content와 함께 렌더링할 선택적 화살표 요소입니다.
Tooltip Content 내부에서 사용합니다.
4. 트러블 슈팅
조건부 렌더링 시에 Ref가 제대로 작동하지 않는 현상
TooltipContent는 isTooltipVisible이 조건부로 렌더링 됩니다. (true 시에)
- 콜백 Ref 없이 useRef를 사용
TooltipContent 컴포넌트에서 ref가 isTooltipViisble 상태에 의해 조건부로 렌더링 됩니다.
따라서 초기에는 null일 수밖에 없습니다.
Tooltip 컴포넌트에서의 useEffect는 컴포넌트가 마운트될 때 한 번 실행됩니다.
하지만 TooltipContent 컴포넌트의 div가 렌더링 되기 전에 이 useEffect가 실행되므로, ref는 여전히 null입니다.
따라서, 원하는 대로 TooltipContent가 렌더링 되지 않습니다.
- 콜백 Ref를 사용
isTooltipVisible 상태가 false에서 true로 변경될 때, div가 렌더링되면서 콜백 ref가 실행됩니다.
콜백 ref 내부에서는 setState를 사용하여 ref가 div 요소를 가리키도록 상태를 업데이트합니다.
이 상태 변경은 Tooltip 컴포넌트 전체를 리렌더링하게 만듭니다.
useEffect는 다시 실행되며, 이번에는 적절한 state 값이 적용되어 TooltipContent가 제대로 렌더링 됩니다.
export const Tooltip = ({
// 생략..
}: PropsWithChildren<TooltipProps>) => {
const [isTooltipVisible, setIsTooltipVisible] = useState<boolean>(false);
// state 값을 하나 둔다.
const [tooltipEl, setTooltipEl] = useState<HTMLDivElement | null>(null);
// 콜백 ref 사용, ref 값을 업데이트
const tooltipRefCallback: RefCallback<HTMLDivElement> = useCallback(
(node) => setTooltipEl(node),
[],
);
useEffect(() => {
if (!tooltipEl) {
return;
}
// 생략..
const tooltipRect = tooltipEl.getBoundingClientRect();
setPosition(
getTooltipPosition(direction, triggerRect, tooltipRect, margin),
);
setArrowPosition(getArrowPosition(direction, tooltipRect, offset));
}, [tooltipEl]);
return (
<TooltipContextProvider
value={{
isTooltipVisible,
tooltipRefCallback,
// 생략..
}}
>
{children}
</TooltipContextProvider>
);
};
// 생략..
Tooltip.Content = TooltipContent;
export const TooltipContent = ({
children,
style,
...props
}: ComponentProps<'div'>) => {
const {
isTooltipVisible,
tooltipCallbackRef,
} = useTooltipContext();
return (
<>
{isTooltipVisible &&
createPortal(
<div
{...props}
ref={tooltipCallbackRef}
// 생략
>
{children}
</div>,
document.body
)}
</>
);
};
참고 자료
리액트 공식 문서
[번역] callback refs 사용으로 useEffect 방지하기
글을 마치며
직접 합성 컴포넌트 패턴과 Headless UI로 툴팁 컴포넌트를 구현해 보면서,
실제 사용하고 있던 라이브러리들이 어떻게 구현되었는지 알게 되었습니다.
여기서 나아가, 나만의 디자인 시스템을 만들고 싶었습니다.
툴팁 뿐만 아니라, 모달, 아코디언 등등 다양한 공통 컴포넌트를 만들고 있습니다.
기존 프로젝트에 제가 만든 디자인 시스템을 사용하고 싶어,
직접 만든 디자인 시스템을 npm에 배포하고, 웹으로 문서를 작성하려고 합니다.
배운점
- 합성 컴포넌트 패턴이란
- Headless UI란
- 공통 컴포넌트를 구현시 고려해야할 점
마무리
지금까지 합성 컴포넌트 패턴으로 나만의 디자인 시스템을 구현한 내용입니다.
읽어주셔서 감사합니다.