팝업과 비슷하지만 다르다.
팝업과 모달 둘 다 사용자에게 현재 중요한 정보를 알려주기 위해서 띄우는 창이다.
여기서 팝업은 새로운 윈도우를 띄우지만 모달은 새로운 레이어를 맨 위에 깐다.
이번에 precrew 팀원들과 가계부 프로젝트를 진행하며 모달창이 필요하게됐다.
그래서 예전에 쓰던 방법으로 만들려 했었는데 그러면 문제가 생겼다.
예전에는 Modal 컴포넌트를 만들어서 z-index가 1000으로 고정된 상태로 사용했다.
근데 이렇게 하면 모달이 맨 위에 올라가지도 않을수도 있다는 단점이 존재한다.
해결하려고 세가지 방법을 써봤다.
첫번째로 redux에 현재 띄워진 모달 이름을 등록해서 모달을 관리했었다.
하지만 굳이 목록을 redux에 저장할 필요가 없었다. 그냥 z-index만 알면 됐기 때문이다.
그래서 두번째 방법으로 context api를 사용해 z-index를 관리해줬다.
역시나 이럴 필요도 없었다.
그냥 modal의 z-index를 1000으로 하고 새로운 모달이 추가되면 형제노드로 추가하면 됐기 때문이다.
마지막 세번째 방법으로 modal div는 최상위에 두고 modal을 추가할 때마다 이 노드에 자식으로 추가했다.
이렇게 하면 모달위에 모달을 띄우는게 가능해진다.
물론 모달위에 모달위에 모달위에 ... 모달을 띄우는것도 가능해진다.
그 방법은 리액트 포탈을 사용한것이다.
공식문서의 리액트 포탈을 참조해 만들었다.
리액트 포탈은 리액트 16버전에 새로생긴 기능이다.
이름에서 알수 있듯 외부에 포탈을 만들어 그곳에서 렌더링을 진행한다.
그래서 DOM의 계층구조에 종속되지 않는 컴포넌트를 렌더링할 수 있게된다!
포탈을 만드는 방법은 간단한다.
// ModalPortal.tsx
import React from 'react';
import ReactDOM from 'react-dom';
interface ModalPortalProps {
children: React.ReactNode;
}
const ModalPortal = ({ children }: ModalPortalProps) => {
const modalRoot = document.getElementById('modal') as HTMLElement;
return ReactDOM.createPortal(children, modalRoot);
};
export default ModalPortal;
createPortal
의 첫번째 인자로 엘리먼트, 문자열처럼 렌더링 가능한 React 자식을 넣어준다.
두번째 인자로는 DOM 엘리먼트를 넣어주는데, 첫번째 인자를 두번째 인자의 자식으로 DOM에 마운트된다.
그래서 위의 코드는 modal
이라는 id를 가진 노드에 자식들을 삽입해준다.
public 폴더의 index.html
파일에 root 노드 아래에 div를 하나 추가한다.
그럼 이제 이 div로 포탈이 열리게된다.
...
<body>
<div id="root"></div>
<div id="modal"></div>
</body>
...
이제 모달에 필요한 기능을 생각해보자.
이 두가지 동작밖에 없다.
두가지 기능을 하기위해 isOpen
이란 상태를 관리해줘야 하는데,
isOpen
이 true일경우 modal을 열어주고, 반대의 경우 닫아주는 상태이다.
상태관리를 하는 방법은 여러가지가 있다.
나는 이를 편하게 하기위해 useModal 훅을 만들어 사용하려한다.
위에서 원하는 동작을 위해 isOpen의 상태는 외부에서 관리해주고
Modal 컴포넌트는 isOpen의 상태에 따라 열리고, 닫히게만 구현해주겠다.
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
const Modal = ({ isOpen, onClose, children }: ModalProps) => {
const handleClickInnerModal = (e: MouseEvent<HTMLDivElement>) => {
// ModalWrapper로 이벤트 전파 방지
e.stopPropagation();
};
return (
<>
{isOpen && (
<ModalPortal>
<ModalWrapper onClick={onClose}>
<ModalInner onClick={onClose}>
<div onClick={handleClickInnerModal}>{children}</div>
</ModalInner>
</ModalWrapper>
</ModalPortal>
)}
</>
);
};
위의 코드를 살펴보면 isOpen의 상태에 따라 보이고, 안보이는건 바로 알 수 있다.
나머지를 저렇게 한 이유는 이렇다.
handleClickInnerModal
함수는 모달이 여러개 겹쳤을 때 이벤트 전파를 방지하기위해 넣어줬다.이제 useModal을 사용하면 아래처럼 겹쳐지는 modal을 띄울 수 있게된다.
const App = () => {
const { Modal, closeModal, isOpen, openModal } = useModal();
return (
<>
<button onClick={openModal}></button>
<Modal
isOpen={isOpen}
onClose={closeModal}
>
<CategoryModal />
</Modal>
</>
);
};
export default App;