효율적인 모달 관리
가끔 모달을 만들다 보면 onClose, onOpen, isOpen 이런 값들을 props로 넘기고 넘기고 넘기고...
동일한 작업을 계속 하게됩니다. 이러한 현상은 Props Drilling 현상이라고 하며, 당연하게도 프로젝트가 커질수록 좋지 않은 구조입니다.
그럼 방법은 뭘까요? 이번 아티클에서는 리액트에서 효율적으로 모달 상태를 관리하는 법을 다뤄보도록 하겠습니다.
const [isAModalOpen, setIsAModalOpen] = useState(false);
const [isBModalOpen, setIsBModalOpen] = useState(false);
const [isCModalOpen, setIsCModalOpen] = useState(false);
const handleAOpen = () => {
setIsModalOpen(true);
}
const handleAClose = () => {
setIsModalOpen(false);
}
const handleBOpen = () => {
setIsModalOpen(true);
}
const handleBClose = () => {
setIsModalOpen(false);
}
const handleCOpen = () => {
setIsModalOpen(true);
}
const handleCClose = () => {
setIsModalOpen(false);
}
기본적으로 모달을 만들 때 사용되어질 코드입니다. 하나의 컴포넌트에서 여러 개의 모달을 관리한다면 다음과 같이 작성해야 합니다.
위 코드는 누가봐도 비효율적으로 보이고 개선해야 할 점이 많아 보입니다.
여러 개의 모달을 관리하게 된다면 좀 더 효율적인 모달을 설계해야 할 필요성이 보입니다.
본 예제에서는 Redux를 사용하나, Context API 등으로 구현하셔도 아무런 문제가 되지 않습니다.
구현 방식을 논리적으로 한번 살펴보도록 합시다.
Store에서 모달 컴포넌트들을 다룹니다. openModal 함수를 실행하면, 전역 상태 객체인 openedModals에 추가합니다.
여기서 특이한 점이 있는데, key값을 받는다는 점입니다. key값을 기반으로 구분을 한다는 점입니다.
import { AnyAction } from 'redux';
export const OPEN_MODAL = 'OPEN_MODAL' as const;
export const CLOSE_MODAL = 'CLOSE_MODAL' as const;
export const CLOSE_ALL_MODAL = 'CLOSE_ALL_MODAL' as const;
interface ModalType {
Component: React.ElementType;
props: { [key: string]: unknown }
}
interface ModalStateType {
openedModals: { [key: string]: ModalType };
}
const initialState: ModalStateType = {
openedModals: {},
};
function modalReducer(state = initialState, action: AnyAction): ModalStateType {
switch (action.type) {
case OPEN_MODAL:
return {
...state,
openedModals: {
...state.openedModals,
[action.payload.modalKey]: {
Component: action.payload.Component,
props: action.payload.props,
},
},
};
case CLOSE_MODAL: {
const temp = { ...state.openedModals };
delete temp[action.payload.modalKey];
return {
...state,
openedModals: temp,
};
}
case CLOSE_ALL_MODAL:
return {
...state,
openedModals: {},
};
default:
return { ...state };
}
}
export default modalReducer;
이렇게 구분하면 얻는 이점은 여러 개의 모달을 효율적으로 관리할 수 있다는 점입니다.
로직을 하나하나 훑어보도록 하겠습니다. OPEN_MODAL 케이스는 openedModals에 새로운 모달을 추가합니다.
CLOSE_MODAL 케이스는 openedModals에서 키값을 제거합니다.
그럼, 이 리듀서를 기반으로 Hooks를 작성해보도록 하겠습니다.
import { useDispatch } from 'react-redux';
import { useTypedSelector } from '@/modules';
import { CLOSE_ALL_MODAL, CLOSE_MODAL, OPEN_MODAL } from '@/modules/modal';
const useModals = () => {
const { openedModals } = useTypedSelector((state) => state.modal);
const dispatch = useDispatch();
const openModal = (
modalKey: string,
Component: React.ElementType,
props?: { [key: string]: unknown },
) => {
dispatch({
type: OPEN_MODAL,
payload: {
modalKey,
Component,
props,
},
});
};
const closeModal = (modalKey: string) => {
dispatch({
type: CLOSE_MODAL,
payload: {
modalKey,
},
});
};
const closeAllModal = () => {
dispatch({
type: CLOSE_ALL_MODAL,
});
};
return {
openedModals,
openModal,
closeModal,
closeAllModal,
};
};
export default useModals;
그러면 openModal, closeModal이라는 액션을 디스패치하는 함수를 실전 예제에서 한번 사용해 볼까요?
const { openModal } = useModals();
...
openModal('login_require', LoginRequireModal);
...
const LoginRequireModal = () => {
const navigate = useNavigate();
const handlePushLogin = () => navigate('/login');
return (
<StyledModal.LoginRequireModal>
<span className="require_content">
<span className="decoration">로그인</span>
이 필요한 서비스입니다.
</span>
<button onClick={handlePushLogin} type="button">확인</button>
</StyledModal.LoginRequireModal>
);
};
다시 한번 전체적인 흐름을 이해해보도록 할까요?
- openModal 함수를 실행한다. (파라미터로 modalKey, Component, props를 전달해줍니다.)
- OPEN_MODAL 액션이 디스 패치된다.
- Modal 리듀서에서 openedModals에 객체의 openedModals: {... openedModals, [modalKey]: { Component } } 형식으로 추가가 된다.
자, 그럼 모달을 openedModals에 추가하는 작업을 했으니까, 렌더링을 하는 로직을 작성해보도록 하겠습니다.
먼저 Modal 컨테이너를 만들어줍시다.
import React from 'react';
import useOutsideRef from '@/hooks/useOutsideRef';
import S from './StyledModal';
interface ModalProps {
children: React.ReactElement;
outsideClickHandler: () => void;
}
const Modal: React.FC<ModalProps> = ({
children,
outsideClickHandler,
}) => {
const outsideRef = useOutsideRef(outsideClickHandler);
return (
<>
<S.Container>
<S.Article
ref={outsideRef}
>
{children}
</S.Article>
</S.Container>
</>
);
};
export default Modal;
이 컨테이너는 기본적인 모달의 형태를 구성하고, 바깥을 클릭할 때 실행되는 함수를 명시합니다. (클릭 시 모달을 닫기 위해.)
이제 모달 컨테이너를 사용해서 각 모달을 렌더링 해보도록 하겠습니다.
import useModals from '@/hooks/useModals';
import ModalPortal from './ModalPortal';
import Modal from '@/components/Modal';
const ModalsProvider = () => {
const {
openedModals,
closeModal,
} = useModals();
const modalsMap = Object.entries(openedModals)
.map(([modalKey, { Component, props }]) => {
const handleOutsideClick = () => {
closeModal(modalKey);
};
return (
<Modal
key={modalKey}
outsideClickHandler={handleOutsideClick}
>
<Component {...props} />
</Modal>
);
});
return (
<ModalPortal>
{modalsMap}
</ModalPortal>
);
};
export default ModalsProvider;
useModals훅을 통해서 openedModals를 가져와서 매핑을 해줍니다.
객체 형태를 map 하기 위해서 entires 메서드를 이용합니다.
그다음, ModalPortal을 이용해서 index.html에 명시한 포탈에 렌더링을 수행합니다.
import ReactDOM from 'react-dom';
const ModalPortal = ({ children }) => {
const el = document.getElementById('modal-root');
return ReactDOM.createPortal(children, el);
};
export default ModalPortal;
Portal이란? DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링 하는 최고의 방법
React Portal에 관해서는 나중에 글을 작성할 예정입니다.
이제 마지막 단계입니다.
만들었던 ModalsProvider를 루트 컴포넌트에 렌더링 합니다.
import {
BrowserRouter,
Routes,
Route,
} from 'react-router-dom';
import Pages from '@/pages';
import ModalsProvider from './components/Modal/ModalsProvider';
function RouteContainer() {
return (
<BrowserRouter>
<Routes>
<Route path="" element={<Pages.PopularPage />} />
<Route path="recent" element={<Pages.RecentPage />} />
<Route path="post">
<Route path=":postId" element={<Pages.PostView />} />
</Route>
<Route path="user">
<Route path=":userId" element={<Pages.ProfilePage />} />
</Route>
<Route path="setting" element={<Pages.SettingPage />} />
<Route path="write" element={<Pages.WritePostPage />} />
<Route path="login" element={<Pages.LoginPage />} />
<Route path="join" element={<Pages.JoinPage />} />
<Route path="search" element={<Pages.SearchPage />} />
<Route
path="*"
element={<>Not Found</>}
/>
</Routes>
<ModalsProvider />
</BrowserRouter>
);
}
export default RouteContainer;
이때 저는 Routes컴포넌트의 자식으로 렌더링을 진행했는데요, 그 이유는 모달에서 useNavigate 같은 Hook을 사용하려면 <Routes />의 자식으로 존재하는 컴포넌트여야 합니다.
네 오늘은 이렇게 전역적으로 모달을 관리하는 방법에 대해서 알아보았습니다. Key값을 기반으로 모달 렌더링을 진행하니까 각 모달마다 isOpen 상태를 명시해놓을 필요도 없어서 더 간편한 것 같습니다.
감사합니다.