보통 드롭다운을 만든다고 하면 어떻게 만들까요?
{dropdownClick === scheduleId && isDropdownOpen && (
<Dropdown className={dropdownContainer}>
<Dropdown.Item
text="수정하기"
onClick={handleEditClick}
rightAddon={<Icon name="pencil" size={16} />}
></Dropdown.Item>
<div className={dropdownDivider}></div>
<Dropdown.Item
text="삭제하기"
onClick={handleDeleteClick}
rightAddon={<Icon name="delete" size={16} />}
></Dropdown.Item>
</Dropdown>
)}
아마 이런식으로 dropdownClick됐을때만 드롭다운이 열리도록 구현을 했을겁니다.
하지만 이런 코드는 중복되는 코드를 늘릴뿐더러 가독성 측면에서도 좋지 못합니다.
드롭다운을 만들때 마다 매번 클릭했는지 상태를 만들어줘야 하다보니 가뜩이나 상태관리할게 많은 UI단이 더 복잡해지겠죠
드롭다운 같은 컴포넌트의 경우 디자인 시스템으로 따로 만드는데 차라리 디자인 시스템한테 상태관리를 할수 있게하면 어떨까?! 라는 생각이 들겁니다.
그래서 나타난 헤드리스 컴포넌트 패턴!
(번역) 헤드리스 컴포넌트: 리액트 UI를 합성하기 위한 패턴
정의를 살펴보면 “헤드리스 컴포넌트 패턴은 계산과 UI 표현을 분리하여, 개발자가 다재다능하고 유지 관리가 가능하며 재사용 가능한 컴포넌트를 구축할 수 있도록 지원합니다.” 라고 하네요.
결론부터 말하자면 사용처에서 드롭다운을 쓸 때 따로 useState로 드롭다운을 열었는지 상태관리를 해주지 않고
<Dropdown>
<Dropdown.Trigger>
<Icon name="more" size={32} />
</Dropdown.Trigger>
<Dropdown.Content className={deleteDropdownContainer}>
<Dropdown.Item
text="삭제하기"
onClick={handleDeleteClick}
rightAddon={<Icon name="delete" size={16} />}
></Dropdown.Item>
</Dropdown.Content>
</Dropdown>
요렇게 드롭다운만 가져다 쓰면되게 만들어볼겁니다! 이렇게 구현하면 개발자는 비즈니스로직과 UI에 좀더 집중할 수 있게 됩니다.
직접구현해보면서 헤드리스 컴포넌트에 꼭 필요한 세가지를 꼽을 수 있었는데 Context,Trigger,Provider 입니다.
드롭다운을 열었는지,닫았는지에 그리고 그 state를 변경시키는 함수를 ContextAPI를 사용해서 전역적으로 관리하고, 실제로 드롭다운을 실행시키는 함수를 Trigger라고 합니다.
- 드롭다운 실행전

- 드롭다운 실행후

<Dropdown>
<Dropdown.Trigger> // 드롭다운을 실행시키는 버튼 (특정버튼을 눌렀을때 드롭다운이 열린다면 그 버튼을 Trigger라한다)
<Icon name="more" size={32} />
</Dropdown.Trigger>
<Dropdown.Content className={deleteDropdownContainer}> // 드롭다운에 들어가는 내용
<Dropdown.Item
text="삭제하기"
onClick={handleDeleteClick}
rightAddon={<Icon name="delete" size={16} />}
></Dropdown.Item>
</Dropdown.Content>
</Dropdown>
직장 옆에 있는 arrow icon이 여기서 Trigger에 해당됩니다.
import { createContext } from '@linker/react';
interface DropdownContext {
isOpen: boolean;
onOpenChange?: () => void;
}
const context = {
isOpen: false,
onOpenChange: undefined,
};
export const [DropdownProvider, useDropdownContext] = createContext<DropdownContext>(
'Dropdown',
context,
);
우선 상태부터 살펴볼까요?
열었는지 상태를 저장하는 isOpen과 이 변수를 변경시키는 onOpenChange()를 함께 저장합니다.
Dropdown.tsx
'use client';
import { HTMLAttributes } from 'react';
import { ReactNode } from 'react';
import { useState } from 'react';
import { dropdownTrigger } from './Dropdown.css';
import DropdownItem from './DropdownItem';
import { DropdownProvider, useDropdownContext } from './context';
interface Props extends HTMLAttributes<HTMLButtonElement> {
children?: ReactNode;
className?: string;
}
const Dropdown = ({ children, className }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<DropdownProvider isOpen={isOpen} onOpenChange={() => setIsOpen((prev) => !prev)}>
<button className={className}>{children}</button>
</DropdownProvider>
);
};
export default Object.assign(Dropdown, {
Item: DropdownItem,
Trigger: DropdownTrigger,
Content: DropdownContent,
});
function DropdownTrigger({ children }: Props) {
const { onOpenChange } = useDropdownContext('Dropdown-Trigger');
return (
<button type="button" onClick={onOpenChange} className={dropdownTrigger}>
{children}
</button>
);
}
function DropdownContent({ children, className }: Props) {
const { isOpen } = useDropdownContext('Dropdow-Trigger');
return <>{isOpen && <button className={className}> {children}</button>}</>;
}
드롭다운 컴포넌트를 보면 상태를 전달해주는 Provider가 있는 Dropdown, Dropdown을 여는 역할을 하는 DropdownTrigger, Dropdown내부에 콘텐트를 저장해주는 DropdownContent가 존재합니다.
여기서 핵심은 Provider에서 open된 상태와 onOpenChange함수의 상태를 가지는데 onOpenChange의 경우 ()⇒setIsOpen((prev)⇒!open)을 통해 Trigger에서 버튼을 누르게 되면 onOpenChange함수가 호출돼서 isOpen값이 바뀐다는 겁니다!
사용처에서는 아래와 같이 쓸 수 있습니다.
<Dropdown>
<Dropdown.Trigger>
<Icon name="more" size={32} />
</Dropdown.Trigger>
<Dropdown.Content className={deleteDropdownContainer}>
<Dropdown.Item
text="삭제하기"
onClick={handleDeleteClick}
rightAddon={<Icon name="delete" size={16} />}
></Dropdown.Item>
</Dropdown.Content>
</Dropdown>