Headless한 컴포넌트 만들기

지인혁·2024년 4월 3일
3

🎈 Headless

Headless의 뜻을 먼저 살펴보면 '머리가 없는'이라는 뜻 입니다. 컴포넌트 기준에서 머리는 UI를 뜻하며 Headless는 UI가 없는 것을 의미합니다.

컴포넌트는 결국 UI를 반환하는 함수인데 UI가 없다는 것은 결국 로직만 존재하는 컴포넌트이며 로직과 UI가 분리되어 있는 것 입니다.

그렇습니다. 핵심은 로직만 제공하고 어떤 UI가 렌더링이 될지는 사용하는 쪽에서 자유롭게 사용하는게 핵심입니다.

🎑 어떤 문제를 해결할 수 있나?

Headless 컴포넌트는 변경이 잦은 UI에 로직을 재사용할 수 있습니다. 만약 공통 컴포넌트를 개발하고 해당 컴포넌트와 비슷한 UI가 새로 추가되었다고 생각해봅시다.

그럼 또 추가된 UI에 대응하는 다른 컴포넌트를 만들어야 할까요? 이는 기존에 만들었던 컴포넌트가 재사용에 실패한 것을 증명합니다.

props에 style 분기를 처리하거나 기존에 만든 컴포넌트에 새로운 태그를 추가해야 할까요? 이 방법도 기존 컴포넌트를 직접 수정해야하고 props에 따른 사이드 이펙트를 초래합니다.

UI가 변경되어도 결국 로직은 동일한 모습을 볼 수 있습니다. 이 경우 Headless하게 컴포넌트를 설계하면 다양한 UI에 공통된 로직을 제공할 수 있습니다.

✨ Headless한 3가지 패턴

컴파운드 패턴

컴파운드 패턴은 부모 컴포넌트가 내부 상태를 제공하고 자식 컴포넌트들이 부모 컴포넌트에게 상태를 공유받습니다.

마치 합성 컴포넌트와 유사한 특징을 가지고 있지만 자식 컴포넌트는 별도의 props가 존재하지 않고 부모 컴포넌트의 context api에서 상태를 주입 받습니다.

이는 합성 컴포넌트의 유연성, 자식 컴포넌트들이 상태를 모두 공유하는 장점, 그리고 Headless한 특징까지 가지고 있습니다.

제가 구현한 페이지네이션 컴포넌트를 예시로 보겠습니다.

context api

export const PaginationContext = createContext<IPaginationContext>({
  currentPage: 0,
  totalPages: 0,
  handleSelectPage: () => {},
  handleMovePrevPage: () => {},
  handleMoveNextPage: () => {},
  createPageButtons: () => {},
});

export const PaginationProvider = ({
  defaultPage,
  limit,
  total,
  onPageChange,
  children,
}: IPaginationProviderProps) => {
  const [currentPage, setCurrentPage] = useState(defaultPage);
  const totalPages = Math.ceil(total / limit);

  const handleSelectPage = (newPage: number) => {
    onPageChange(newPage);
    setCurrentPage(newPage);
  };

  const handleMovePrevPage = () => {
    if (currentPage > 1) {
      handleSelectPage(currentPage - 1);
    }
  };

  const handleMoveNextPage = () => {
    if (currentPage + 1 <= totalPages) {
      handleSelectPage(currentPage + 1);
    }
  };

  const createPageButtons = (className?: string) => {
    return Array.from({ length: totalPages }, (_, index) => index + 1)
      .filter((page) => {
        const minPage = Math.floor((currentPage - 1) / 10) * 10 + 1;
        const maxPage = minPage + 9;

        return page >= minPage && page <= maxPage;
      })
      .map((page) => {
        const style = twMerge(
          `min-w-[4.5rem] py-4 text-center w-10 rounded-[50%] hover:rounded-[50%] hover:bg-[#E6E8EA] ${currentPage === page && 'bg-[#E6E8EA] font-bold'} ${className}`,
        );

        return (
          <button
            type="button"
            key={page}
            onClick={() => handleSelectPage(page)}
            className={style}
          >
            {page}
          </button>
        );
      });
  };

  return (
    <PaginationContext.Provider
      value={{
        currentPage,
        totalPages,
        handleSelectPage,
        handleMoveNextPage,
        handleMovePrevPage,
        createPageButtons,
      }}
    >
      {children}
    </PaginationContext.Provider>
  );
};

context api에서 페이지네이션 로직을 중앙화하여 한 곳에서 관리하게 됩니다. 해당 context api는 부모 컴포넌트 내부에 provider로 사용되며 자식 컴포넌트들에게 상태와 로직을 제공합니다.

메인 컴포넌트

const Pagination = ({
  defaultPage,
  limit,
  total,
  onPageChange,
  children,
  ...rest
}: IProps) => {
  const style = twMerge(`flex gap-6 text-large ${rest.className}`);

  return (
    <PaginationProvider
      defaultPage={defaultPage}
      limit={limit}
      total={total}
      onPageChange={onPageChange}
    >
      <div className={style}>{children}</div>
    </PaginationProvider>
  );
};

Pagination.PrevButton = PrevButton;
Pagination.NextButton = NextButton;
Pagination.PageButtons = PageButtons;

export default Pagination;

이전에 생성한 context api를 사용하여 children에 감싸줍니다. 벌써 Headless하게 만들어졌습니다. 자식 children은 어떤 UI가 올지는 모릅니다. 그저 context api로 생성한 상태와 로직을 제공해주기만 하고 있습니다.

자식으로 어떤 UI가 와도 한 곳에서 관리되는 로직으로 대응할 수 있게되어 매우 Headless하게 설계되었습니다.

서브 컴포넌트

페이지네이션 컴포넌트는 3개의 서브 컴포넌트로 분리할 수 있겠습니다.

  1. 이전 페이지 버튼
  2. 다음 페이지 버튼
  3. 페이지 버튼들
이전 페이지 버튼 컴포넌트
const PrevButton = ({ ...rest }: IProps) => {
  const { handleMovePrevPage } = useContext(PaginationContext);

  return (
    <button
      type="button"
      onClick={handleMovePrevPage}
      {...rest}
    >
      {'<'}
    </button>
  );
};

export default PrevButton;
다음 페이지 버튼 컴포넌트
const NextButton = ({ ...rest }: IProps) => {
  const { handleMoveNextPage } = useContext(PaginationContext);

  return (
    <button
      type="button"
      onClick={handleMoveNextPage}
      {...rest}
    >
      {'>'}
    </button>
  );
};

export default NextButton;
페이지 버튼들 컴포넌트
const PageButtons = ({ ...rest }: IProps) => {
  const { createPageButtons } = useContext(PaginationContext);

  return <>{createPageButtons(rest.className)}</>;
};

export default PageButtons;

각 서브 컴포넌트들은 context api에서 제공해주는 상태와 로직을 사용하면서 또한 합성 컴포넌트와 같이 독립적으로 자신의 역할만 하고 있습니다.

사용처

const PaginationDev = () => {
  const printPageChange = (page: number) => {
    console.log(page);
  };

  return (
    <Pagination
      defaultPage={5}
      limit={20}
      total={423}
      onPageChange={printPageChange}
    >
      <Pagination.PrevButton />
      <Pagination.PageButtons />
      <Pagination.NextButton />
    </Pagination>
  );
};

export default PaginationDev;

사용하는 곳에서도 조각난 서브 컴포넌트들 조합하여 부모 컴포넌트 자식으로 주입만 해주게 됩니다.

만약 UI의 수정이 발생해도 사용하는 곳에서 자식 컴포넌트에서 작업만 해준다면 로직을 계속해서 재사용할 수 있어 매우 Headless하게 되었습니다.

Function as child 패턴

Function as child는 이름 부터 자식 요소를 함수로 받는 패턴입니다.

자식 요소에 다양한 UI나 어떤 요소가 올지 모르는 상황에서 부모 컴포넌트는 오직 상태와 로직만 가지며 자식 요소를 함수채로 받아 매개변수로 상태로 로직을 전달하는 형태입니다.

const FunctionAsChildInput = ({ children }) => {
  const [value, setValue] = useState("");

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  return children({
    value,
    onChange: handleChange,
  });
};

export default FunctionAsChildInput;

<FunctionAsChildInput>
        {({ value, onChange }) => {
          return (
            <div className="input-container">
              <label id="1">Name</label>
              <input type={"text"} id="1" value={value} onChange={onChange} />
            </div>
          );
        }}
</FunctionAsChildInput>

부모 컴포넌트에서는 자식으로 오는 children을 매개변수에 상태와 로직을 넣은 후 호출만 해줍니다. 어떤 요소가 들어올지는 모릅니다.

사용하는 곳에서는 함수 형태로 자식으로 전달받은 매개변수의 상태와 로직을 적절하게 사용하면서 원하는 UI를 주입 시켜줍니다.

이 패턴도 매우 Headless하게 어떤 UI를 마주해도 로직을 한 곳에서 관리하며 제공해줄 수 있습니다.

Custom Hook 패턴

자주 사용하는 Custome Hook이 Headless한 패턴인지 잘 모르고 사용했었습니다. Custome Hook도 어떻게 보면 상태와 로직을 구현하고 사용하는 곳에서 자유롭게 주입받아 UI를 그립니다.

이는 재사용도 가능하며 어느 UI가 와도 동일한 로직으로 수행할 수 있습니다. 즉 Headless다고 할 수 있습니다.

const useInput = () => {
  const [value, setValue] = useState('');

  const onChange = (event) => {
    setValue(event.target.value);
  };

  return { value, onChange };
}

const CustomeHookComponent = () => {
  const { value, onChange } = useInput();
  
  return (
    <input 
      type="text" 
      value={value} 
      onChange={onChange} 
    />
  )
}

🛒 마치며

사실 UI의 변경에 대응하기 위한 목적으로 Headless 패턴을 사용하지는 않았습니다. 왜냐하면 아직 잦은 UI 요구사항의 변경의 경험이 없기 때문입니다.

하지만 Headless하게 컴포넌트를 설계하면서 좋았던 점은 UI와 로직의 분리였습니다. 컴포넌트가 굉장히 깔끔해졌고 관심사가 분리되어 유지보수에 매우 편리성을 주었습니다.

여기서 덤으로 UI가 변경되는 문제가 발생해도 Headless한 패턴을 이용한다면 정말 변경되는 UI만 작업할 수 있기 때문에 언제나 컴포넌트를 잘 설계해야하는 점을 다시 돌아보게 되었습니다.

profile
대구 사나이

1개의 댓글

comment-user-thumbnail
2024년 4월 8일

잘 보고갑니다

답글 달기