현재 진행중인 사이드 프로젝트에서는 디자이너분들이 피그마에 작성해주신 디자인 시스템이 있습니다. 그리고 프론트엔드 개발자는 디자인시스템 스펙에 맞게 컴포넌트를 설계합니다.
저는 Dropdown 컴포넌트의 확장성 재사용성을 고려하여 컴포넌트를 설계하도록 노력하였습니다.
드랍다운 컴포넌트의 조건은 다음과 같습니다.
1. 특정 버튼(트리거)를 클릭하면 메뉴리스트박스가 아래로 내려오면서 렌더링 되어야한다.
2. 메뉴아이템의 특정 이벤트가 있다면 해당 이벤트가 수행되어야한다.
3. 어디서든 쉽게 사용가능 하도록 구현해야한다.
4. 커스텀 및 확장이 쉬워야 한다.
저는 이조건들에 맞는 디자인 패턴인 Compound pattern 을 사용하여 Dropdown 컴포넌트를 설계해보았습니다.
1.드랍다운 컴포넌트의 상태를 제어하기위해 Context API 사용
import React from 'react'
interface DropdownContextType {
isOpen: boolean;
handleDropdown: () => void;
closeDropdown: () => void;
}
const DropdownContext = createContext<DropdownContextType>({
isOpen: false,
handleDropdown: () => {},
closeDropdown: () => {},
});
export const useDropdownContext = () => {
const dropDownState = useContext(DropdownContext);
if (!dropDownState) {
throw new Error(
'Dropdown의 하위컴포넌트를 사용하려면 Dropdown으로 감싸져야합니다. ',
);
}
return dropDownState;
};
export default function Dropdown({ children }: {children:React.ReactNode}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const handleDropdown = () => {
setIsOpen(!isOpen);
};
const providerValue = useMemo(
() => ({ isOpen, handleDropdown, closeDropdown: () => setIsOpen(false) }),
[isOpen],
);
return (
<DropdownContext.Provider value={providerValue}>
<div className="relative">
{children}
</div>
</DropdownContext.Provider>
);
}
드랍다운 메뉴의 렌더링 상태여부를 해당 컴포넌트를 사용하는곳에서 useState를 사용하는것이 아닌 드랍다운 내부에서 context api를 사용하여 렌더링하도록 설계하였습니다.
2. 드랍다운 랜더링 상태여부를 바꾸는 Trigger 컴포넌트
import { useDropdown } from './Dropdown';
export default function Trigger({ children }: { children: React.ReactNode }) {
const { handleDropdown } = useDropdown();
return (
<button type="button" onClick={handleDropdown}>
{children}
</button>
);
}
드랍다운 메뉴를 랜더링 할지 말지를 정하는 트리거 버튼 컴포넌트입니다. 컨텍스트에서 넘겨받은 함수로 클릭시 드랍다운 메뉴를 랜더링합니다.
3.드랍다운의 메뉴 컴포넌트
import { useDropdown } from './Dropdown';
interface DropdownMenuType {
children: React.ReactNode;
direction?: 'left' | 'right';
width?: string;
}
export default function Menu({
children,
direction = 'right',
width = 'w-48',
}: DropdownMenuType) {
const { isOpen } = useDropdown();
if (!isOpen) return null;
const directionClass = direction === 'left' ? 'left-0' : 'right-0';
return (
<ul
className={`flex flex-col absolute ${directionClass} ${width} animate-dropdown gap-2 p-4 bg-white shadow-dropdown rounded-[10px] `}
>
{children}
</ul>
);
}
드랍다운의 메뉴컴포넌트는 Context로부터 isOpen 상태를받아 랜더링 할지 말지를 결정하게 됩니다.
4.드랍다운 메뉴 아이템
import { useDropdown } from './Dropdown';
import React from 'react'
export default function Item({ children }:{children:React.ReactNode}) {
const { closeDropdown } = useDropdown();
return (
<li onClick={closeDropdown} className="w-full rounded-[10px] hover:bg-primary-50 active:bg-primary-100" >
{children}
</li>
);
}
드랍다운 아이템컴포넌트입니다. children을 prop으로 받기 때문에 어떠한 컴포넌트든 다 들어갈수있으며 클릭시 드랍다운은 닫히게 됩니다.
5. 분산된 컴포넌트를 묶어주기.
import Item from './Item';
import Menu from './Menu';
import Trigger from './Trigger';
export default function Dropdown({ children }: DropDownProps) {
... 이전코드 생략
}
Dropdown.Trigger = Trigger;
Dropdown.Menu = Menu;
Dropdown.Item = Item;
처음 Context api를 사용했던 곳에서 분산되었던 트리거,메뉴,아이템을 Dropdown의 속성으로 묶어줍니다. 이는 사용하는곳에서 Dropdown 만 import해도 트리거,메뉴,아이템을 사용할 수 있게 됩니다.
이제 사용해보자!
import Dropdown from '@/components/ui/Dropdown/Dropdown';
<Dropdown>
<Dropdown.Trigger>
<DotsIcon className="w-6 h-6 tablet:w-7 tablet:h-7" />
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item>
<Link href="메인으로">홈으로 이동</Link>
</Dropdown.Item>
<Dropdown.Item>
<button type="button" onClick={() => alert('알러트')}>
알러트
</button>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
조건에 맞게 잘 동작하고있는 모습이다.
구현하며 느낀점
Compound pattern으로 설계하며 느낀점은 확장성 이다. 만약 드랍다운 컴포넌트를 따로 분리하지않고 하나의 컴포넌트로 만들어 props 로만 랜더링했다면 어땟을까? 아마 드랍다운 아이템에 아이콘을 추가해야하는 상황이 생긴다면 icon 여부를 판단하는 props가 추가되고 이와같은일이 반복된다면 아마 수많은 props들을 관리해야 할것이다.
하지만 compound pattern은 각 역할을 수행하는 컴포넌트들을 잘개 쪼갰기 때문에 변화에도 유연하며 사용하기 편리하다.
이 패턴을 공부하면서 별개로 HOC컴포넌트 즉 고차컴포넌트로 설계하여 확장하는 법도 알게되었는데 이부분도 공부해야할것같다.
'React' 카테고리의 다른 글
React createPortal을 사용하여 Modal 구현 (0) | 2023.10.10 |
---|---|
React-Query - onClick 이벤트로 useQuery data fetching (0) | 2023.10.09 |
React - 리액트 해시태그 구현 (0) | 2023.09.23 |
리액트 라이브러리없이 달력(Calender) 구현 with "date-fns" (0) | 2023.09.12 |
React - useCallback, useMemo 에 대하여 (0) | 2023.07.04 |