모달 상태 관리를 Jotai에서 ContextAPI로 리팩토링 하기
-
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을 이용해 라우팅 변화가 감지되면 초기화로 리렌더링 되도록 작성했다.
Custom Hook
커스텀훅 내에 작성된 useEffect는 훅을 호출한 컴포넌트의 라이프사이클을 따른다.
Context Provider
Provider 컴포넌트에 작성된 useEffect는 컨텍스트를 구독 중인 모달 호출 컴포넌트의 렌더링과 독립적으로 동작한다. 즉, 호출 컴포넌트가 언마운트 되더라도 컨텍스트가 언마운트 되지 않을 수 있다.
컨텍스트가 언마운트 되는 시점에 혼동이 와서 간단한 예제로 테스트를 해보았다.
Example
//parent
return (
<div className={cx('container')}>
<button onClick={() => setToggle(!toggle)}>눌러주세요</button>
<ModalContextProvider>
{toggle && <Child />}
<ModalContextProvider>
</div>
);
`<ModalContextProvider>`는 Root를 감싸고 있고, `<Parent/ >`내부 `<Child />`를 따로 한 번 더 감싸고 있다. 그리고 `<Child />` 에서도 모달을 띄울 수 있도록 했다.
이 상황에서 내가 예상했던 결과는 다음과 같다.
결과는 열린 모달이 닫히지 않는다.
`<ModalContextProvider>`를 가지고 있는 `<Parent/ >`가 언마운트 되지 않았기 때문이다. 이 문제는 `<ModalContextProvider>`로 감싼 `<Child />`컴포넌트를 가지는 컴포넌트를 작성하면(Provider가 toggle에 의해 언마운트 될 수 있도록) 해결할 수 있지만 컴포넌트 depth가 불필요하게 깊어진다.
🎯 목표: 동적으로 전달받는 모달 컴포넌트를 호출 컴포넌트의 자식으로 렌더링 한다.
모달에 여러 제약이 생기는 이유 중 하나는 호출하는 컴포넌트와 보여주고 싶은 컴포넌트가 다르면서도 열려야 하는 위치는 정해져 있기 때문이다. `View`로서는 `Root`의 자식(혹은 외부)이어야 하고, 모달 자체는 호출부의 자식이어야 호출 컴포넌트와 동일한 데이터를 사용하고, 싱크를 맞추기 쉽다.
[참고] createPortal 공식 문서
const ModalPortal = ({ children }: PropsWithChildren) => {
return createPortal(children, document.getElementById('modal-root')!);
};
뷰를 `Root`에서 빼내어 외부에 열릴 수 있도록 개선한다. `ModalPortal` 컴포넌트로 감싸진 `children`은 외부 `modal-root`에 렌더링 된다.
>_ 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 컴포넌트`에서 이루어진다.
`renderModal`은 함수로 호출하여 `modals` 상태에 저장된 모든 모달을 순차적으로 반환한다. 첫 렌더링에는 빈 배열이기 때문에 열리지 않았을 뿐 함수는 호출된다. 이 때문에 모달을 열고 페이지를 이동하면 닫힌 듯 보이지만, 다시 이전 페이지로 돌아왔을 때 모달이 나타나는 마술🧙🏻🪄을 볼 수 있다.
이 문제를 해결하기 위한 방법으로 2가지를 떠올렸지만 너무 번거롭거나 열고 닫는 로직을 재사용하는 커스텀훅과 다를 것이 없기 때문에 지금까지의 개선이 무의미했다.
React 전역 Modal 추상화 정복기 - 마지막 (0) | 2024.04.16 |
---|---|
React 전역 Modal 추상화 정복기 - (5) (1) | 2024.04.14 |
React 전역 Modal 추상화 정복기 - (4) (0) | 2024.04.13 |
React 전역 Modal 추상화 정복기 - (3) (0) | 2024.04.13 |
React 전역 Modal 추상화 정복기 - (1) (2) | 2024.04.13 |