이번 4주차에는 Select를 헤드리스(headless) 로 직접 라이브러리 없이, 네이티브 <select>도 사용하지않고 만들어보았습니다!
headless 컴포넌트란?
headless 컴포넌트는 동작만, 진짜 동작만 되는 껍데기 컴포넌트입니다. 대표적으로 Radix, Base UI 라는 라이브러리가 존재합니다.
처음엔 스타일 없이 기능만 주는 컴포넌트 껍데기만 headless 인 줄 알았습니다.
이번에 downshift의 useSelect 패턴을 참고하면서 커스텀 훅 형태로 동작만 제공하는 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에서 웹 접근성과 포커스 관리, 그리고 키보드 상호작용까지 챙기는 걸 보고, 직접 만들 때 고려할 게 얼마나 많은지 체감했습니다.


