[FE workshop] Compound Pattern

7/14/2024

개념

Compound pattern

컴파운드 패턴은 드롭다운, 모달과 같이 부모(<select>)-자식(<option>) 컴포넌트 간 상태를 공유하는 UI를 구현할 때 사용할 수 있는 패턴입니다.
 

장점과 단점

여러 개의 하위 컴포넌트, Context를 설정해야 하므로 초기 구현이 복잡하고 재렌더링을 고려해야 하며, 러닝 커브가 있을 수 있다는 단점이 있습니다.
반대로 장점으로는
<Dropdown> <Dropdown.Toggle/> <Dropdown.Menu/> </Dropdown>
이처럼 컴포넌트를 직관적으로 표현할 수 있습니다. 또한 이 패턴 내부에서 상태를 관리하므로, 사용자는 상태 로직을 신경쓰지 않고 컴포넌트를 사용할 수도 있습니다. 더불어 사용자가 원하는 대로 하위 컴포넌트를 조합하고, 순서를 변경할 수 있어 자유롭게 변형하여 사용할 수도 있습니다. 또한 컴포넌트의 렌더링 로직을 사용자에게 위임할 수 있다는 장점도 있습니다.
 

장점 디깅 : 컴파운드 패턴에서의 제어 역전

아래 일반적인 방식으로, Dropdown을 구현했을 경우 1. 구조의 고정 : 컴포넌트 내부에서 버튼, 목록의 구조가 고정되어 있습니다. 항상 버튼이 먼저 오고 목록이 다음에 오죠! 사용자는 이를 변경하고 싶어도 할 수 없습니다. 다른 요소를 추가할 수도 없죠 2. 렌더링 로직 제한 : 목록 항목 <li>의 렌더링 방식 또한 정해져 있습니다. 특정 항목에 다른 스타일, 구조를 적용하기 어렵습니다 3. 상태 관리 캡슐화: isOpen의 상태가 컴포넌트 내부에 캡슐화되어 있습니다. 사용자는 드롭다운의 열림/닫힘을 직접 제어할 수 없습니다 4. 이벤트 처리 제한 : 토글 버튼, 항목 선택에 대한 이벤트 처리가 이미 정의되어 있어 커스터마이즈 할 수 없습니다. 5. 확장성 한계 : 새로운 기능이나, 요소를 추가하려면 컴포넌트 자체를 수정해야 합니다. 항목을 그룹화하거나, 중첩된 드롭다운 등을 구현하기 어렵습니다.
//일반적인 방식 function Dropdown({ items, onSelect }) { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(!isOpen)}>Toggle</button> {isOpen && ( <ul> {items.map(item => ( <li key={item.id} onClick={() => onSelect(item)}> {item.label} </li> ))} </ul> )} </div> ); }
이러한 컴포넌트의 사용자는, 주어진 props (items, onSelect)를 통해서만 상호작용 가능하며, 내부 구조 또는 동작을 변경하기 어렵습니다.
//컴파운드 패턴 function App() { const Dropdown = ({ children, defaultOpen = false }) => { const [isOpen, setIsOpen] = useState(defaultOpen); return ( <DropdownContext.Provider value={{ isOpen, setIsOpen }}> {children} </DropdownContext.Provider> ); }; <Dropdown> <Dropdown.Toggle>{customToggleContent}</Dropdown.Toggle> <Dropdown.Menu> <Dropdown.Item>Regular Item</Dropdown.Item> <CustomItem>Custom Styled Item</CustomItem> <Dropdown.Item> <ComplexContent /> </Dropdown.Item> </Dropdown.Menu> </Dropdown> const Menu = ({ children }) => { const { isOpen } = useDropdownContext(); if (!isOpen) return null; return <div>{children}</div>; };
반대로 컴파운드 패턴으로 구현하면, 드롭다운의 구조나 각 항목의 내용, 스타일, 렌더링 순서를 직접 제어할 수 있습니다. 또한 defaultOpen을 통해 초기 상태를 외부에서 제어할 수 있습니다. 또한 Context를 통해 Dropdown.Menu를 조건부로 렌더링하거나, 필요시 로직을 추가할 수도 있습니다.
완전히 상태 관리를 외부로 노출시키는 것은 아니지만, 상태에 접근하고 조작할 수 있는 더 많은 유연성을 제공한다는 것이죠!
 

구현

import React, { createContext, useState, useContext } from 'react'; // Context 타입 정의 type DropdownContextType = { isOpen: boolean; toggleDropdown: () => void; }; // Context 생성 const DropdownContext = createContext<DropdownContextType | undefined>(undefined); // Dropdown 컴포넌트 const Dropdown: React.FC<{ children: React.ReactNode }> & { Toggle: typeof DropdownToggle; Menu: typeof DropdownMenu; Item: typeof DropdownItem; Group: typeof DropdownGroup; } = ({ children }) => { const [isOpen, setIsOpen] = useState(false); const toggleDropdown = () => setIsOpen(!isOpen); return ( <DropdownContext.Provider value={{ isOpen, toggleDropdown }}> <div className="relative inline-block text-left"> {children} </div> </DropdownContext.Provider> ); }; // Toggle 컴포넌트 const DropdownToggle: React.FC<{ children: React.ReactNode }> = ({ children }) => { const context = useContext(DropdownContext); if (!context) throw new Error('DropdownToggle must be used within a Dropdown'); return ( <button onClick={context.toggleDropdown} className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500" > {children} </button> ); }; // Menu 컴포넌트 const DropdownMenu: React.FC<{ children: React.ReactNode }> = ({ children }) => { const context = useContext(DropdownContext); if (!context) throw new Error('DropdownMenu must be used within a Dropdown'); if (!context.isOpen) return null; return ( <div className="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> {children} </div> ); }; // Item 컴포넌트 const DropdownItem: React.FC<{ children: React.ReactNode; onClick?: () => void }> = ({ children, onClick }) => ( <a href="#" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900" onClick={(e) => { e.preventDefault(); onClick && onClick(); }} > {children} </a> ); // Group 컴포넌트 const DropdownGroup: React.FC<{ children: React.ReactNode; title: string }> = ({ children, title }) => ( <div className="py-1"> <div className="px-4 py-2 text-sm font-semibold text-gray-900">{title}</div> {children} </div> ); // 컴포넌트 조합 Dropdown.Toggle = DropdownToggle; Dropdown.Menu = DropdownMenu; Dropdown.Item = DropdownItem; Dropdown.Group = DropdownGroup; // 사용 예시 수정 const App: React.FC = () => { return ( <div className="p-4"> <Dropdown> <Dropdown.Toggle>Options</Dropdown.Toggle> <Dropdown.Menu> <Dropdown.Group title="Edit Options"> <Dropdown.Item onClick={() => console.log('Edit')}>Edit</Dropdown.Item> <Dropdown.Item onClick={() => console.log('Rename')}>Rename</Dropdown.Item> </Dropdown.Group> <Dropdown.Group title="Danger Zone"> <Dropdown.Item onClick={() => console.log('Delete')}>Delete</Dropdown.Item> </Dropdown.Group> </Dropdown.Menu> </Dropdown> </div> ); }; export default App;
 

마무리

완성된 UI와 코드 살펴보기

※ 한번에 코드를 읽을 수 있도록 App.tsx에 모든 컴포넌트를 넣어두었습니다.
 
 

레퍼런스

※ Anthropic의 AI 어시스턴트 Claude가 제공한 코드를 기반으로 수정되었습니다.
 
©JIYOUNG CHOI, All rights reserved