React Context로 모달 관리하기
Table of contents
모달 컴포넌트 예시
예전에는 리액트 프로젝트에서 모달 컴포넌트를 렌더링할 때는 다음과 같은 방법을 주로 적용했다.
// Modal.tsx
interface ModalProps {
isOpen: boolean;
onClick: () => unknown;
onClose: () => unknown;
}
const Modal = (props: ModalProps) => {
const { isOpen, onClick, onClose } = props;
if (!isOpen) {
return <></>;
}
return (
<div>
<h2>Here is the modal.</h2>
</div>
);
};
export default Modal;
// Page.tsx
import Modal from "@/components/Modal";
const Page = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<main></main>
{isOpen &&
createPortal(
<Modal
isOpen={isOpen}
onClick={() => setIsOpen(false)}
onClose={() => setIsOpen(false)}
/>,
document.getElementById("modal")
)}
</div>
);
};
export default Page;
문제점 인식
페이지 내에서 모달이 열려야 하는지 판단하는 상태를 정의한 다음 해당 값과 모달 내 버튼을 클릭했을 때, 그리고 모달을 종료했을 때의 콜백 함수를 각각 Props로 전달함으로써 모달과 상호작용할 수 있었다.
프로젝트 내 모달의 종류가 점점 증가하게 되면서 다음과 같은 문제점들을 발견했다.
모달의 개수가 증가할 때마다 정의해야 하는 상태가 늘어난다. 각각의 모달이 어떤 컴포넌트의 상태와 연결되어 있는지, 어디서 렌더링되는지 파악하기 힘들어졌다.
페이지 내 모달이 한번에 여러 개 중첩돼서 보인다.
모달 하나를 닫을 때 여러 모달이 한꺼번에 닫히는 경우가 있었다.
3번의 경우 모달은 보통 페이지에서 모달 바깥 영역을 클릭할 때 닫히게 개발되어 있는 경우가 많다.
const modalRef = useRef(null as HTMLElement | null);
useEffect(() => {
const onClickOutside = function (event: MouseEvent) {
const { target } = event;
if (!(target instanceof HTMLElement)) {
return;
}
if (modalRef.current?.contains(target)) {
return;
}
onClose();
};
window.addEventListener("click", onClickOutside);
return () => {
window.removeEventListener("click", onClickOutside);
};
}, [onClose]);
브라우저 내 임의의 영역을 클릭하면 사용자가 클릭한 영역이 모달 내부에 위치하지 않을 때 콜백 함수를 호출하여 모달을 종료할 수 있다.
여러 개의 모달이 순서대로 출력되어야 하는 상황일 때 첫번째 모달을 종료할 때 나머지 모달까지 전부 비활성화되는 것을 확인했다.
React Context
이를 방지하기 위해 각 컴포넌트 내부에 상태를 정의하지 않고 React Context로 한 영역에서 관리해주기로 결정했다.
AppModal은 각 모달이 열렸는지의 여부 그리고 필수적인 데이터 타입이 명시되어 있다.
ModalContext의 Provider가 전체 컴포넌트에 상태를 제공하고 상태는
useReducer
로 선언한다.dispatch 함수에 인자를 전달할 때 타입을 보장하기 위한 Action 타입
컨텍스트를 변경하는
modalReducer
createContext
로 컨텍스트를 선언할 때는 Provider에서 값을 주입할 수 있어 우선undefined!
로 타입 에러가 발생하지 않게 한다.ModalProvider
로 진입점이 되는 컴포넌트를 포함하도록 작성한다.
// ./contexts/modal.tsx
export interface AppModal {
alarm: {
isOpen: boolean;
message?: string;
};
success: {
isOpen: boolean;
message?: string;
};
fail: {
isOpen: boolean;
reason?: string;
};
update: {
isOpen: boolean;
};
}
export interface AppModalAction<Key extends keyof AppModal = keyof AppModal> {
payload: {
key: Key;
value: AppModal[Key];
};
}
export const ModalContext = createContext({
store: undefined! as AppModal,
dispatch: undefined! as Dispatch<ModalAction<keyof AppModal>>,
});
const modalReducer = (state: AppModal, action: ModalAction) => {
const {
payload: { key, value },
} = action;
switch (key) {
case "alarm": {
state = {
...state,
[key]: value as AppModal["alarm"],
};
break;
}
case "success": {
state = {
...state,
[key]: value as AppModal["success"],
};
break;
}
case "fail": {
state = {
...state,
[key]: value as AppModal["fail"],
};
break;
}
case "update": {
state = {
...state,
[key]: value as AppModal["update"],
};
break;
}
}
return state;
};
export const ModalProvider = (props: PropsWithChildren) => {
const { children } = props;
const [store, dispatch] = useReducer(modalReducer, {
alarm: { isOpen: false, message: undefined },
success: { isOpen: false, message: undefined },
fail: { isOpen: false, reason: undefined },
update: { isOpen: false },
} satisfies AppModal);
return (
<ModalContext.Provider value={{ store, dispatch }}>
{children}
</ModalContext.Provider>
);
};
// ./src/App.tsx
const App = () => {
return (
<ModalProvider>
<RouterProvider router={Router} />
</ModalProvider>
);
};
각 컴포넌트에서 모달의 상태를 확인 및 업데이트할 수 있는 Hook을 작성해줬다.
- Props로 전달되는
key
는 undefined도 될 수 있기 때문에, 컨텍스트를 업데이트할 때 undefined인지 확인하는 과정이 필요했다.
import { useCallback, useContext, useMemo } from "react";
import { AppModal, ModalContext } from "~/contexts/modal";
interface UseModalContextProps<K extends keyof AppModal> {
key?: K;
}
const useModalContext = <K extends keyof AppModal>(
props: UseModalContextProps<K>
) => {
const { key } = props;
const { store, dispatch } = useContext(ModalContext);
const state = useMemo(() => {
if (key === undefined) {
return;
}
return store[key];
}, [store, key]);
const onChange = useCallback(
(value: AppModal[K]) => {
if (key === undefined) {
return;
}
dispatch({ payload: { key: key, value: value } });
},
[dispatch, key]
);
return { state, onChange };
};
export default useModalContext;
특정 모달이 비활성화 되어야 렌더링되는 컴포넌트가 있을 때의 Hook
interface UseModalsCloseProps<Keys extends string[]> {
keys?: Keys;
}
const useModalsClose = <Keys extends Array<keyof AppModal>>(
props: UseModalsCloseProps<Keys>
) => {
const { keys = [] } = props;
const isModalsClosed = useMemo(() => {
return keys.every((key) => !isModalOpens[key].isOpen);
}, [isModalOpens, keys]);
return { isModalsClosed };
};
이후 리팩토링
React Context의 Provider를 전체 컴포넌트에 적용할 때에는 상태가 업데이트될 때마다 전체 컴포넌트가 다시 렌더링되기 때문에
Redux Toolkit
,Jotai
등의 전역 상태 관리 라이브러리를 대신 채택하는게 사용자 경험에 더 좋을 것이라고 생각했다.만약 React Context를 채택해야 한다면 다음과 같은 작업이 필요할 거 같았다.
페이지가 렌더링될 때 자동으로 보여져야 하는 모달만 컨텍스트에 관리.
나머지 모달도 열렸는지 상태를 Props로 받지 않고 Hook에서 관리.
const useConfirmModal = () => {
const { isModalsClosed } = useModalsClose({
keys: ["alarm", "success", "fail", "update"],
});
const [isInnerOpen, setIsInnerOpen] = useState(true);
const isOpen = isModalsClosed && isInnerOpen;
const onClose = () => setIsInnerOpen(false);
return { isOpen, onClose };
};
const ConfirmModal = () => {
const { isOpen, onClose } = useConfirmModal();
if (!isOpen) {
return null;
}
return (
<Modal>
<h2>This is a confirm modal.</h2>
</Modal>
);
};
export default ConfirmModal;
Subscribe to my newsletter
Read articles from Nowon Lee directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by