| name | react-headless-dev |
| description | SEED React Headless component development specialist. Use when developing unstyled, logic-only components in packages/react-headless folder. Focuses on data-driven primitives, custom hooks, and state management without styling concerns. |
| allowed-tools | Read, Write, Edit, MultiEdit, Bash, Glob, Grep |
React Headless Component Developer
Develop unstyled React components following SEED headless architecture patterns.
Purpose
이 스킬은 SEED Design System의 React Headless 컴포넌트를 개발합니다. Headless 컴포넌트는 스타일 없이 순수한 데이터 로직과 상태 관리만 제공하며, @seed-design/react 패키지에서 스타일을 입힐 수 있는 기반을 제공합니다.
When to Use
다음 상황에서 이 스킬을 사용하세요:
- 새 Headless 컴포넌트 생성:
packages/react-headless/폴더에 새로운 컴포넌트 추가 - Headless 로직 리팩토링: 기존 컴포넌트의 비즈니스 로직 개선 또는 분리
- Custom Hook 구현: 컴포넌트의 상태 관리와 이벤트 핸들링 로직 작성
- Primitive 조합: React 기본 요소들을 조합한 컴포지션 패턴 구현
- Data Attributes 정의: 컴포넌트 상태를 표현하는 data attributes 설계
트리거 키워드: "headless component", "unstyled component", "custom hook", "primitive composition", "packages/react-headless"
Architecture Principles
1. Style-Free Logic
원칙: 스타일 관련 로직 없이 순수 컴포넌트 데이터 로직만 제공
// ❌ Bad: 스타일 관련 로직 포함
const Button = () => {
const className = size === 'large' ? 'btn-lg' : 'btn-sm'
return <button className={className} />
}
// ✅ Good: 데이터만 제공, 스타일은 @seed-design/react에서 처리
const Button = () => {
return <button data-size={size} />
}
스타일 관련 컴포넌트 로직 및 옵션은 @seed-design/react 패키지에서 제공합니다.
2. Custom Hook Pattern
원칙: 중요 비즈니스 로직은 커스텀 훅 파일에 작성
// use{Component}.ts
export function useCheckbox(props: UseCheckboxProps) {
const [checked, setChecked] = useState(props.defaultChecked)
const [focused, setFocused] = useState(false)
const handleChange = useCallback(() => {
setChecked(prev => !prev)
props.onChange?.(!checked)
}, [checked, props.onChange])
return {
rootProps: {
'data-checked': checked,
'data-focused': focused,
onClick: handleChange,
},
inputProps: {
type: 'checkbox',
checked,
onChange: handleChange,
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
},
}
}
가이드라인:
- 파일명:
use{Component}.ts(예:useCheckbox.ts,useRadio.ts) - 컴포넌트 복잡도에 따라 여러 개의 커스텀 훅 파일 작성 가능
- 각 hook은 parts별 props를 반환 (rootProps, inputProps, labelProps 등)
- 상태 관리, 이벤트 핸들링, 접근성 로직을 캡슐화
3. Primitive Composition
원칙: 컴포넌트 파일은 커스텀 훅의 parts를 spread하여 조합된 Primitive 컴포넌트들을 내보냄
// {Component}.tsx
import { useCheckbox } from './useCheckbox'
export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
(props, ref) => {
const { rootProps, inputProps } = useCheckbox(props)
return (
<button ref={ref} {...rootProps}>
<input {...inputProps} />
{props.children}
</button>
)
}
)
가이드라인:
- 파일명:
{Component}.tsx(예:Checkbox.tsx,Radio.tsx) - 단순히 커스텀 훅에서 반환된 props를 spread
- DOM 요소 조합 및 children 배치만 담당
- 복잡한 로직은 hook에 위임
4. State-Driven Data Attributes
원칙: Data attributes는 컴포넌트의 상태를 나타내는 데이터 위주로 작성
// ✅ Good: 상태를 나타내는 data attributes
<button
data-checked={checked}
data-disabled={disabled}
data-invalid={invalid}
data-required={required}
data-focused={focused}
/>
// ❌ Bad: 스타일을 위한 computed prop
<button
data-button-color="red"
data-button-size="large"
data-should-have-shadow={true}
/>
일반적인 Data Attributes:
data-checked: 선택 상태 (checkbox, radio, switch)data-disabled: 비활성 상태data-invalid: 유효하지 않은 상태 (form fields)data-required: 필수 입력 (form fields)data-focused: 포커스 상태data-pressed: 눌린 상태 (button)data-selected: 선택된 상태 (list items, tabs)data-expanded: 확장된 상태 (accordion, dropdown)data-loading: 로딩 상태
5. Namespace Pattern (Multi-Part Components)
원칙: Parts가 여러 개인 경우 {Component}.namespace.ts barrel file을 정의하여 내보냄
// Dialog.namespace.ts
export { Dialog as Root } from './Dialog'
export { DialogTrigger as Trigger } from './DialogTrigger'
export { DialogContent as Content } from './DialogContent'
export { DialogHeader as Header } from './DialogHeader'
export { DialogTitle as Title } from './DialogTitle'
export { DialogDescription as Description } from './DialogDescription'
export { DialogFooter as Footer } from './DialogFooter'
export { DialogClose as Close } from './DialogClose'
// index.ts
import * as Dialog from './Dialog.namespace'
export { Dialog }
사용 예시:
import { Dialog } from '@seed-design/react-headless'
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Title</Dialog.Title>
</Dialog.Header>
</Dialog.Content>
</Dialog.Root>
Development Workflow
Step 1: Requirements Analysis
사용자에게 다음 정보를 요청합니다:
필수 정보:
- Component Name: 예)
Checkbox,Radio,Dialog - Component Type:
- Single: 단일 컴포넌트 (예: Checkbox, Switch)
- Multi-Part: 여러 parts로 구성 (예: Dialog, Dropdown)
- State Requirements: 관리할 상태 목록 (checked, open, selected 등)
- Event Handlers: 필요한 이벤트 핸들러 (onChange, onOpen, onClose 등)
선택 정보:
- Data Attributes: 제공할 data attributes 목록
- Accessibility: ARIA attributes 요구사항
- Controlled vs Uncontrolled: 제어 컴포넌트 vs 비제어 컴포넌트
Step 2: Package Structure Setup
Headless 컴포넌트는 packages/react-headless/ 폴더 내에 위치합니다:
packages/react-headless/
├── checkbox/
│ ├── src/
│ │ ├── useCheckbox.ts # Custom hook
│ │ ├── Checkbox.tsx # Component
│ │ └── index.ts # Public exports
│ ├── package.json
│ └── tsconfig.json
├── dialog/
│ ├── src/
│ │ ├── useDialog.ts # Main hook
│ │ ├── Dialog.tsx # Root component
│ │ ├── DialogTrigger.tsx # Trigger part
│ │ ├── DialogContent.tsx # Content part
│ │ ├── Dialog.namespace.ts # Namespace barrel
│ │ └── index.ts
│ ├── package.json
│ └── tsconfig.json
디렉토리 생성:
mkdir -p packages/react-headless/{component-name}/src
Step 3: Implement Custom Hook
Step 3-1: use{Component}.ts 파일 생성
import { useCallback, useState } from 'react'
export interface Use{Component}Props {
defaultValue?: boolean
value?: boolean
disabled?: boolean
onChange?: (value: boolean) => void
}
export interface Use{Component}Return {
// Part별 props 반환
rootProps: {
'data-checked': boolean
'data-disabled': boolean
onClick: () => void
}
inputProps: {
type: 'checkbox'
checked: boolean
disabled: boolean
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
}
export function use{Component}(props: Use{Component}Props): Use{Component}Return {
// 1. State management (controlled vs uncontrolled)
const [internalValue, setInternalValue] = useState(props.defaultValue ?? false)
const checked = props.value ?? internalValue
// 2. Event handlers
const handleChange = useCallback(() => {
if (props.disabled) return
const newValue = !checked
setInternalValue(newValue)
props.onChange?.(newValue)
}, [checked, props.disabled, props.onChange])
// 3. Return parts props
return {
rootProps: {
'data-checked': checked,
'data-disabled': props.disabled ?? false,
onClick: handleChange,
},
inputProps: {
type: 'checkbox',
checked,
disabled: props.disabled ?? false,
onChange: handleChange,
},
}
}
Hook 작성 가이드:
- Controlled & Uncontrolled 모두 지원:
defaultValue+valueprops 제공value가 있으면 controlled, 없으면 uncontrolled
- 이벤트 핸들러 최적화:
useCallback으로 메모이제이션- 의존성 배열 정확히 명시
- 접근성 고려:
- ARIA attributes 포함 (aria-checked, aria-disabled 등)
- 타입 안정성:
- Props와 Return 타입 명확히 정의
- Generic 타입 활용 가능
Step 4: Implement Component
Step 4-1: {Component}.tsx 파일 생성
import { forwardRef } from 'react'
import { use{Component}, Use{Component}Props } from './use{Component}'
export interface {Component}Props extends Use{Component}Props {
children?: React.ReactNode
className?: string
}
export const {Component} = forwardRef<HTMLButtonElement, {Component}Props>(
(props, ref) => {
const { children, className, ...hookProps } = props
const { rootProps, inputProps } = use{Component}(hookProps)
return (
<button
ref={ref}
className={className}
{...rootProps}
>
<input {...inputProps} />
{children}
</button>
)
}
)
{Component}.displayName = '{Component}'
Component 작성 가이드:
- Props Spreading:
- Hook props와 DOM props 분리
- Hook에서 반환된 props를 spread
- Ref Forwarding:
forwardRef사용하여 ref 전달- 적절한 DOM 요소에 ref 연결
- Children Composition:
- children의 위치와 렌더링 방식 고려
- DisplayName:
- 디버깅을 위해 displayName 설정
Step 5: Multi-Part Components (선택)
Parts가 여러 개인 경우:
Step 5-1: 각 Part별 파일 생성
// DialogTrigger.tsx
export const DialogTrigger = forwardRef<HTMLButtonElement, DialogTriggerProps>(
(props, ref) => {
const { triggerProps } = useDialogContext()
return <button ref={ref} {...triggerProps} {...props} />
}
)
Step 5-2: Context 생성 (필요 시)
// DialogContext.tsx
const DialogContext = createContext<UseDialogReturn | null>(null)
export function useDialogContext() {
const context = useContext(DialogContext)
if (!context) throw new Error('Dialog parts must be used within Dialog.Root')
return context
}
Step 5-3: Namespace 파일 생성
// Dialog.namespace.ts
export { Dialog as Root } from './Dialog'
export { DialogTrigger as Trigger } from './DialogTrigger'
export { DialogContent as Content } from './DialogContent'
// ... 다른 parts
Step 6: Public Exports
Step 6-1: index.ts 파일 작성
Single Component:
// index.ts
export { Checkbox } from './Checkbox'
export type { CheckboxProps } from './Checkbox'
export { useCheckbox } from './useCheckbox'
export type { UseCheckboxProps, UseCheckboxReturn } from './useCheckbox'
Multi-Part Component:
// index.ts
import * as Dialog from './Dialog.namespace'
export { Dialog }
export type { DialogProps } from './Dialog'
export type { DialogTriggerProps } from './DialogTrigger'
// ... 다른 types
export { useDialog } from './useDialog'
export type { UseDialogProps, UseDialogReturn } from './useDialog'
Step 7: Package Configuration
Step 7-1: package.json 확인
{
"name": "@seed-design/react-headless-{component-name}",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
Examples
Example 1: Simple Checkbox Component
// useCheckbox.ts
export function useCheckbox(props: UseCheckboxProps) {
const [checked, setChecked] = useState(props.defaultChecked ?? false)
const isChecked = props.checked ?? checked
const handleChange = useCallback(() => {
if (props.disabled) return
const newValue = !isChecked
setChecked(newValue)
props.onChange?.(newValue)
}, [isChecked, props.disabled, props.onChange])
return {
rootProps: {
'data-checked': isChecked,
'data-disabled': props.disabled ?? false,
role: 'checkbox',
'aria-checked': isChecked,
onClick: handleChange,
},
inputProps: {
type: 'checkbox',
checked: isChecked,
disabled: props.disabled,
onChange: handleChange,
},
}
}
// Checkbox.tsx
export const Checkbox = forwardRef<HTMLDivElement, CheckboxProps>(
(props, ref) => {
const { children, ...hookProps } = props
const { rootProps, inputProps } = useCheckbox(hookProps)
return (
<div ref={ref} {...rootProps}>
<input {...inputProps} />
{children}
</div>
)
}
)
Example 2: Multi-Part Dialog Component
// useDialog.ts
export function useDialog(props: UseDialogProps) {
const [open, setOpen] = useState(props.defaultOpen ?? false)
const isOpen = props.open ?? open
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
props.onOpenChange?.(newOpen)
}, [props.onOpenChange])
return {
isOpen,
triggerProps: {
'data-state': isOpen ? 'open' : 'closed',
onClick: () => handleOpenChange(true),
},
contentProps: {
'data-state': isOpen ? 'open' : 'closed',
hidden: !isOpen,
},
closeProps: {
onClick: () => handleOpenChange(false),
},
}
}
// Dialog.tsx
export const Dialog = (props: DialogProps) => {
const dialog = useDialog(props)
return (
<DialogContext.Provider value={dialog}>
{props.children}
</DialogContext.Provider>
)
}
// DialogTrigger.tsx
export const DialogTrigger = forwardRef<HTMLButtonElement, DialogTriggerProps>(
(props, ref) => {
const { triggerProps } = useDialogContext()
return <button ref={ref} {...triggerProps} {...props} />
}
)
// Dialog.namespace.ts
export { Dialog as Root } from './Dialog'
export { DialogTrigger as Trigger } from './DialogTrigger'
export { DialogContent as Content } from './DialogContent'
export { DialogClose as Close } from './DialogClose'
Testing Guidelines
Headless 컴포넌트는 스타일이 없으므로 데이터 로직과 상태 관리를 테스트합니다:
describe('useCheckbox', () => {
it('should toggle checked state', () => {
const { result } = renderHook(() => useCheckbox({}))
expect(result.current.rootProps['data-checked']).toBe(false)
act(() => {
result.current.rootProps.onClick()
})
expect(result.current.rootProps['data-checked']).toBe(true)
})
it('should call onChange callback', () => {
const onChange = vi.fn()
const { result } = renderHook(() => useCheckbox({ onChange }))
act(() => {
result.current.rootProps.onClick()
})
expect(onChange).toHaveBeenCalledWith(true)
})
})
테스트 항목:
- 상태 변화 (checked, open, selected 등)
- 이벤트 핸들러 호출
- Controlled vs Uncontrolled 모드
- Data attributes 정확성
- 접근성 attributes (ARIA)
Checklist
컴포넌트 개발 후 다음 사항을 확인합니다:
- 스타일 관련 로직이 없는가?
- 커스텀 훅이 올바른 parts props를 반환하는가?
- Data attributes가 상태를 정확히 표현하는가?
- Controlled & Uncontrolled 모드를 모두 지원하는가?
- Ref forwarding이 올바르게 구현되었는가?
- Multi-part 컴포넌트의 경우 namespace 파일이 있는가?
- 접근성 attributes (ARIA)가 포함되었는가?
- TypeScript 타입이 정확하게 정의되었는가?
- Public exports (
index.ts)가 올바르게 설정되었는가? - 테스트가 작성되었는가?
Reference
기존 Headless 컴포넌트:
packages/react-headless/폴더의 다른 컴포넌트들 참조- 유사한 컴포넌트의 패턴 활용
외부 라이브러리 참고:
- Radix UI Primitives
- React Aria Components
- Headless UI
Tips
로직 분리:
- 비즈니스 로직은 hook에
- DOM 조합은 컴포넌트에
- 스타일은
@seed-design/react에
상태 관리:
- Controlled와 Uncontrolled 모두 지원
value와defaultValue패턴 사용
접근성 우선:
- ARIA attributes 항상 포함
- 키보드 네비게이션 고려
- 스크린 리더 호환성 확보
타입 안정성:
- Props와 Return 타입 명확히
- Generic 타입 적극 활용
- JSDoc 주석으로 문서화
테스트 작성:
- 상태 변화 테스트
- 이벤트 핸들러 테스트
- 엣지 케이스 커버
Performance:
useCallback,useMemo활용- 불필요한 리렌더링 방지
- 의존성 배열 정확히 관리