React 전역 Modal 추상화 정복기 - (2)
모달 상태 관리를 Jotai에서 ContextAPI로 리팩토링 하기
-
ContextAPI로 리팩토링 하면서 리액트 라이프사이클 활용법과 뷰와 렌더의 분리로 얻게 되는 이점을 알아본다.
1. 👋 Bye Jotai , 🙌 Hello ContextAPI
>_ ModalContext.tsx
const ModalContextProvider = ({ children }: PropsWithChildren) => {
const [modals, setModals] = useState<JSX.Element[]>([]);
const location = useLocation();
const openModal = (element: JSX.Element) => setModals((prev) => [...prev, element]);
const closeModal = () => setModals((prev) => prev.slice(0, -1));
const ModalStack = () => modals.map((modal, idx) => <Modal key={idx} modal={modal} />);
useEffect(() => {
setModals([]);
}, [location]);
return (
<modalContext.Provider value={{ openModal, closeModal }}>
{children}
{ModalStack()}
</modalContext.Provider>
);
};
export const useModal = () => useContext(modalContext);
`context`를 반환하는 커스텀훅을 만들고, Provider를 사용하는 것 외에는 크게 달라진 점이 없지만 `modals`를 초기화하는 부분과 ModalStack이 적용되는 부분이 달라졌다. 이번에는 useLocation을 이용해 라우팅 변화가 감지되면 초기화로 리렌더링 되도록 작성했다.
Hook과 Context Provider에서의 useEffect 차이
Custom Hook
커스텀훅 내에 작성된 useEffect는 훅을 호출한 컴포넌트의 라이프사이클을 따른다.
- 그 이유로 useEffect의 언마운트 시점에 clean-up을 해주면 커스텀훅을 호출한 컴포넌트가 언마운트 될 때, `modals` 상태가 초기화된다.
Context Provider
Provider 컴포넌트에 작성된 useEffect는 컨텍스트를 구독 중인 모달 호출 컴포넌트의 렌더링과 독립적으로 동작한다. 즉, 호출 컴포넌트가 언마운트 되더라도 컨텍스트가 언마운트 되지 않을 수 있다.
- 보통 Provider 컴포넌트는 Root 컴포넌트를 감싸도록 작성하는 경우가 많은데 그렇게 되면 서비스가 실행되고 있는 중에는 해당 컴포넌트는 언마운트 되지 않는다. 그 이유로 Context에서의 모달 언마운트는 라우팅 변화에 따라 Provider 컴포넌트를 리렌더링 하도록 작성해야 했다.
Context Unmount Test
컨텍스트가 언마운트 되는 시점에 혼동이 와서 간단한 예제로 테스트를 해보았다.
Example
//parent
return (
<div className={cx('container')}>
<button onClick={() => setToggle(!toggle)}>눌러주세요</button>
<ModalContextProvider>
{toggle && <Child />}
<ModalContextProvider>
</div>
);
`<ModalContextProvider>`는 Root를 감싸고 있고, `<Parent/ >`내부 `<Child />`를 따로 한 번 더 감싸고 있다. 그리고 `<Child />` 에서도 모달을 띄울 수 있도록 했다.
이 상황에서 내가 예상했던 결과는 다음과 같다.
- 눌러주세요 버튼으로 toggle이 true가 되면서 <Child />가 렌더링 된다.
- <Child />에서 모달을 띄운다.
- <Parent/ >에서 눌러주세요 버튼을 눌러 <Child />를 언마운트 한다.
- <Child />에서 열린 모달이 닫힌다.
결과는 열린 모달이 닫히지 않는다.
`<ModalContextProvider>`를 가지고 있는 `<Parent/ >`가 언마운트 되지 않았기 때문이다. 이 문제는 `<ModalContextProvider>`로 감싼 `<Child />`컴포넌트를 가지는 컴포넌트를 작성하면(Provider가 toggle에 의해 언마운트 될 수 있도록) 해결할 수 있지만 컴포넌트 depth가 불필요하게 깊어진다.
🚨 중간점검
- Context로 변경하면서 호출 컴포넌트와 열린 모달 컴포넌트의 라이프사이클을 맞추기 위해 확인할 사항이 많아졌다.
- 모달은 한 군데에서 열리지만, Provider는 여러 개가 될 수 있다.
- 서비스에 단 1개의 전체화면 모달을 여는 것은 ContextAPI나 Jotai나 큰 차이가 없다. 오히려 이 경우에는 호출 컴포넌트의 라이프사이클을 따르는 Jotai가 관리하기 쉬울 수 있다.
⚠️ 문제
- 리팩토링 후 모달의 상태가 `Context`의 라이프사이클에 영향을 받고 있다. 라우팅 변화 혹은 유저가 직접 닫는 경우 외 다른 이유로 모달 호출 컴포넌트가 언마운트 되어도 Context가 언마운트 되지 않아 모달이 닫히지 않는 경우가 생길 수 있다.
- 서버 상태 관리를 위한 `Tanstack-Query`도 Provider를 사용해야 한다. 그리고 모달 내에서도 서버 상태에 접근해야 하기 때문에 `QueryClientProvider`가 `ModalContextProvider`보다 상위에 있어야 한다. 하지만 일반적으로 모달은 뷰와 관련되어 모든 콘텐츠 위에서 열리기 때문에 가장 상위에 있어야 자연스럽다.
2. 뷰와 렌더 분리하기
🎯 목표: 동적으로 전달받는 모달 컴포넌트를 호출 컴포넌트의 자식으로 렌더링 한다.
모달에 여러 제약이 생기는 이유 중 하나는 호출하는 컴포넌트와 보여주고 싶은 컴포넌트가 다르면서도 열려야 하는 위치는 정해져 있기 때문이다. `View`로서는 `Root`의 자식(혹은 외부)이어야 하고, 모달 자체는 호출부의 자식이어야 호출 컴포넌트와 동일한 데이터를 사용하고, 싱크를 맞추기 쉽다.
createPortal
[참고] createPortal 공식 문서
const ModalPortal = ({ children }: PropsWithChildren) => {
return createPortal(children, document.getElementById('modal-root')!);
};
뷰를 `Root`에서 빼내어 외부에 열릴 수 있도록 개선한다. `ModalPortal` 컴포넌트로 감싸진 `children`은 외부 `modal-root`에 렌더링 된다.
React.Element를 렌더링 하는 함수
>_ ModalContext.tsx
export const ModalContextProvider = ({ children }: PropsWithChildren) => {
const [modals, setModals] = useState<JSX.Element[]>([]);
const openModal = (element: JSX.Element) => setModals((prev) => [...prev, element]);
const closeModal = () => setModals((prev) => prev.slice(0, -1));
const renderModal = () =>
modals.map((modal, idx) => (
<ModalPortal key={idx}>
<Modal key={idx} modal={modal} />
</ModalPortal>
));
return <modalContext.Provider value={{ openModal, closeModal, renderModal }}>{children}</modalContext.Provider>;
};
`renderModal` 함수는 modals를 순회하며 `ModalProtal`로 감싸 렌더링 한다. children인 `Modal`은 ModalPortal을 통해 외부에서 열리게 된다.
Example
const Home = () => {
const { openModal, renderModal } = useModal();
const handleModal = () => openModal(<Confirm />);
return (
<>
<button onClick={handleModal}>Modal Open!</button>
{renderModal()}
</>
);
};
`renderModal` 함수가 호출되는 곳이 모달 컴포넌트가 렌더링 되는 위치가 된다.
즉, 뷰는 외부 `modal-root`에서 열리지만, 렌더링은 `Home 컴포넌트`에서 이루어진다.
✨ 결과
- 뷰와 렌더를 분리하게 되면서 모달 컴포넌트는 호출 컴포넌트의 자식으로 렌더링 된다.
- 반환되는 element는 외부 modal-root에 포함된다.
⚠️ 문제
`renderModal`은 함수로 호출하여 `modals` 상태에 저장된 모든 모달을 순차적으로 반환한다. 첫 렌더링에는 빈 배열이기 때문에 열리지 않았을 뿐 함수는 호출된다. 이 때문에 모달을 열고 페이지를 이동하면 닫힌 듯 보이지만, 다시 이전 페이지로 돌아왔을 때 모달이 나타나는 마술🧙🏻🪄을 볼 수 있다.
- ModalContextProvider 작게 쪼개 사용하기
- isOpen 속성 제어
이 문제를 해결하기 위한 방법으로 2가지를 떠올렸지만 너무 번거롭거나 열고 닫는 로직을 재사용하는 커스텀훅과 다를 것이 없기 때문에 지금까지의 개선이 무의미했다.