진행중인 프로젝트에서 위의 서비스 준비중 모달을 구현하면서 겪은 일이다.
위와 같이, 레시피에 대한 후기 작성 버튼을 클릭했을 경우 모달이 나타나게 하면 되는 단순한 작업이었다.
기능에 대해 조금 더 설명을 덧붙이자면... ReviewList 안에 들어있는 ReviewItem들을 가로로 스크롤하면서 다른 사람이 남긴 리뷰 사진들을 감상할 수 있는 기능이며, 좌측의 CreateReview, 그러니까 "저도 자랑할래요"를 클릭하면 리뷰를 남길 수 있는 리뷰 작성 페이지로 이동하게 만들 생각이었다.
처음 기획 당시에는 이런 기능이었는데, 기능을 축소하게 되면서 클릭할 경우 서비스 준비중 모달을 띄우는 것으로 변경했다.
하지만 막상 해보니 어라라?
이렇게 좌측에 빈 공간이 생겨버렸다.
모달의 position을 fixed로 설정해두었는데, "저도 자랑할래요"가 적혀있는 부모 컴포넌트, 그러니까 CreateReview 컴포넌트를 컨테이닝 블록으로 삼으면서 발생한 문제였다.
컴포넌트 구조는 아래와 같았다.
Review 컴포넌트를 그림으로 그리면 아래와 같았다.
CreateReview 안의 Btn이 클릭되면 isModalOpen이라는 state를 변경하고, 이을 통해서 모달을 제어하면 되지 않을까? 라고 굉장히 심플하게 생각했다.
다만, 그 모달이 CreateReview 안에 들어있었던게 문제일 뿐이었다.
isModalOpen이라는 state는 Btn의 클릭에 따라 변하는 값이므로, CreateReview의 안에 Btn과 함께 존재한다. 그리고 이런 isModalOpen의 변경을 감지하여 렌더링되는 Modal은 마찬가지로 CreateReview 안에 존재해야 한다고 생각했다.(그래야 편하기도 하고.)
그래서 Modal이 CreateReview를 컨테이닝 블록으로 삼게 된 것이다.
물론 contextAPI를 사용하거나 Recoil등의 상태관리 라이브러리를 통해 isModalOpen을 상위 컴포넌트로 넘겨주고 Modal도 상위 컴포넌트에서 렌더링한다는 선택지도 있긴 했으나, 가능한 CreateReview의 내부에서 필요한 모든 기능을 구현하고 싶었다. 기능이 수정됨에 따라 사라질 수도 있는 컴포넌트였기도 하고, 구체적으로 어떤 역할을 하게될지 명확하지 않은 상황에서 여러군데에 필요한 코드를 작성해놓을 경우 나중에 수정하기가 불편해질 수 있다고 생각했다.(아톰이라던지... 아톰을 계속 만드는 것도 조금 찝찝한 기분이 들었다.)
그리하여 결국 CreateReview 안에 모달과 버튼이 함께 들어가있는 상황이 되었고, 옳지못한 UI를 바로잡을 방법을 찾아야했다.
첫 번째로 고려한 방법은 CSS를 수정하는 방법이었다.
Modal 컴포넌트의 최상위 div에 transform을 주어서 좌측 간격만큼 옮겨주었다.
어라라~? 이렇게 간단하게 해결한다고?!
const ErrorModalWrapper = styled(motion.div)`
position: fixed;
top: 0;
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
transform: translateX(-16px); //이렇게!
`;
결과는...?
성공!
이 아니라 실패였다.
모바일 환경에서는 성공한 것처럼 보이지만, 화면이 확장되면 문제가 드러난다.
16px만큼 옮긴다고 해결될 문제가 아니었던 것이다 ㅠㅠ
저 안타까운 Modal을 해방시켜주기 위해서는 결국 '상위 컴포넌트에 렌더링하여 viewport를 컨테이닝 블록으로 삼게 하는 것'이 관건이었다.
어쩔 수 없이 isModalOpen을 상위 컴포넌트로 넘겨줘야하나.. 라는 생각을 하면서 혹시나 방법이 있을까 조언을 구했다.
참고한 블로그 (인프랩 어텀)
https://velog.io/@dyongdi/React-Hooks-Portals
역시 조언을 구하길 잘했다는 생각이 든다!
전에 동일한 고민을 했었던 분이 계셔서 당시 작성하신 블로그 글을 공유받았다.
Portals...?
어딘가의 게임 이름과 같다는 생각을 했는데, 바로 밸브의 포탈이라는 게임이다.
보이는 것처럼, 한쪽으로 들어가면 다른 쪽으로 나오는 포탈을 생성하여 퍼즐을 풀어나가는 게임인데...
어쨌든! 리액트의 Portals도 비슷한 기능을 제공하고 있었다.
Portals을 알아보도록 하자.
React에서 자식 컴포넌트들은 부모 컴포넌트의 DOM 안에서 렌더링되어야 했지만 Portals를 사용하면 부모 컴포넌트 바깥에 렌더링할 수 있다.
Portals를 사용해 Modal을 구현하기 위해서는
//index.html
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<div id="modal"></div>
</body>
root아래에 modal이라는 id를 가진 새로운 DOM을 만들어주었다.
//ModalPortal.js
import ReactDOM from 'react-dom';
const ModalPortal = ({ children }) => {
const el = document.getElementById('modal');
return ReactDOM.createPortal(children, el);
};
export default ModalPortal;
이 컴포넌트로 감싸진 children들을 modal로 보내는 포탈을 만드는 과정이다.
//CreateReview.jsx
function CreateRecipeReview() {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleModal = () => {
setIsModalOpen((prev) => !prev);
};
return (
<>
<ModalPortal>
{isModalOpen ? (
<ErrorModal isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} />
) : null}
</ModalPortal>
<CreateRecipeReviewContainer onClick={handleModal}>
<PlusIcon />
<Subtitle>저도 자랑할래요</Subtitle>
</CreateRecipeReviewContainer>
</>
);
}
export default CreateRecipeReview;
이제 Modal이 App 바깥에 렌더링되면서 원하는 형태가 되었다.
Modal 구현할때 굉장히 유용하게 쓰일 것 같다. 앞으로도 자주 써먹어야지.
원하는 곳에 컴포넌트를 렌더링 시키면서 상태관리는 기존처럼 할 수 있음!
그리고 App 안에서도 사용할 수 있다고 함!
참고 블로그 (벨로퍼트)
https://velog.io/@velopert/react-portals