이번 4주차에는 Select헤드리스(headless) 로 직접 라이브러리 없이, 네이티브 <select>도 사용하지않고 만들어보았습니다!

headless 컴포넌트란?

머리없는_아바타이미지

headless 컴포넌트는 동작만, 진짜 동작만 되는 껍데기 컴포넌트입니다. 대표적으로 Radix, Base UI 라는 라이브러리가 존재합니다.

처음엔 스타일 없이 기능만 주는 컴포넌트 껍데기만 headless 인 줄 알았습니다.

이번에 downshiftuseSelect 패턴을 참고하면서 커스텀 훅 형태로 동작만 제공하는 headless도 있다고 알게 되었습니다.

설계의 핵심: 동작만 노출하고 생김새는 넘긴다

헤드리스를 이렇게 정의했습니다 — 로직은 상태만 노출하고, 그 상태로 무엇을 그릴지는 사용처가 정한다. 그래서 훅은 마크업을 전혀 반환하지 않고, 사용처가 스타일을 판단할 재료(각 옵션의 selected / highlighted / disabled 등.. ) 만 돌려줍니다.

downShift 라이브러리의 useSelect 훅을 참고하여 작성했어요.

downshift는 select의 동작을 여러 action으로 정의하고 action에 맞는 상태를 업데이트하여 내보내는 reducer 함수를 사용합니다.

상태가 단방향으로 흐릅니다.

export default function downshiftSelectReducer<Item>(
  state: UseSelectState<Item>,
  action: UseSelectReducerAction<Item> & {
    props: UseSelectMergedProps<Item>
  },
): UseSelectState<Item> {
  const { type, props } = action
  let changes: Partial<UseSelectState<Item>>
 
  switch (type) {
    case stateChangeTypes.ItemClick:
      changes = {
        isOpen: getDefaultValue(props.defaultIsOpen, dropdownDefaultStateValues.isOpen),
        highlightedIndex: getDefaultHighlightedIndex(
          props.items,
          props.isItemDisabled,
          props.defaultHighlightedIndex,
        ),
        selectedItem: props.items[action.index],
      }
 
      break
 
    //...
  }
}

그리고 해당 reducer를 useSelect 훅에서 사용하며, 사용처에서는 useSelect를 호출하여 아래와 같이 태그에 그대로 spread 합니다.

function DropdownSelect() {
  const {
    selectedItem,
    getLabelProps,
  } = useSelect({items: colors})
 
  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        width: 'fit-content',
        justifyContent: 'center',
        marginTop: 100,
        alignSelf: 'center',
      }}
    >
      <label
        style={{
          fontWeight: 'bolder',
          color: selectedItem ? selectedItem : 'black',
        }}
        {...getLabelProps()}
      >
    </div>
  )
}

이로써 사용처에서는 select의 로직을 굳이 신경쓰지 않아도 단순히 조합만 하면되고, 비지니스 로직 작성에 좀 더 충실할 수 있게 되었습니다.

직접 구현해보기

동일한 느낌으로 reducer를 먼저 정의해주었습니다.

import type { BaseSelectOption, SelectAction, SelectState } from "./types"
 
/**
 * select 의 열림과 닫힘, option의 하이라이트 ( 마우스 올림이나 키보드 방향키 포커스 ), option 선택에 대한 액션을 커스텀훅 레이어에서 dispatch 받아,
 * 각 액션별로 상태를 적절히 설정 후 return 하는 reducer 순수함수.
 */
export const selectReducer = <T extends BaseSelectOption>(
  state: SelectState<T>,
  action: SelectAction<T>,
): SelectState<T> => {
  switch (action.type) {
    case "SELECT.OPEN":
      return { ...state, isOpen: true, highlightedIndex: action.highlightedIndex }
    case "SELECT.CLOSE":
      return { ...state, isOpen: false, highlightedIndex: -1 }
    case "OPTION.HIGHLIGHT":
      return { ...state, highlightedIndex: action.index }
    case "OPTION.SELECT":
      return { ...state, selected: action.option, isOpen: false, highlightedIndex: -1 }
    default:
      return state
  }
}

그리고 useSelect 훅에서 useReducer로 만든 reducer를 사용하여 상태를 관리하고, 해당 상태나 메서드를 사용처에서 곧바로 사용할 수 있도록 내보내주었습니다.

export const useSelect = <T extends BaseSelectOption>({
  options,
  defaultValue,
  onChange,
}: UseSelectProps<T>) => {
  const [state, dispatch] = useReducer(selectReducer<T>, {
    isOpen: false,
    selected: defaultValue ?? null,
    highlightedIndex: -1,
  } satisfies SelectState<T>)
 
  // open/close/highlight/selectOption ... 는 dispatch 로 이어지고,
  // 품절 건너뛰기·열 때 하이라이트 위치 계산·키보드(↑↓ Enter Esc)는 핸들러가 담당합니다.
 
  /** 트리거 버튼에 스프레드 — 열기/닫기 + 키보드 전체를 담당. */
  const getToggleProps = () => ({ type: "button" as const, onClick: toggle, onKeyDown })
 
  /** 옵션에 스프레드 — 클릭 선택 + hover 하이라이트. */
  const getOptionProps = (option: T, index: number) => ({
    onClick: () => selectOption(option),
    onMouseEnter: () => {
      if (!option.disabled) highlight(index)
    },
  })
 
  const getOptionState = (option: T, index: number) => ({
    isSelected: state.selected?.value === option.value,
    isHighlighted: state.highlightedIndex === index,
    isDisabled: option.disabled ?? false,
  })
 
  return {
    isOpen: state.isOpen,
    selected: state.selected,
    highlightedIndex: state.highlightedIndex,
    close,
    getToggleProps,
    getOptionProps,
    getOptionState,
  }
}

덕분에 같은 useSelect 훅 하나로 서로 다른 UI 3종을 렌더할 수 있었습니다. 로직은 손대지 않고 옵션 내부 마크업만 갈아끼웠습니다.

텍스트 옵션사이즈 옵션썸네일 옵션
텍스트 옵션사이즈 옵션썸네일 옵션

value는 문자열이 아니라 옵션 객체 전체

가장 신경 쓴 결정입니다. onChange"L" 같은 문자열을 돌려주면, 사용처는 가격/배송을 계산하려고 원본 옵션을 id로 다시 찾아야 합니다. 그래서 value를 옵션 객체 통째로 노출했습니다.

export type UseSelectProps<T extends BaseSelectOption> = {
  options: T[]
  defaultValue?: T
  placeholder?: string
  onChange?: (option: T) => void
}

인프라 레이어는 따로 두자: @floating-ui/react

드롭다운이 뷰포트를 벗어날 때의 flip / shift 좌표 계산은 또 다른 상호작용의 문제 입니다.

useSelect가 담당하는 열림/닫힘/하이라이트/선택 과는 결이 다릅니다. 앞의 로직은 이 컴포넌트가 어떻게 동작하는가에 대한 문제라 직접 설계해야 할 패턴이지만, 옵션 박스를 화면 어디에 띄울까는 어떤 드롭다운이든 똑같이 필요한 인프라입니다. 그래서 두 관심사를 다른 레이어로 분리했습니다.

  • 순수 코어 (useSelect) — 상태 전이만 아는 레이어입니다. DOM도, 좌표도, 뷰포트도 모릅니다.
  • 합성 레이어 (useSelectFloating) — 순수 코어를 그대로 감싸고, 위치 계산만 @floating-ui/react로 얹습니다.
export const useSelectFloating = <T extends BaseSelectOption>(props: UseSelectProps<T>) => {
  const select = useSelect(props)
 
  const { refs, floatingStyles, placement } = useFloating({
    open: select.isOpen,
    placement: "bottom-start",
    middleware: [offset(0), flip(), shift({ padding: 8 })],
    whileElementsMounted: autoUpdate,
  })
 
  // 바깥 클릭 닫기 — 위치 계산은 도구지만, "언제 닫힐까"는 상호작용 패턴이라 직접
  useEffect(() => {
    // ...pointerdown 으로 trigger/menu 바깥이면 select.close()
  }, [select.isOpen])
 
  return { ...select, refs, floatingStyles, placement }
}

이렇게 나누면 useSelect는 여전히 DOM 없이 단독으로 재사용할 수 있고, floating-ui는 필요할 때만 얹는 인프라가 됩니다.

회고

  • 헤드리스로직을 상태로 노출하고 생김새를 넘기는 패턴이며, 그 트레이드오프로 상태 관리가 복잡해지고 고려해야할 것이 많았습니다. reducer 단방향 업데이트가 그 복잡도를 잡아준다는 것을 Downshift에서 배웠습니다.
  • 지금까지 동작은 하지만 스타일 없는 컴포넌트만 헤드리스인 줄 알았는데, 커스텀 훅으로 동작만 제공하는 것도 헤드리스라는 걸 알았습니다.
  • 헤드리스를 직접 만드는 게 생각보다 어려웠습니다. Radix 같은 라이브러리가 core에서 웹 접근성과 포커스 관리, 그리고 키보드 상호작용까지 챙기는 걸 보고, 직접 만들 때 고려할 게 얼마나 많은지 체감했습니다.