Headless Component: 로직과 UI의 완전한 분리

Headless Component는 로직과 상태 관리는 제공하지만, UI는 전혀 렌더링하지 않는 컴포넌트(또는 훅)입니다. “머리(head, 즉 시각적 표현)가 없다”는 뜻입니다.

Radix UI, Headless UI, React Aria 같은 라이브러리들이 이 패턴을 사용합니다. 복잡한 접근성(a11y) 로직과 상태 관리는 라이브러리가 담당하고, 스타일링은 개발자가 완전히 자유롭게 합니다.

기존 방식의 문제

스타일이 내장된 컴포넌트 라이브러리를 쓰면, 디자인을 바꾸기 어렵습니다.

// 스타일이 내장된 라이브러리
import { Modal } from 'some-ui-library';
 
// 클래스 이름으로만 오버라이드 가능, 한계가 있음
<Modal className="my-modal"> ... </Modal>

디자인을 완전히 바꾸려면 라이브러리를 교체해야 합니다.

Headless 방식

// Radix UI (Headless): 스타일 없음, 로직만 제공
import * as Dialog from '@radix-ui/react-dialog';
 
function MyModal({ isOpen, onClose, children }) {
  return (
    <Dialog.Root open={isOpen} onOpenChange={onClose}>
      <Dialog.Portal>
        <Dialog.Overlay className="my-overlay" /> {/* 내 스타일 적용 */}
        <Dialog.Content className="my-modal">   {/* 내 스타일 적용 */}
          {children}
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

키보드 네비게이션, 포커스 트랩, 접근성 속성(aria-*) 등은 Radix가 처리하고, 시각적 표현은 100% 개발자가 결정합니다.

직접 만들기: Headless 훅 패턴

커스텀 훅으로 Headless 패턴을 구현할 수 있습니다.

// useDropdown.js: 드롭다운 로직만, UI 없음
function useDropdown(items) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState(null);
  const [highlightedIndex, setHighlightedIndex] = useState(0);
  const containerRef = useRef(null);
 
  // 바깥 클릭 시 닫기
  useEffect(() => {
    function handleClickOutside(e) {
      if (containerRef.current && !containerRef.current.contains(e.target)) {
        setIsOpen(false);
      }
    }
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);
 
  // 키보드 네비게이션
  function handleKeyDown(e) {
    if (!isOpen) return;
    switch (e.key) {
      case 'ArrowDown':
        setHighlightedIndex(i => Math.min(i + 1, items.length - 1));
        break;
      case 'ArrowUp':
        setHighlightedIndex(i => Math.max(i - 1, 0));
        break;
      case 'Enter':
        setSelectedItem(items[highlightedIndex]);
        setIsOpen(false);
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  }
 
  // prop getter: 필요한 속성들을 한 번에 제공
  function getToggleProps() {
    return {
      onClick: () => setIsOpen(prev => !prev),
      onKeyDown: handleKeyDown,
      'aria-expanded': isOpen,
      'aria-haspopup': 'listbox',
    };
  }
 
  function getItemProps(index) {
    return {
      onClick: () => {
        setSelectedItem(items[index]);
        setIsOpen(false);
      },
      'aria-selected': selectedItem === items[index],
      role: 'option',
    };
  }
 
  return {
    isOpen,
    selectedItem,
    highlightedIndex,
    containerRef,
    getToggleProps,
    getItemProps,
  };
}
// 사용 1: 기본 드롭다운
function BasicDropdown({ items }) {
  const { isOpen, selectedItem, highlightedIndex, containerRef, getToggleProps, getItemProps } =
    useDropdown(items);
 
  return (
    <div ref={containerRef}>
      <button {...getToggleProps()}>
        {selectedItem ?? '선택하세요'} ▼
      </button>
      {isOpen && (
        <ul role="listbox">
          {items.map((item, i) => (
            <li
              key={item}
              style={{ background: highlightedIndex === i ? '#eee' : '' }}
              {...getItemProps(i)}
            >
              {item}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
 
// 사용 2: 완전히 다른 스타일의 드롭다운 (같은 훅 재사용)
function FancyDropdown({ items }) {
  const { isOpen, selectedItem, getToggleProps, getItemProps } =
    useDropdown(items);
 
  return (
    <div className="fancy-select">
      <div className="fancy-trigger" {...getToggleProps()}>
        <span>{selectedItem ?? '항목 선택'}</span>
        <ChevronIcon />
      </div>
      {isOpen && (
        <div className="fancy-options">
          {items.map((item, i) => (
            <div className="fancy-option" key={item} {...getItemProps(i)}>
              {item}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

같은 useDropdown 훅으로 완전히 다른 두 가지 UI를 만들었습니다.

언제 적합한가?

상황권장
특정 디자인에 묶이고 싶지 않을 때Headless
복잡한 접근성 로직이 필요할 때Headless
빠른 프로토타이핑, 일관된 디자인스타일 내장 라이브러리
디자인 커스터마이징이 거의 없을 때스타일 내장 라이브러리

정리

  • Headless Component는 상태와 로직만 제공하고 렌더링하지 않습니다
  • 같은 로직으로 완전히 다른 UI를 만들 수 있습니다
  • 접근성 구현을 라이브러리에 위임하면서 디자인 자유도를 얻습니다
  • 커스텀 훅 형태로 직접 구현하거나, Radix UI / Headless UI 같은 라이브러리를 사용합니다