
개요
디자인 시안이 변경되면 추가되거나 다른 UI지만 같은 데이터 로직을 가진 컴포넌트를 만드는 경우가 발생한다.
이럴 때 매번 새로운 컴포넌트를 만들어야 했던 귀찮은 일이 생긴다.
위와 같은 이유가 발생하는 이유는 해당 컴포넌트에 두 가지 관심사가 존재하는 문제를 잘 분리하지 못했기 때문이다.
컴포넌트를 Headless하게 만들어서 이를 해결할 수 있다.
하나의 컴포넌트에 두 가지 관심사가 존재하는 문제를 Headless하게 만들어 보자.
Headless가 뭘까?
우선 Headless가 무엇일까?
말 그대로 머리(Head)가 없는 것을 의미한다.
보통 Head는 컨텐츠를 보여주는 방법을 의미하고, Body는 컨텐츠 자체를 의미한다.
React에서 Head는 UI 즉, 컨텐츠를 보여주는 방법, 컴포넌트가 어떻게 보여질지를 의미한다.
Body는 데이터 즉, 컨텐츠 - 데이터 그 자체를 의미한다.
그래서 Headless한 React Component는 Head를 날려버린 Body만 남은 즉, 데이터만 남긴 컴포넌트를 의미한다.
남길 부분과 남기지 않을 부분을 크게 나눠보자
남길 부분 즉, Body에 해당하는 부분은 요소 노드의 `value`값, `onChange` Handler, 그리고 그 외에 만약에 필요한 태그 노드 Attribute들이 있을 것이다.
남기지 않을 부분 즉, 날려버려야 하는 Head에 해당하는 부분은 여러 요소 노드가 모여서 사용자에게 어떻게 보여질지를 의미하는 UI에 해당한다.
대표적인 세 가지 패턴이 있다
Headless라는 개념을 잘 녹여낼 수 있는 대표적인 세 가지 패턴이 있다.
1. Compound Component 패턴

Compound는 영어로 '화합물'이라는 뜻을 가지고 있다.
즉, Compound Component라는 것은 컴포넌트의 화합물이라는 뜻이다.
만들어보자.
body에 해당하는 로직을 `Context API`를 사용해서 구성해주면 된다.
`createContext`훅을 사용해서 context를 만들어준다.
이렇게 해주면 컴포넌트 내부에서 공유될 상태=body=데이터를 미리 정의해놓는 셈이다.
그리고 이 context를 주입시킬 `Provider` 즉, 부모 컴포넌트를 정의해준다.
이 부모 컴포넌트 내부의 컴포넌트들은 금방 위에서 얘기한 Body에 해당하는 데이터들을 주입시켜놓은 context를 공유받게 된다. 즉, 부모 컴포넌트 Provider가 data를 사용할 수 있게 해주는 entry point라고 생각해도 좋다.
코드는 아래와 같다.
import { createContext, useContext, useMemo } from 'react';
import { InputProviderProps, InputProviderState } from '../models/models';
const initialState: InputProviderState = {
id: '',
name: '',
value: '',
type: 'text',
onChange: () => null,
};
const InputProviderContext = createContext<InputProviderState>(initialState);
/// /////////////////////////////////////
// case 1: useState를 Provider 내부에서 생성해서 context로 내려주는 방식 ---> 외부에서 setter를 알 필요가 없을 때 사용. 예를 들어 dark 모드 light모드 class 뗐다 붙였다 하는 거만 하면 되기 때문에 setter를 외부에서 바꿀 필요도, 외부에서 알 필요도 없음
// case 2: useState를 Provider Props로 주입시켜서 context로 내려주는 방식 ---> 외부에서 setter를 주입시키고 변화된 state를 뽑아가거나 제출해야 할 때 외부에서 주입
// case 3: Provider 내부에서 외부에서 가져온 전역 store를 value로 주입시켜서 context로 내려주는 방식 ---> 입력할 때는 관심이 없다가 외부에서 값을 제출하거나 뽑아야 할 때만 state에 관심을 갖도록 설계해야 할 때. 제출할 때 전역 store에 접근해서 뽑아야 한다(api 혹은 onSubmitHandler에서 접근).
/// /////////////////////////////////////
const InputProvider = ({ id, name, onChange, type, value, children }: InputProviderProps) => {
const providerValue: InputProviderState = useMemo(
() => ({
id,
name,
value,
type,
onChange,
}),
[id, name, onChange, type, value],
);
return <InputProviderContext.Provider value={providerValue}>{children}</InputProviderContext.Provider>;
};
export const useInputProvider = () => {
const context = useContext(InputProviderContext);
if (context === undefined) throw new Error('useInputProvider must be used within InputProvider component');
return context;
};
export default InputProvider;
마지막에는 부모 컴포넌트의 프로퍼티로 자식 컴포넌트를 등록해서 명시적으로 Provider에 해당하는 부모 컴포넌트와 자식 컴포넌트와의 관계를 표현할 수 있다. 둘 중 한 방법을 택하면 된다.
// 방법 (1) Object.assign 사용
/* index.ts */
const InputWrapper = Object.assign(InputProvider, {
Input,
Label,
});
// 방법 (2) 혹은 닷 노테이션(.)을 활용한 프로퍼티 등록을 사용
InputProvider.Input = CardHeader;
InputProvider.Label = Image;
...
Input과 Label 컴포넌트는 Provider에서 내려준 context를 `useContext`훅을 사용해서 데이터를 소비해준다. `useContext`를 사용했을 때 Provider 내부에 위치해야 하기 때문에 `useInputProvider`라는 검증할 수 있는 훅을 만들었다.
export const useInputProvider = () => {
const context = useContext(InputProviderContext);
if (context === undefined) throw new Error('useInputProvider must be used within InputProvider component');
return context;
};
/* Input.tsx */
import { ComponentPropsWithoutRef } from 'react';
import { useInputProvider } from './context/input-provider';
import { InputProviderState } from './models/models';
interface IInputProps extends Omit<ComponentPropsWithoutRef<'input'>, keyof InputProviderState> {}
const Input = (props: IInputProps) => {
const inputProviderDatas = useInputProvider();
return <input {...props} {...inputProviderDatas} />;
};
export default Input;
/* Label.tsx */
import { ComponentPropsWithoutRef } from 'react';
import { useInputProvider } from './context/input-provider';
interface ILabelProps extends ComponentPropsWithoutRef<'label'> {}
const Label = ({ children, ...rest }: ILabelProps) => {
const { id } = useInputProvider();
return (
<label htmlFor={id} {...rest}>
{children}
</label>
);
};
export default Label;
폴더 구조는 아래와 같이 구성했다.
COMPOUND-DESIGN\SRC\COMPONENTS\UI\INPUT
│ index.ts
│ Input.tsx
│ Label.tsx
│
├─context
│ input-provider.tsx
│
└─models
models.ts
Compound Component의 사용처 보기
이를 가져와서 사용하는 부분을 보도록 하자
보다시피 외부에서는 컴포넌트끼리 어떠한 데이터를 공유하고 있는지 알 수 없다.
하위 컴포넌트를 자유롭게 볼 수 있고 위치를 수정하여 마크업을 수정할 수도 있다.
또한 부모 컴포넌트의 데이터에 의존하고 있다는 것을 모르고 있기 때문에 자유롭게 사용처에서 쓸 수 있다.
import InputWrapper from '@components/ui/input';
import { InputProviderState } from '@components/ui/input/models/models';
type IAgeInputProps = Pick<InputProviderState, 'onChange' | 'value' | 'name'>;
const NameInput = ({ value, onChange, name }: IAgeInputProps) => {
return (
<InputWrapper name={name} onChange={onChange} value={value} id='name' type='text'>
<InputWrapper.Label className='w-full'>이름</InputWrapper.Label>
<InputWrapper.Input className='w-full' />
</InputWrapper>
);
};
export default NameInput;
데이터를 공유하고 있는지 관심을 둘 필요가 없기 때문에 UI와 데이터의 분리를 할 수 있다. 그러면 유연한 확장이 가능해진다. 예를 들어 디자인 시안의 변경에 유동적으로 대처할 수 있게 된다. 아래 예시처럼 말이다.
type IAgeInputProps = Pick<InputProviderState, 'onChange' | 'value' | 'name'>;
const NameInput = ({ value, onChange, name }: IAgeInputProps) => {
return (
<InputWrapper name={name} onChange={onChange} value={value} id='name' type='text'>
<안녕하세요 />
<InputWrapper.Label className='w-full'>이름</InputWrapper.Label>
<InputWrapper.Input className='w-full' />
<반갑습니다 />
<안녕히가세요 />
</InputWrapper>
);
};
export default NameInput;
2. Function as Children Component 패턴

자식에 어떤 것이 들어올지 모른다고 가정하는 패턴이다.
아래 예시에서 InputHeadless 컴포넌트는 데이터 로직만 갖는다.
해당 데이터 로직을 자식 함수(`children`)에 주입한다.
import { useState } from 'react';
type TInputProps = {
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
};
type TProps = {
children: React.ReactNode | React.FC<TInputProps>;
};
const InputHeadless = ({ children }: TProps) => {
const [value, setValue] = useState('');
const handleChangeValue = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
};
if (typeof children !== 'function') return children;
return children({
value,
onChange: handleChangeValue,
});
};
export default InputHeadless;
`InputHeadless`컴포넌트의 사용처에서는 전달되는 데이터와 관계없이 자유롭게 데이터를 사용하며 ui관련 코드를 짤 수 있게 된다.
import InputHeadless from './InputHeadless';
const Consumer = () => {
return (
<div>
<InputHeadless>
{({ onChange, value }) => {
return (
<div className='input-container'>
<label htmlFor='a'>Name</label>
<input type='text' id='a' value={value} onChange={onChange} />
</div>
);
}}
</InputHeadless>
</div>
);
};
export default Consumer;
3. Custom Hook 패턴
가장 쉽게 접해왔던 패턴일 것이다.
혹은 이미 자연스럽게 사용하고 있었을 수도 있다.
export const useInput = <T>(initial: T) => {
const [input, setInput] = useState<T | null>(initial);
const onChange: TOnChange = e => {
const { name, value } = e.target as HTMLInputElement;
setInput(prev => {
if (prev) {
return { ...prev, [name]: value };
}
return null;
});
};
const clearInput = () => {
setInput(null);
};
return [input, onChange, clearInput] as const;
};
커스텀 훅을 만들면 해당 훅을 어디에서나 사용할 수 있으며 해당 훅을 사용하는 ui부분은 자유롭게 코드를 짤 수 있다. 데이터 로직은 계속 똑같기 때문에 어느 ui가 와도 대응할 수 있기 때문이다.