공통 컴포넌트를 구현하거나 사용하면서 부딪혔던 불편한 점들
컴포넌트 스타일을 prop 으로 받는 경우
interface SomeComponentProps {
textSize: 'sm' | 'md' | 'lg';
color: string;
border: boolean;
}
const SomeComponentProps = ...
🔥 문제점
자식들이 부모 prop에 묶여있는 경우
...
return (
<Tab
items = ['tab1', 'tab2', 'tab3']
contents = [<div>tab1 contents</div>, <div>tab2 contents</div>, ...]
/>
)
🔥 문제점
결국 공통
이기 때문에 여러 사람이 여러 상황에서 사용을 하게 되고, 수정해야 하는 일이 계속 생긴다.
Tab 구현 과정
- 탭 컴포넌트 완성!
- 스타일 덮어 씌우는 과정에서 버그가 발생해서 수정
- 탭의 index를 컴포넌트 밖에서 알아야 한다는 요구사항이 발생해서 수정
- 탭이 변할 때 마다 콜백을 호출하고 싶다는 요구사항이 발생해서 수정
- …
Accordion 구현 과정
- 아코디언 컴포넌트 완성!
- 전체 App Container 너비 값이 변하면서, 너비를 다른 방법으로 부여해야 해서 수정
- 아코디언이 열릴 때, 하나만 열리면 좋겠다는 요구 사항이 발생해서 수정
(옵션으로 추가 = prop+1
)- 검색할 때 해당된 아코디언이 자동으로 열리면 좋겠다는 요구 사항이 발생해서 수정
(이때도 prop+1
)- 아코디언이 열리는 클릭 이벤트가 버튼이 아닌, 전체에 부여되었으면 좋겠다는 요구 사항이 발생해서 수정
(prop + 1
)- …
⇒ 이런 식으로, 여러가지 요구 사항을 반영하려다 보니 덕지덕지 컴포넌트가 되었음!
interface AccordionProps {
className?: string;
initialOpen?: boolean;
children: React.ReactNode;
}
const Accordion = ({
children,
initialOpen = false,
className = ''
}: AccordionProps) => {
const [id] = useState(v4());
const { containerStyle, setOpenedId, openedId, singleOpen } =
useAccordionGroup();
const [isOpen, setIsOpen] = useState(singleOpen ? false : initialOpen);
const toggleOpen = () => {
if (!singleOpen) setIsOpen((prev) => !prev);
setOpenedId(!isOpen ? id : '');
};
useEffect(() => {
if (singleOpen) setIsOpen(openedId === id);
}, [id, openedId, singleOpen]);
....
머리 없는 컴포넌트
즉, Headless component
란 머리(외모=UI)가 없는, 기능은 있고 스타일이 없는 컴포넌트!
👍 장점
👎 단점
createContext 를 사용하여, 컴포넌트 묶음 내에서 필요한 state들을 공유하는 패턴
context
context provider
component
Context
interface InputContextProps {
id: string;
value: string | number;
type: React.HTMLInputTypeAttribute;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const InputContext = createContext<InputContextProps | null>(null);
const useInputContext = () => {
const inputContext = useContext(InputContext);
if (inputContext === null) {
throw new Error('InputContext must be used in Provider');
}
return inputContext;
};
Context Provider
const InputBox = ({
id = '',
initial = '',
type = 'text',
onInput,
children,
}: InputBoxProps) => {
const { getInputProps } = useInput({
initial,
onInput,
type,
});
return (
<InputContext.Provider value={{ id, ...getInputProps() }}>
{children}
</InputContext.Provider>
);
};
Component
const Input = ({
...props
}: React.ComponentPropsWithoutRef<'input'>) => {
const contextValue = useInputContext();
return (
<input
{...contextValue}
{...props}
/>
);
const Label = ({
children,
...props
}: React.ComponentPropsWithoutRef<'label'>) => {
const { id } = useInputContext();
return (
<label
htmlFor={id}
{...props}>
{children}
</label>
);
};
사용하기
...
const [text, setText] = useState('');
return (
<InputBox
type='search'
onInput={(value) => setText(value)}>
<InputBox.Label>🔎</InputBox.Label>
<InputBox.Input
placeholder='내용을 입력해 보세요'
/>
</InputBox>
)
👍 장점
👎 단점
children 을 prop 으로 받아서 상태 값들을 주입해주기
interface InputHeadlessProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const InputHeadless = (props: {
children: (args: InputHeadlessProps) => JSX.Element;
}) => {
const [value, setValue] = useState('');
return props.children({
value,
onChange: (e) => setValue(e.target.value),
});
};
사용하기
// ...
return (
<InputHeadless>
{({ onChange, value }) => (
<>
<input
onChange={onChange}
value={value}
/>
<div>{value}</div>
</>
)}
</InputHeadless>
)}
👍 장점
👎 단점
컴포넌트 기능을 수행하는 커스텀훅 사용하기
import { useState } from 'react'
export const useInput = () => {
const [value, setValue] = useState('')
return {
value,
onChange: (e) => setValue(e.target.value),
}
}
사용하기
// ...
const { value, onChange } = useInput()
return (
<>
<input value={value} onChange={onChange} />
<div>{value}</div>
</>
)
👍 장점
👎 단점
♻️ 개선 방법
const useInput = (option?: string | number | InputOptions) => {
...
const getInputProps = () => ({
type,
value,
onChange,
});
return { value, getInputProps };
};
...
const { getInputProps, value } = useInput();
return (
<div>
<h3>Input Box</h3>
<input
{...getInputProps()}
placeholder='내용을 입력해 보세요'
/>
<div>value: {value}</div>
</div>
);
Headless 컴포넌트 방식이 모든 문제를 해결하는 정답인 것은 아니지만,
UI 나 기능이 조금씩 변경될 때 마다 컴포넌트를 수정하는 과정이 번거롭거나, 공통 컴포넌트의 관심사를 어디까지 허용해야 할 지 애매할 때 한번 시도해 보면 좋은 방법이라고 생각한다.
Headless 안에서도 최선의 방식이 정해져 있는 것이 아니니, 팀 내 컨벤션과 개발 상황에 맞게 사용해야 한다.
✨ 구현해보니…!
headless 구현 방법 중에서도, 상태 값을 자유자재로 쓸 수 있는 hook 방식과 좀 더 컴포넌트스럽게 사용할 수 있는 compound 방식을 함께 쓸 수 있으면 좋겠다는 생각이 들었다.
바로 사용할 수 있도록 커스텀 훅을 구현하고, 해당 커스텀 훅을 감싼 합성 컴포넌트를 함께 구현하면 두 방식의 장점을 모두 사용할 수 있다.
컴포넌트를 구현하는 개발자는 커스텀 훅을 구현하고, 컴포넌트에 매핑할 때는 해당 훅만 불러서 감싸주면 된다.
컴포넌트를 사용하는 개발자 입장에서는 상태값을 더 자유롭게 쓰고 싶을 때는 커스텀훅을, 매핑이 잘된 컴포넌트를 사용하고 싶을 때는 컴포넌트를 불러다가 쓰면 된다.
useTab
만 사용하는 경우
: 현재 어떤 탭이 선택되어 있는 지 상태값이 외부에서도 필요할 때
...
const useTabProps = (option?: number | TabOptions) => {
// ...
const [select, setSelect] = useState(initial);
const getTabItemProps = useCallback(
(index: number) => ({
onClick: () => {
setSelect(index);
onChange && onChange(index);
},
'aria-selected': select === index,
}),
[onChange, select]
);
const getTabScreen = useCallback(
(screens: React.ReactNode[]) => {
return screens[select];
},
[select]
);
return {
getTabItemProps,
getTabScreen,
select,
};
};
export default useTabProps;
...
const { select, getTabItemProps, tabListRef } = useTabRef();
return(
<>
<div ref={tabListRef} style={{ display: 'flex', gap: '10px' }}>
<div>Tab0</div>
<div>Tab1</div>
<div>Tab2</div>
</div>
<div>
{getTabScreen([
<div>Tab Screen 0</div>,
<div>Tab Screen 1</div>,
<div>Tab Screen 2</div>,
])}
</div>
</>
useTab을 감싼
compound component
를 사용하는 경우
: 깔끔한 코드와 로직 감추기의 우선순위가 더 높을 때
const TabBox = ({
initial,
onChange,
children,
}: TabBoxProps) => {
const { select, getTabItemProps } = useTabProps({
initial,
onChange,
});
return (
<TabContext.Provider
value={{
select,
getTabItemProps,
}}>
{children}
</TabContext.Provider>
);
};
...
return(
<Tab.Box
onChange={(index) => console.log(index)}
{/* 하지만 이 때도 onChange 로 로직의 상태값을 가져올 수 있어야 함! */}>
<Tab.List style={{ display: 'flex' }}>
<Tab>tab1</Tab>
<Tab>tab2</Tab>
<Tab>tab3</Tab>
</Tab.List>
<Tab.Screens>
<Tab.Screen>screen1</Tab.Screen>
<Tab.Screen>screen2</Tab.Screen>
<Tab.Screen>screen3</Tab.Screen>
</Tab.Screens>
</Tab.Box>
)