우주마켓 리빌딩 - Headless UI

한호수 (The Lake)·2023년 8월 14일
0

우주마켓 리빌딩

목록 보기
2/2
post-thumbnail
post-custom-banner

개요

처음 기획할때 하위 컴포넌트들과 상태를 공유하는 컴포넌트의 경우 Compound Components 패턴을 통해 구현할 계획이었다. 하지만 여러 변형을 가진 컴포넌트를 만났을때 문제가 발생했다.

// Compound Components 예제 코드

interface NavHeadlessProps {
  redirect: () => void;
  onChange: () => void;
  openMenu: () => void;
  onUpload: () => void;
}

// 컨텍스트 생성
const NavWrapperContext = createContext<NavHeadlessProps>({
  redirect: () => {},
  onChange: () => {},
  openMenu: () => {},
  onUpload: () => {},
});

// NavWrapper 컴포넌트의 자식요소에게 Context 공유
const NavWrapper = ({ children }: PropsWithChildren) => {
  const value = {
    redirect: () => {},
    onChange: () => {},
    openMenu: () => {},
    onUpload: () => {},
  };

  return <NavWrapperContext.Provider value={value}>{children}</NavWrapperContext.Provider>;
};

// NavWrapper 하위 컴포넌트
const Container = ({ children }: { children: JSX.Element[] }) => {
  return (
    <div className="w-[390px] flex justify-between items-center px-4 py-3 border-b border-gray-250 bg-white">
      {children}
    </div>
  );
};

// NavWrapper 하위 컴포넌트
const BackButton = ({ href, ...props }: { href: string }) => {
  const { redirect } = useContext(NavWrapperContext);

  return <ImgButton icon="leftArrow" onClick={() => redirect(href)} {...props} />;
};

// NavWrapper 하위 컴포넌트
const MoreButton = ({ ...props }) => {
  const { openMenu } = useContext(NavWrapperContext);

  return <ImgButton icon="moreVertical" onClick={openMenu} {...props} />;
};

// NavWrapper 컴포넌트 객체의 property로 저장
NavWrapper.Container = Container;
NavWrapper.BackButton = BackButton;
NavWrapper.MoreButton = MoreButton;

export default NavWrapper;
// 사용된 컴포넌트
export default function Home() {
  return (
    <main>
      <NavWrapper>
        <NavWrapper.Container>
          <NavWrapper.BackButton href="/feed" />
          <NavWrapper.MoreButton />
        </NavWrapper.Container>
      </NavWrapper>
    </main>
  );
}

발생한 문제

  1. Atom 컴포넌트들은 추상화되어있기 때문에 도메인이 들어간 훅을 사용한다면 재사용이 불가하게된다. 그래서 컴포넌트를 생성해서 랩핑해서 추가하는 방법을 사용했다.
// ImgButton 컴포넌트를 랩핑하여 상태를 전달한다.
const BackButton = ({ href, ...props }: { href: string }) => {
  const { redirect } = useContext(NavWrapperContext);

  return <ImgButton icon="leftArrow" onClick={() => redirect(href)} {...props} />;
};

NavWrapper.BackButton = BackButton;
  1. 랩핑하여 사용할경우 컨텍스트를 사용하기 때문에 useContext 또는 커스텀 훅을 계속 호출해줘야 상태에 접근할 수 있다. 확장성이 떨어졌다.
// NavWrapper 하위 컴포넌트
const BackButton = ({ href, ...props }: { href: string }) => {
  const { redirect } = useContext(NavWrapperContext); // useContext
  
  // (...)
};

// NavWrapper 하위 컴포넌트
const MoreButton = ({ ...props }) => {
  const { openMenu } = useContext(NavWrapperContext); // useContext

  // (...)
};
  1. NavWrapper에 작성되는 서브 컴포넌트들이 마크업 또는 스타일을 가지게 되어서 로직만 재활용할 수 없다. UI와 데이터에 대한 관심사가 완벽하게 분리되지 않았다.

Headless UI

// Headless UI 예제 코드

interface NavHeadlessProps {
  redirect: () => void;
  onChange: () => void;
  openMenu: () => void;
  onUpload: () => void;
}

// Headless UI 생성
const NavHeadless =({
  children,
}: {
  children: (args: NavHeadlessProps) => JSX.Element;
}) => {
  
  const redirect = () => { };
  const onChange = () => { };
  const openMenu = () => { };
  const onUpload = () => { };
  
  // children이 없거나 함수가 아니라면 렌더링하지 않는다.
  if (!children || typeof children !== "function") return null;

  // 함수인 children에게 함수 파라미터로 값을 내려준다.
  return children({ onChange, redirect, openMenu, onUpload });
}

export default NavHeadless;
// 기본 Nav Bar
const BasicNavBar = () => {
  return (
    <NavHeadless>
      // 함수로 된 children을 생성해서 파라미터로 상태값을 받아 렌더링한다.
      {({ openMenu, redirect }) => {
        return (
          <div className="w-[390px] flex justify-between items-center px-4 py-3 border-b border-gray-250 bg-white">
            <ImgButton size="2xs" icon="leftArrow" onClick={() => redirect("/feed")}>
              메인으로
            </ImgButton>
            <ImgButton size="xs" icon="moreVertical" onClick={openMenu}>
              메뉴
            </ImgButton>
          </div>
        );
      }}
    </NavHeadless>
  );
}

// Feed 페이지 NavBar
const FeedNavBar = () => {
  return (
    <NavHeadless>
      {({ redirect }) => {
        return (
          <div className="w-[390px] flex justify-between items-center px-4 py-3 border-b border-gray-250 bg-white">
            <Text size="lg">우주마켓 피드</Text>
            <ImgButton size="xs" icon="search" onClick={() => redirect("/search")} />
          </div>
        );
      }}
    </NavHeadless>
  );
}
  • Headless UI 컴포넌트를 만들어서 사용하게 된다면 상태값을 받아서 이미 구현된 Atom 컴포넌트에 랩핑할필요 없이 전달해주면 된다.

  • Compound Components 패턴에 비해 간략화 되어있기 때문에 가독성이 좋아졌다.

  • Headless UI 컴포넌트는 로직만을 가지고 UI를 가지고 있지 않아 같은 기능을 가진 다른 스타일의 컴포넌트에 유연하게 대응할 수 있다.

  • 마지막으로 디자인시스템에 적용하기 위해 variant 변수를 통해 렌더링할 NavBar를 선택할 수 있게 하였다.


interface IProps {
  variant?: "basic" | "search" | "feed" | "upload" | "chat";
  onClickRightIcon?: MouseEventHandler;
  username?: string;
}

// 컴포넌트들을 객체화
const variants = {
  basic: BasicNavBar, 
  search: SearchNavBar,
  feed: FeedNavBar,
  upload: UploadNavBar,
  chat: ChatNavBar,
};

// 랜더러 함수를 만들어 variant를 통해 NavBar들을 선택할 수 있게 하였다.
export default function NavBar({ variant = "basic", username }: IProps) {
  const renderer = variants[variant];

  // 컴포넌트 중 Props를 받아야하는 경우 객체로 넘겨주었다.
  return renderer({ username });
}

맺음말

디자인 패턴은 상황에 맞게 알맞은 패턴을 사용해야한다는 점을 다시 한번 느꼈다. 사실 Compound Components 패턴을 직접 구현하면서 그냥 사용해도 됬지만 근거를 가지고 선택할 수 있게 되어서 학습에 큰 도움이 되었다.

profile
항상 근거를 찾는 사람이 되자
post-custom-banner

0개의 댓글