공통 컴포넌트에 대한 고찰과 Headless

차차·2024년 1월 2일
7

react study

목록 보기
2/5
post-thumbnail

😫 불편한 컴포넌트?

공통 컴포넌트를 구현하거나 사용하면서 부딪혔던 불편한 점들


case1

컴포넌트 스타일을 prop 으로 받는 경우

interface SomeComponentProps {
	textSize: 'sm' | 'md' | 'lg';
	color: string;
	border: boolean;
}

const SomeComponentProps = ...

🔥 문제점

  1. 앱의 UI가 조금이라도 바뀌면 수정이 복잡해짐
  2. 컴포넌트의 prop 이 과도하게 많아질 수 있음
  3. 특정 스타일 라이브러리에 종속됨
  4. 스타일을 자유롭게 추가하기 어려움

case2

자식들이 부모 prop에 묶여있는 경우

...
return (
	<Tab 
		items = ['tab1', 'tab2', 'tab3']
		contents = [<div>tab1 contents</div>, <div>tab2 contents</div>, ...]
	/>
)

🔥 문제점

  1. 코드가 길어질 경우 가독성이 좋지 못함
  2. Tab 컴포넌트 내의 모든 레이아웃이 무조건 고정

case3

결국 공통 이기 때문에 여러 사람이 여러 상황에서 사용을 하게 되고, 수정해야 하는 일이 계속 생긴다.

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]);

	....

🙄 편한 컴포넌트?

깨달은 점

  1. 변경이 용이해야 한다.
  2. 컴포넌트의 prop 으로 모든 것을 해결하려 하면 안된다.
  3. 컴포넌트 로직을 깔끔하게 숨긴다고 해서 무조건 좋은 것은 아니다.
  4. 스타일을 부여하게 되면, 재사용성이 떨어진다. (UI 가 크게 바뀌면 결국 기능 똑같은 컴포넌트 하나 더 만들게 되는 수가 ..)
  5. 컴포넌트를 사용하는 입장을 고려해서 작성해야 한다.

🤯 Headless Component

머리 없는 컴포넌트

Headless 란?

  • 서로 독립적으로 운영하는 FE, BE 아키텍처 = UI 를 분리하여 개발하는 아키텍처 패턴
  • 사용하려는 환경을 고려하지 않아도 쓸 수 있도록 설계된 디자인 시스템
  • 쉽게 바뀔 수 있는 부분(UI)을 제외한 핵심적인 부분(데이터 처리)
  • UI 를 따로 가지지 않는 컴포넌트

즉, Headless component 란 머리(외모=UI)가 없는, 기능은 있고 스타일이 없는 컴포넌트!


👍 장점

  • 마크업과 스타일 완전 자유, 완전 제어 가능
  • 모든 스타일 패턴 및 라이브러리 지원
  • 작은 번들 사이즈

👎 단점

  • 설정해야 하는 것들이 많음
  • 패턴에 따라 마크업, 스타일이 모두 지원이 안되는 경우가 있음

Headless Component 만들기

1. Compound Component

createContext 를 사용하여, 컴포넌트 묶음 내에서 필요한 state들을 공유하는 패턴

  • state를 공유하기 위한 context
  • state을 제공하는 context provider
  • state을 사용하는 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>
  )

👍 장점

  • Wrapper 내부 레이아웃과 스타일에 대해 고정된 것이 없음 = 자유롭게 수정할 수 있음

👎 단점

  • 써야 하는 코드가 길다..

2. Function as Child Component

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>
)}

👍 장점

  • 상태값을 선언하지 않아도, 렌더링부에서 바로 사용할 수 있다.
  • 데이터와 데이터를 렌더링하는 부분이 붙어있어서 읽기 쉽다.

👎 단점

  • 컴포넌트 내에서의 상태값을 외부에서 사용하기 어렵다.

3. Custom Hooks

컴포넌트 기능을 수행하는 커스텀훅 사용하기

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>
  </>
)

👍 장점

  • 기능만 분리된 컴포넌트를 작성하는 제일 간단한 방법

👎 단점

  • input 관련 상태값을 잘못된 프로퍼티에 연결하는 실수가 발생할 수 있음

♻️ 개선 방법

  • 태그에 주입되어야 하는 속성들을 한번에 반환해주는 함수를 훅에서 반환
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>
)

0개의 댓글