모달은 프론트 개발에 빼놓을 수 없는 UI 컴포넌트이다.
디자인이 나오면 모달로 다양한 요소들이 배치되어서 나오게 된다.
모달을 구현하는 것 보다는 모달을 어떻게 하면 효율적으로 관리할 수 있는지에 대한 방법을 적어보려고 한다.
모달은 react-modal
라이브러리를 사용했고, react 내장 함수인 context API를 사용했다.
참고한 블로그 : https://nakta.dev/how-to-manage-modals-1
참고한 블로그에서 내 프로젝트에 맞게 좀더 개편했다.(typescript 적용, 모달 애니메이션 효과 및 기본 라이브러리 기능 사용)
import { ComponentType, HTMLAttributes, createContext } from "react";
export type ModalComponent = ComponentType<any>;
export type ModalProps = Record<string, any> | HTMLAttributes<HTMLDivElement>;
export type ModalsState = Array<{ Component: ModalComponent; props?: ModalProps; isOpen: boolean }>;
export const ModalsStateContext = createContext<ModalsState>([]);
type ModalsDispatch = {
open: (Component: ModalComponent, props: ModalProps) => void;
close: (Component: ModalComponent) => void;
};
export const ModalsDispatchContext = createContext<ModalsDispatch>({
open: () => {},
close: () => {},
});
모달의 상태를 관리하는 컨택스트인ModalsStateContext
와 모달의 기능부분을 담당하는 컨택스트인 ModalsDispatchContext
를 만들어 준다.
이제 context를 주입시켜줄 provider를 만들어줘야하는데 다음과 같다.
const disableScroll = () => {
document.body.style.cssText = `
position: fixed;
top: -${window.scrollY}px;
overflow-y: scroll;
width: 100%;`;
};
const ableScroll = () => {
const scrollY = document.body.style.top;
document.body.style.cssText = "";
window.scrollTo(0, parseInt(scrollY || "0", 10) * -1);
};
const ModalsProvider = ({ children }: PropsWithChildren) => {
const [openedModals, setOpenedModals] = useState<ModalsState>([]);
const open = (Component: ModalComponent, props: ModalProps) => {
disableScroll();
setOpenedModals((modals) => {
const modalIndex = modals.findIndex((modal) => modal.Component === Component);
if (modalIndex !== -1) {
// 모달이 이미 배열에 있는 경우, 해당 모달의 isOpen 값을 true로 변경
modals[modalIndex].isOpen = true;
modals[modalIndex].props = props;
return [...modals];
}
return [...modals, { Component, props, isOpen: true }];
});
};
const close = (Component: ModalComponent) => {
ableScroll();
setOpenedModals((modals) =>
modals.map((modal) => (modal.Component === Component ? { ...modal, isOpen: false } : modal)),
);
};
const dispatch = useMemo(() => ({ open, close }), []);
return (
<ModalsStateContext.Provider value={openedModals}>
<ModalsDispatchContext.Provider value={dispatch}>
{children}
<Modals />
</ModalsDispatchContext.Provider>
</ModalsStateContext.Provider>
);
};
export default ModalsProvider;
openedModals
은 모달들을 관리하는 상태이다. 즉, 모달 컴포넌트, 모달의 props, 열려있는지의 여부(isOpen) 3가지를 배열로 가지는 상태이다. 모달을 열고 닫는 함수의 구현은 그렇게 어렵지 않다. isOpen을 각 모달별로 관리해주어야 react-modal
라이브러리의 기능을 사용할 수 있다.
const open = (Component: ModalComponent, props: ModalProps) => {
disableScroll();
setOpenedModals((modals) => {
const modalIndex = modals.findIndex((modal) => modal.Component === Component);
if (modalIndex !== -1) {
// 모달이 이미 배열에 있는 경우, 해당 모달의 isOpen 값을 true로 변경
modals[modalIndex].isOpen = true;
modals[modalIndex].props = props;
return [...modals];
}
return [...modals, { Component, props, isOpen: true }];
});
};
open 함수는 모달이 열렸을 때 scroll을 막는 disableScroll()
과 이미 있는 모달 컴포넌트가 아닐때만 open을 true로 만들어주는 로직을 담고 있다.
const close = (Component: ModalComponent) => {
ableScroll();
setOpenedModals((modals) =>
modals.map((modal) => (modal.Component === Component ? { ...modal, isOpen: false } : modal)),
);
};
close 함수는 더 간단하다. disable한 스크롤을 다시 able시키는 ableScroll()
과 해당 모달 컴포넌트의 isOpen 속성을 false로 해서 다시 모달을 닫아주면 된다.
Modals
컴포넌트<Modals/>
컴포넌트가 직접적으로 랜더링하는 부분인데 이부분은 다음과 같이 구현되어 있다.
export const modals = {
// 사용할 모달들
};
const Modals = () => {
const openedModals = useContext(ModalsStateContext);
const { close } = useContext(ModalsDispatchContext);
return openedModals.map((modal, index) => {
const { Component, props, isOpen } = modal;
if (!props) return null;
const { onSubmit, ...rest } = props;
const onClose = () => {
close(Component);
};
const handleSubmit = async () => {
if (typeof onSubmit === "function") {
await onSubmit();
}
onClose();
};
return <Component key={index} isOpen={isOpen} onClose={onClose} onSubmit={handleSubmit} {...rest} />;
});
};
export default Modals;
provider에서 세팅한 열려있는 모달들을 꺼내와서 반복 랜더링을 시켜주는 로직이다.
openedModals
를 돌리면서 해당 모달의 컴포넌트를 return해주고 props로 isOpen
, onClose
, onSubmit
을 넘겨주고 그 외의 props는 모두 넘겨주면 된다.
이때 onSubmit은 확인 버튼을 눌렀을때 사용할 함수로 개발 프로젝트에 맞춰서 변경이 필요하면 변경을 하면 된다.
위에서 만든 context 와 provider을 이제 적용하고 모달을 사용해야 할 때이다.
ReactModal.setAppElement("#root");
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ModalsProvider>
<App />
</ModalsProvider>
</React.StrictMode>,
);
react-modal
라이브러에서 어디에 모달을 이동시킬지(#root)를 정한뒤 provider로 감싸주면 된다.
이제 모달을 사용하기 쉽게 hook을 만들 차례이다.
export function useModals() {
const { open, close } = useContext(ModalsDispatchContext);
const openModal = (Component: ModalComponent, props: ModalProps) => {
open(Component, props);
};
const closeModal = (Component: ModalComponent) => {
close(Component);
};
return {
openModal,
closeModal,
};
}
ModalsDispatchContext
에서 만들어놓은 open, close함수를 가져와서 각각 모달을 열고 닫는 함수를 가진 hook을 만들어 주면 된다.
다음으로 모달을 어떻게 사용하면 되는지에 대한 예시이다.
export const Modal = ({ children, isOpen, onClose }: Props) => {
return (
<ReactModal
isOpen={isOpen}
contentLabel="modal"
closeTimeoutMS={150}
onRequestClose={onClose}
>
{children}
</ReactModal>
);
};
참고한 블로그에서는 모달이 열고 닫혔을 때 애니메이션을 넣을 수 없어서 변경한 사항 중 하나가 여기 있다.
react-modal을 사용할 때 isOpen
을 항상 true로 하면 안되고 다음과 같이 isOpen을 받아야 하고
onRequestClose
에 close 되는 함수를 props로 넣어주어야 한다. 마지막으로 closeTimeoutMS
에도 시간을 넣어줘야 동작을 한다.
// 특정 컴포넌트에서 modal을 오픈하고 싶을 떄
const { openModal } = useModals();
const clickHandler = (props: propsType) => {
openModal(modals.someModal, {
onSubmit: () => {}
props,
// .. 등등
});
};
이제 useModals
훅을 사용해서 오픈할 모달 컴포넌트와 원하는 props를 넘겨서 원하는 모달을 쉽고 간편하게 관리할 수 있게 된다.
modals.someModal
은<Modals/>
컴포넌트에서 설정한 모달 객체로 한번에 관리하기 위한 방법중 하나이다.
내가 구현한 방법은 상태관리 라이브러리를 사용하고 싶지 않아서 Context API를 도입했지만, recoil
, zotai
,zustand
와 같은 다른 전역 상태관리 라이브러리를 사용하더라도 로직은 같기에 구현에 크게 어려움은 있을 것 같지는 않다.
보다 자세한 구현 방법이나 이런 관리방법을 알게된 경로는 아까 위에 써놓은 참고 블로그를 먼저 읽고 보면 좋을 것 같다.
더 좋은 의견이 있다면 남겨주세요 :)
끗 🥳🥳🥳