context 하나로 여러 종류의 모달 관리하기(context API, createPortal)

문도연·2023년 12월 4일
1
post-thumbnail

같은글 노션링크 | 코드 변경 전후를 명확하게 보실수있습니다.

1.전역에서 모달 관리하기

1.1 도입이유
1.2 도입 목표

2. Context 하나로 여러 종류의 모달을 전역에서 관리하기

2.1 기존 모달 로직
2.2 context 생성
2.3 모달 사용하기
2.4 ReactDom.CreatePortal

3.결과물 및 마무리


1. 전역에서 모달 관리하기

1.1 도입 이유

현재 AelrtModalContext 로 모달을 사용하고있다. 기능적으로 문제는 없지만, 해당 코드를 볼수록 디자인과 기능이 강결합돼 있어서 Headless 처럼 두 로직을 분리해야 하지 않을까 라는 생각이 들었다.

또, 다른 UI의 모달을 새로 만든다면, 기존의 것(AlertModalContext)을 재사용할 수 없으므로, XXXModalContext를 여러개 새로 만들어야만 하는 상황이다. 그래서 모달을 잘 만들고 효율적으로 관리할 수 있는 방법을 찾아보게 되었다.

1.2 도입 목표

  • 기능과 디자인을 분리해 디자인의 자유도를 높이자
  • 하나의 Context 로 여러 종류의 모달을 관리하자

2. Context 하나로 여러 종류의 모달을 전역에서 관리하기

2.1 현재 모달 로직

문제점

  1. 알람 모달만을 위한 UI가 고정돼있음. 재사용불가. 종류가 다른 모달을 만들려면 새 context를 만들어야함
  2. state와 setState가 한 context를 공유하고 있다. state 값이 바뀌었을때 직접적으로 state를 참조하지 않는(setState만 사용하는) 컴포넌트도 불필요하게 리렌더링 되고 있다.
// context/AlertModalProvider.tsx
export const useAlertModalContext = () => {
  return useContext(AlertModalContext);
};

export const AlertModalContext = createContext({
  showAlert(message: string) {},
});

export const AlertModalProvider = ({ children }: PropsWithChildren) => {
  const [message, setMessage] = useState('');
  const [isOpen, setIsOpen] = useState(false);

  const showAlert = (message: string) => {
    setIsOpen(true);
    setMessage(message);
  };
  const closeAlert = () => {
    setIsOpen(false);
    setMessage('');
  };

  return (
    <AlertModalContext.Provider value={{ showAlert }}>
      {children}
      {isOpen && (
        <Backdrop whiteBoard>
          <div
            css={css`
              display: flex;
              flex-direction: column;
              align-items: center;
              justify-content: space-around;
              width: 85%;
              height: 200px;
              font-size: 22px;
              font-family: 'Noto Sans KR';
            `}
          >
            {message}
            <Button
              onClick={closeAlert}
              css={css`
                width: 90%;
                padding: 5px 0;
                background-color: ${colors.grey200};
                font-size: 20px;
              `}
            >
              닫기
            </Button>
          </div>
        </Backdrop>
      )}
    </AlertModalContext.Provider>
  );
};

2.2 모달용 context 생성

context/ModalContext.tsx

  • 열린 모달들을 배열state로 관리한다.
  • 모달을 사용하는 컴포넌트는 모달의 종류 및 필요한 prop을 전달해주면 된다.
  • 모달 닫기는, 가장 최근에 열린 모달이 배열state의 가장 마지막 순서에 있을 것이므로 slice 메서드로 마지막 요소를 제외해 setState에 전달
  • state 와 setState 의 context를 분리해줌으로서 불필요한 리렌더링 방지
export const ModalContext = createContext<TestModals[]>([]);
export const ModalDispatchContext = createContext({
  open: ({ type, props }: TestModals) => {},
  close: () => {},
});

export const useModalContext = () => useContext(ModalContext);
export const useModalDispatchContext = () => useContext(ModalDispatchContext);

function ModalProvider({ children }: { children: ReactNode }) {
  const [openedModals, setOpenedModals] = useState<TestModals[]>([]);

  const open = ({ type, props }: TestModals) => {
    const KEY = Math.random().toString(36).substring(2);
    setOpenedModals(modals => {
      return [
        ...modals,
        {
          type,
          props,
          id: `${type}${KEY}`,
        },
      ];
    });
  };
  const close = () => {
    setOpenedModals(modals => {
      return modals.slice(0, modals.length - 1);
    });
  };

  const dispatch = useMemo(() => ({ open, close }), []);

  return (
    <ModalDispatchContext.Provider value={dispatch}>
      <ModalContext.Provider value={openedModals}>
        {children}
        <CreatePortal />
      </ModalContext.Provider>
    </ModalDispatchContext.Provider>
  );
}

export default ModalProvider;

2.3 모달 사용하기

function RootErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
  const { openModal } = useModal();
 ...

if (error.code === 'ERR_NETWORK') {
    openModal({
      type: 'alert', 	// 모달종류
			// 표현할 컨텐츠 전달
      props: { title: '앗...😰', message: ERROR_MESSAGE[998], btnText: '닫기' }, 
    });
    resetErrorBoundary();
    return;
  }

	return (...)
}

모달 종류는 두가지 있다.

const MODAL_COMPONENTS = {
  alert: AlertModal,
  confirm: ConfirmModal,
};

function AlertModal({ onClose, title, message, btnText = '닫기' }: ModalProps) {
  return (
    <BackDrop whiteBoard>
      <div css={alert.alignColumn} role="dialog" tabIndex={0}>
        <h3 css={alert.title}>{title}</h3>
        <p css={alert.message}>{message}</p>
        <Button onClick={onClose} css={alert.button} aria-label={btnText}>
          {btnText}
        </Button>
      </div>
    </BackDrop>
  );
}

function ConfirmModal({  onClose, onSubmit, title, message, btnText,: ModalProps) {
  return (
    <BackDrop whiteBoard>
      <div css={alert.alignColumn}>
        <h3 css={alert.title}>{title}</h3>
        <p css={alert.message}>{message}</p>
        <div css={confirm.btnWrapper}>
          <Button onClick={onSubmit} css={confirm.button}>
            {btnText}
          </Button>
          <Button onClick={onClose} css={confirm.button}>
            닫기
          </Button>
        </div>
      </div>
    </BackDrop>
  );
}

2.4 ReactDom.createPortal로 모달 렌더링하기

createPortal은 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식 컴포넌트를 렌더링하는 방법이다. 모달창이 때때로 부모 컴포넌트의 스타일링에 영향받는 경우가 있는데, 이때 모달 컴포넌트가 부모 컴포넌트로부터 독립적인 위치에서 렌더링을 할 수 있게 해준다.

// components/@helper/Modal/CreatePortal.tsx

// 사용가능한 모달종류
const MODAL_COMPONENTS = {
  alert: AlertModal,
  confirm: ConfirmModal,
};

function CreatePortal() {
  const openedModals = useModalContext();
  const { hideModal } = useModal();
  let modalElement;

	// openedModals state를 map으로 순회하면서 
	// 렌더링할 모달컴포넌트를 리턴하고, createPortal에 전달한다.
  const renderModal = openedModals.map(({ type, props, id }: Modals) => {
    const ModalComponent = type && MODAL_COMPONENTS[type];
    const { onSubmit, onClose, ...restProps } = props;
    modalElement = document.getElementById(`${type}-modal`);

    const handleSubmit = async () => {
      if (typeof onSubmit === 'function') await onSubmit();
    };

    const handleClick = () => {
      onClose && onClose();
      hideModal();
    };

    return (
      <ModalComponent
        key={id}
        {...restProps}
        onClose={handleClick}
        onSubmit={handleSubmit}
      />
    );
  });

  return modalElement && createPortal(renderModal, modalElement);
}

export default CreatePortal;

3.결과물

const { showModal } = useModal();
  const confirm = () => {
    showModal({
      type: 'confirm',
      props: {
        title: ' confirm 모달',
        message: '컨펌하시겠습니까?',
        btnText: 'yes',
        onSubmit: () => window.alert('submit'),
      },
    });
  };
  const alert = () => {
    showModal({
      type: 'alert',
      props: {
        title: ' alert 모달',
        message: '알림입니다.',
        btnText: 'yes',
      },
    });
  };

return (
    <>
        <div onClick={confirm}>confirm</div>
        <div onClick={alert}>alert</div>
		</>
  );

참고
낙타 | 효율적인 modal 관리 with React(1)
낙타 | 효율적인 modal 관리 with React(2)
leego | [React] 효율적으로-모달-관리하기 -1
leego | [React] Portal을 사용한 모달창 만들기 -2
[React] 모달 깊게 파헤치기 : 효율적인 모달 관리에 대하여 (useModal custom hooks)
React | Portal을 이용한 Modal 구현
[React] Portal (포탈), Modal 구현하기
https://velog.io/@velopert/react-context-tutorial#context-란
https://mygumi.tistory.com/406
portal, modal 기본적인 사용법
https://abangpa1ace.tistory.com/entry/React-Portal-포탈 - 포탈 설명 굿
https://www.jeong-min.com/33-modal/ - 모달에 추가할만한 기능들

profile
중요한건 꺾이지 않는 마음이 맞는 것 같습니다

0개의 댓글