한땀한땀

헤드리스 컴포넌트와 합성 컴포넌트: 학습한 내용 정리 본문

FrontEnd Dev

헤드리스 컴포넌트와 합성 컴포넌트: 학습한 내용 정리

junfromkorea 2025. 3. 18. 22:13

프론트엔드 개발에서 UI 컴포넌트를 설계할 때, 재사용성과 확장성을 고려하는 것이 중요하다는 것을 다시금 깨달았습니다. 이 과정에서 "헤드리스 컴포넌트(Headless Component)"와 "합성 컴포넌트(Compound Component)"라는 개념을 배우게 되었고, 이를 정리해 보려고 합니다. 혹시 잘못된 부분이 있다면 언제든 피드백 부탁드립니다! 😊


헤드리스 컴포넌트 (Headless Component)

개념

헤드리스 컴포넌트는 UI를 직접 렌더링하지 않고, 상태와 로직만을 관리하는 컴포넌트다. UI를 어떻게 렌더링할지는 이를 사용하는 쪽에서 결정하도록 설계되어 있어, 유연성이 높고 재사용성이 뛰어나다.

예제

import { useState, ReactNode } from 'react';

type DropdownProps = {
  children: (isOpen: boolean, toggle: () => void) => ReactNode;
};

const Dropdown = ({ children }: DropdownProps) => {
  const [isOpen, setIsOpen] = useState(false);

  const toggle = () => setIsOpen(!isOpen);

  return <>{children(isOpen, toggle)}</>;
};

// 사용 예시
const MyComponent = () => {
  return (
    <Dropdown>
      {(isOpen, toggle) => (
        <div>
          <button onClick={toggle}>토글</button>
          {isOpen && <div className="dropdown-menu">메뉴 내용</div>}
        </div>
      )}
    </Dropdown>
  );
};

 

이와 같이 설계하면, Dropdown 컴포넌트는 오직 상태만을 관리하고, 사용자는 원하는 방식으로 UI를 구성할 수 있다


합성 컴포넌트 (Compound Component)

개념

합성 컴포넌트는 하나의 컴포넌트를 여러 개의 작은 컴포넌트로 나누어 내부적으로 조합할 수 있도록 설계하는 방식이다. 부모 컴포넌트가 자식 컴포넌트들에게 상태나 컨텍스트를 제공하고, 각각의 자식 컴포넌트는 이 정보를 활용하여 UI를 렌더링한다.

 

React의 Context API를 활용하는 것이 일반적이며, 대표적인 예로 Tabs, Accordion 패턴이 있다.

예제

import { createContext, useContext, useState, ReactNode } from 'react';

const TabsContext = createContext<{ activeTab: string; setActiveTab: (tab: string) => void } | null>(null);

type TabsProps = { children: ReactNode };
const Tabs = ({ children }: TabsProps) => {
  const [activeTab, setActiveTab] = useState('');
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs-container">{children}</div>
    </TabsContext.Provider>
  );
};

type TabProps = { name: string; children: ReactNode };
const Tab = ({ name, children }: TabProps) => {
  const context = useContext(TabsContext);
  if (!context) throw new Error('Tab must be used within a Tabs');

  return context.activeTab === name ? <div className="tab-content">{children}</div> : null;
};

type TabListProps = { children: ReactNode };
const TabList = ({ children }: TabListProps) => {
  return <div className="tab-list">{children}</div>;
};

type TabButtonProps = { name: string; children: ReactNode };
const TabButton = ({ name, children }: TabButtonProps) => {
  const context = useContext(TabsContext);
  if (!context) throw new Error('TabButton must be used within a Tabs');

  return (
    <button className="tab-button" onClick={() => context.setActiveTab(name)}>
      {children}
    </button>
  );
};

// 사용 예시
const MyTabs = () => {
  return (
    <Tabs>
      <TabList>
        <TabButton name="tab1">Tab 1</TabButton>
        <TabButton name="tab2">Tab 2</TabButton>
      </TabList>
      <Tab name="tab1">이것은 Tab 1의 내용입니다.</Tab>
      <Tab name="tab2">이것은 Tab 2의 내용입니다.</Tab>
    </Tabs>
  );
};

 

 

이와 같이 설계하면, Tabs 내부의 구조를 유연하게 조합할 수 있다. TabList, TabButton, Tab이 각각 독립적으로 동작하지만, Tabs를 통해 연결된다.


헤드리스 컴포넌트와 합성 컴포넌트 함께 사용하기

이 두 가지 개념을 조합하여 더 유연한 UI 컴포넌트를 만들 수도 있다. 예를 들어, 헤드리스 상태 관리를 활용한 Accordion을 합성 컴포넌트 방식으로 구현할 수 있습니다.

const useAccordion = () => {
  const [openIndex, setOpenIndex] = useState<number | null>(null);
  const toggle = (index: number) => setOpenIndex(openIndex === index ? null : index);
  return { openIndex, toggle };
};

const AccordionContext = createContext<{ openIndex: number | null; toggle: (index: number) => void } | null>(null);

const Accordion = ({ children }: { children: ReactNode }) => {
  const accordion = useAccordion();
  return <AccordionContext.Provider value={accordion}>{children}</AccordionContext.Provider>;
};

const AccordionItem = ({ index, title, children }: { index: number; title: string; children: ReactNode }) => {
  const context = useContext(AccordionContext);
  if (!context) throw new Error('AccordionItem must be used within an Accordion');

  return (
    <div>
      <button onClick={() => context.toggle(index)}>{title}</button>
      {context.openIndex === index && <div>{children}</div>}
    </div>
  );
};

이렇게 하면 상태는 헤드리스 훅에서 관리하고, UI 구조는 합성 컴포넌트 방식으로 정의할 수 있다.