React - 합성 컴포넌트 적용해보기 (Modal)

Moolbum·2022년 12월 26일
5

React

목록 보기
19/23
post-thumbnail

컴포넌트 (Component)

컴포넌트는 React에서 UI를 그리는 최소한의 단위입니다.
컴포넌트를 만들 때 어떤 기준으로 만들어야 하는지는 개개인마다 다릅니다.

유연한 컴포넌트 만들기

우리들은 하나의 서비스를 만들면서 다양한 변화를 마추하게 됩니다. 어떤 기능이 추가되거나 삭제되거나 하는 다양한 변화에 대응해야 합니다.

  • Headless 기반의 추상화하기 : 변하는것, 변하지 않는것 구분하기
  • 한 가지 역할만 하기 : 한 가지 역할로만 가지고 있는 것으로 조합
  • 도메인 분리하기 : 도메인을 포함하는 컴포넌트와 그렇지 않은것 구분하기

Component UI

지금까지 사용해본 React UI 라이브러리는 두 가지 라이브러리입니다.
대표적인 Component UI 방식입니다.

라이브러리를 사용해본 장점으로는 기본값으로 style이 지정되어있고 styled-component와도 같이 사용해 style을 커스텀 할 수 있습니다. 또한
다양한 API와 TypeScript까지 지원하여 손쉽게 UI를 만들어 낼 수 있습니다.


Component UI 장단점?

style을 쉽게 할 수 있고 다양한 API,Type까지 지원을 해주고 있지만 한계는 있다. 라이브러리에 없는 기능을 추가하기에 제한적이고 그에 대응해서 style을 수정하기 힘들기 때문이다.

  • 장점
    • 기본 style이 적용되어있어 바로 사용 가능하다
    • 다양한 API로 설정에 크게 공을 들이지 않아도 된다
  • 단점
    • 기본테마의 style이 적용되어있어 수정하기 힘들다
    • 마크업을 커스텀하기 힘들다
    • 큰 번들 사이즈

Headless UI

Headless를 보면 우선 떠오르는 것은 머리가 없나? 라고 생각할 수 있습니다. 맞습니다! 머리는 컨텐츠를 보여줄 방법(웹사이트, 모바일 등)을 의미하고 몸통은 컨텐츠(데이터)를 의미합니다

머리(표현 방법)를 언제든 바꿔 끼울 수 있다.
Headless는 기능은 있지만 스타일이 없는 UI 방식입니다.

직접 Style을 커스텀하여 내가 원하는 Style을 적용하며
변화에 대응하기 좋아 기능을 추가하기 용이합니다

대표적인 Headless UI 라이브러리는 두가지가 있습니다.


Headless UI 장단점?

Headless UI의 장단점은 component ui의 장단점과 반대입니다.

  • 장점
    • 기능 추가에 용이하다
    • 기본 테마의 style이 없어 자유로운 style을 만들 수 있다
    • 작은 번들 사이즈
  • 단점
    • 바로 사용하기 힘들다

어떤게 더 좋고 안 좋은지를 고르기보다 각자 상황에 맞게 사용하면 그것이 최고의 선택이라고 생각합니다!


합성 컴포넌트

Headless 기반 ui를 적용하기 위해서는 합성 컴포넌트를 적용합니다.
합성 컴포넌트는 하나의 컴포넌트를 여러 집합체로 분리한 후, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 패턴이여서 재사용성과 기능 추가에 대응하기 좋은 장점이 있습니다.


Dialog (Modal) 합성 컴포넌트 적용해보기

Dialog를 예시로 만들어 보겠습니다. 컴포넌트를 나누는 기준은 개인의 차가 존재하기 때문에 나누는 기준은 기능 별로 또는 팀의 컨벤션에 맞게 하시면 됩니다!

제가 나눈 기준은 6가지로 나누었습니다!
Dialog ,Toggle , Portal, Overlay, Content, CloseToggle


Dialog

Dialog(modal)를 감싸는 최상단 컴포넌트로 createContext를 이용해 context를 만듭니다. PropsWithChildren 타입은 React에서 기본으로 제공하는 children으로 Props로 전달 할 때 사용 할 수 있습니다. props로만 데이터를 전달하는 것이 아닌 children을 이용해서 JSX.Element를 같이 전달해 유동적으로 만들어보세요

//
/** atoms/Dialog.tsx */

interface DialogContextProps {
  isOpen: boolean;
  toggle: React.Dispatch<React.SetStateAction<boolean>>;
}

export const DialogContext = createContext<DialogContextProps >();

/** Dialog Main 최상단 컴포넌트*/
function DialogMain({
  children,
  open,
  onOpenChange,
}: PropsWithChildren<DialogProps>) {
  
  const values = {
  	isOpen: open,
	toggle: onOpenChange
  }
  
  return (
    <DialogContext.Provider value={values}>{children}</DialogContext.Provider>
  );
}

Toggle

useContext를 이용해 DialogContext값을 참조합니다
추가적으로 button에 로직을 추가 할 것을 생각해서 onClick을 받을수 있게 합니다

interface DialogToggleProps {
  onClick?: () => void;
}

/** Dialog Toggle */	
function DialogToggle({
  children,
  onClick,
}: PropsWithChildren<DialogToggleProps>) {
  const { isOpen, toggle } = useContext(
    DialogContext as React.Context<DialogContextProps>
  );


  return <button onClick={toggle}>{children}</button>;
}

CloseToggle

/** Dialog Toggle */
export function DialogCloseToggle({ children }: PropsWithChildren) {
  const { toggle } = useContext(
    DialogContext as React.Context<DialogContextProps>
  );

  return <button onClick={() => toggle(false)}>{children}</button>;
}

Portal

Dialog(modal)은 React Portal을 이용해 DOM에 생성되어 상단에 렌더링되게 했습니다. Portal을 이용하면 좀 더 쉽게 모달을 만들 수 있습니다.

/** Dialog Portal */
export function DialogPortal({ children }: PropsWithChildren) {
  const { isOpen } = useContext(
    DialogContext as React.Context<DialogContextProps>
  );

  return isOpen ? createPortal(<>{children}</>, document.body) : null;
}

Overlay

Overlay는 Dialog(modal) 뒷 배경입니다. 보통 투명도 있는 검은색을 배경으로 하죠! 하지만 지금 Headless ui를 기반으로 작업하기에 모든 atom에 style을 추가하지 않았습니다.

/** Dialog Overlay */
export function DialogOverlay(props: HTMLAttributes<HTMLDivElement>) {
  const { toggle } = useContext(
    DialogContext as React.Context<DialogContextProps>
  );

  return <div {...props} onClick={() => toggle(false)} />;
}

Content

실질적으로 Dialog의 컨텐츠를 보여주는 곳입니다

/** Dialog Content */
export function DialogContent(
  props: PropsWithChildren<HTMLAttributes<HTMLDivElement>>
) {
  return <div {...props}>{props.children}</div>;
}

export

Object.assign 메서드를 이용해 하나의 객체로 묶었습니다

export const Dialog = Object.assign(DialogMain, {
  Toggle: DialogToggle,
  Content: DialogContent,
  Portal: DialogPortal,
  Overlay: DialogOverlay,
  CloseToggle: DialogCloseToggle,
});

이렇게 코드를 작업하면 단점이 있습니다. 사용하는 곳에서 무조건 open, onOpenChange를 props로 전달 받아야 합니다. 간단한 로직은 내부로직으로만 작동 할 수 있게 해봅니다.

useToggleProvider 커스텀 훅

커스텀훅을 추가로 작업하고 이제 props를 받는다면 props로 받은 데이터로 context의 기본값으로 적용하고 props가 없다면 커스텀훅 내부 기본값을 바라보게 합니다

/* hooks/useToggleProvider.tsx */
import { useCallback, useMemo, useState } from "react";

interface ToggleProviderProps {
  open?: boolean;
  onOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
}

const useToggleProvider = ({ open, onOpenChange }: ToggleProviderProps) => {
  const [defaultOpenState, setDefaultOpenState] = useState<boolean>(false);

  const handleToggle = useCallback(() => {
    if (onOpenChange === undefined) {
      return setDefaultOpenState((prev) => !prev);
    }

    if (onOpenChange) {
      return onOpenChange((prev) => !prev);
    }
  }, [onOpenChange]);

  const values = useMemo(() => {
    return {
      isOpen: open ?? defaultOpenState,
      toggle: handleToggle,
    };
  }, [defaultOpenState, handleToggle, open]);

  return { values };
};

export default useToggleProvider;

커스텀훅을 적용한 합성 컴포넌트

import React, {
  createContext,
  HTMLAttributes,
  PropsWithChildren,
  useContext,
} from "react";
import { createPortal } from "react-dom";
import useToggleProvider from "../../../hooks/useToggleProvider";

interface DialogContextProps {
  isOpen: boolean;
  toggle: React.Dispatch<React.SetStateAction<boolean>>;
}

interface DialogProps {
  open?: boolean;
  onOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
}

interface DialogToggleProps {
  onClick?: () => void;
}

export const DialogContext = createContext<DialogContextProps | undefined>(
  undefined
);

/** Dialog Main 최상단 컴포넌트*/
function DialogMain({
  children,
  open = undefined,
  onOpenChange = undefined,
}: PropsWithChildren<DialogProps>) {
  
  /*-------------- 커스텀 훅 적용 --------------*/
  
  const { values } = useToggleProvider({ 
    open: open,
    onOpenChange: onOpenChange,
  });

  return (
    <DialogContext.Provider value={values}>{children}</DialogContext.Provider>
  );
}

/** Dialog Toggle  */
function DialogToggle({
  children,
  onClick,
}: PropsWithChildren<DialogToggleProps>) {
  const { isOpen, toggle } = useContext(
    DialogContext as React.Context<DialogContextProps>
  );

  const handleClick = () => {
    if (onClick !== undefined) {
      return onClick();
    } else toggle(!isOpen);
  };

  return <button onClick={handleClick}>{children}</button>;
}

/** Dialog Toggle */
export function DialogCloseToggle({ children }: PropsWithChildren) {
  const { toggle } = useContext(
    DialogContext as React.Context<DialogContextProps>
  );

  return <button onClick={() => toggle(false)}>{children}</button>;
}

/** Dialog Portal */
export function DialogPortal({ children }: PropsWithChildren) {
  const { isOpen } = useContext(
    DialogContext as React.Context<DialogContextProps>
  );

  return isOpen ? createPortal(<>{children}</>, document.body) : null;
}

/** Dialog Overlay */
export function DialogOverlay(props: HTMLAttributes<HTMLDivElement>) {
  const { toggle } = useContext(
    DialogContext as React.Context<DialogContextProps>
  );

  return <div {...props} onClick={() => toggle(false)} />;
}

/** Dialog Content */
export function DialogContent(
  props: PropsWithChildren<HTMLAttributes<HTMLDivElement>>
) {
  return <div {...props}>{props.children}</div>;
}

export const Dialog = Object.assign(DialogMain, {
  Toggle: DialogToggle,
  Content: DialogContent,
  Portal: DialogPortal,
  Overlay: DialogOverlay,
  CloseToggle: DialogCloseToggle,
});

실제 적용하기

styled-component를 이용해 스타일을 추가하고
Dialog props로 open, onOpenChange를 주어 기본값으로 주어지는 상태값을 바라보는 것이아닌 실제 사용하는 곳의 값을 바라보게 했습니다.

toggle에는 onClick으로 onClick에 함수를 전달하여 비동기 로직에 대응 할 수 있게 했습니다.

closeToggle은 기본값으로만 사용하여 클릭하면 Dialog는 항상 꺼지는 컴포넌트로 제작했습니다.

import React, { useState } from "react";
import styled from "styled-components";
import { Dialog } from "../../components/atoms/Dialog";

function HeadLess() {
  const [open, setOpen] = useState(false);

  const handleClickDialog = () => {
    console.log("오픈>>>>");

    setOpen((prev) => !prev);
  };

  const handleConfirmClick = () => {
    setTimeout(() => {
      console.log("확인클릭>>>>");
      setOpen(false);
    }, 1500);
  };

  return (
    <div>
      <Title>합성 컴포넌트</Title>
      <Section>
        <button onClick={handleClickDialog}>여기도 버튼이라네~</button>
      </Section>

      <Section>
        <Dialog open={open} onOpenChange={setOpen}>
          Dialog 테스트
          <Dialog.Toggle>Dialog 열기 버튼</Dialog.Toggle>
          <Dialog.Portal>
            <Overlay />
            <Content>
              <div className="content-container">
                <h2>다이얼로그 (모달)</h2>
                <p>세상에 즐겁다 마참내</p>

                <div className="content-toggle-container">
                  <Dialog.Toggle onClick={handleConfirmClick}>
                    확인
                  </Dialog.Toggle>
                  <Dialog.CloseToggle>닫기</Dialog.CloseToggle>
                </div>
              </div>
            </Content>
          </Dialog.Portal>
        </Dialog>
      </Section>
    </div>
  );
}

export default HeadLess;

const Title = styled.h1`
  font-size: 24px;
  font-weight: 700;
  margin-bottom: 24px;
`;

const Section = styled.section`
  display: flex;
  gap: 10px;
  margin-bottom: 24px;
`;

const Overlay = styled(Dialog.Overlay)`
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  width: 100%;
  height: 100vh;
`;

const Content = styled(Dialog.Content)`
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);

  .content-container {
    display: flex;
    flex-direction: column;
    gap: 10px;
    width: 600px;
    padding: 20px;
    border-radius: 8px;
    background-color: #fff;
    color: #1e1e1e;
  }

  .content-toggle-container {
    display: flex;
    justify-content: end;
  }
`

배포주소 https://react-masterclass-eta.vercel.app/headless
github 코드

참고사이트
카카오 개발블로그 합성 컴포넌트로 재사용성 극대화하기
Radix UI 라이브러리
Headless UI 라이브러리
Headless 컴포넌트란?
토스 2022 컨퍼런스 : 지속가능한 성장과 컴포넌트

profile
Junior Front-End Developer 👨‍💻

0개의 댓글