먼저 createPortal
을 활용하여 Modal 컴포넌트를 만들었습니다. 그리고 모달의 열림/닫힘 상태를 커스텀 훅으로 관리하였습니다.
// 커스텀 훅
const useModal = (initIsOpen) => {
// isOpen: 모달 열림 여부
const [isOpen, setIsOpen] = useState(initIsOpen || false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return { isOpen, open, close };
};
// 모달 컴포넌트
const Modal = ({
isOpen,
onClose,
children,
}) => {
const modalRef = useRef(null);
if (!isOpen) return null;
return createPortal(
<BackDrop
onClick={(e) => {
// 모달 외부를 클릭하면 닫히는 기능
if (modalRef.current && !modalRef.current.contains(e.target)) {
onClose(e);
}
}}
>
<ModalLayout ref={modalRef}>
{children}
</ModalLayout>
</BackDrop>,
document.body
);
};
// 모달 컴포넌트 사용
const Component = () => {
const { isOpen, open, close } = useModal();
return (
<div>
<button onClick={open}>open</button>
<Modal isOpen={isOpen} onClose={close}>
<div>modal</div>
</Modal>
</div>
)
}
위와 같은 Modal 컴포넌트를 사용하였으나, 기능이 추가될수록 아래와 같은 불편함을 느꼈습니다.
하나의 Page 컴포넌트에서 여러 개의 모달이 존재하는 경우가 있었습니다. 이 경우에는 각 모달을 useModal
커스텀 훅으로 열림/닫힘 상태를 관리하였습니다.
여러분은 위 코드에 대한 문제점을 발견하셨나요? 제가 느낀 문제점은 크게 세가지 입니다.
첫 번째로, 모달이 추가될 때마다 useModal
훅을 반복해서 호출해야 합니다. 그래서 모달이 추가되면, 커스텀 훅에서 반환되는 객체 프로퍼티의 이름을 새로 지어줘야했습니다.
두 번째는 Page
컴포넌트에서 모달 관련 코드가 분산되어 있습니다. 열림/닫힘 상태, 모달 관련 핸들러, 모달 컴포넌트가 흩어져 있기 때문에, '이 버튼을 누르면 어떤 모달이 열리는지' 찾기 위해 매번 위로 올라가서 useModal
를 확인했습니다.
마지막으로 모달 컴포넌트가 모달을 사용하는 부모 컴포넌트에 결합되어 있다는 점입니다. createPortal
을 사용하게 되면 실제로 부모 컴포넌트에서 벗어나 다른 DOM에서 렌더링이 되지만, React 컴포넌트 트리에서는 자식 컴포넌트처럼 작동합니다. 이로 인해 포탈로 만들어진 컴포넌트에서 부모 컴포넌트로 이벤트가 전파됩니다.
또한 react dev tools의 Components에서 Page 컴포넌트의 자식 컴포넌트로 Modal
컴포넌트가 있는 것을 보고 여전히 부모-자식 관계가 유지되고 있음을 확인할 수 있었습니다.
다음은 modal(FirstModal
) 안에 버튼을 클릭하면 또 다른 modal(SecondModal
)이 나오는 코드입니다.
위와 같은 코드로 실행했더니, SecondModal
의 close second modal
버튼이나 모달 외부를 클릭하면 FirstModal
도 같이 닫혔습니다. 왜그럴까요?
(1번 문제점에서 언급한 내용에 힌트가 있습니다!)
모달이 포탈이어도 React 트리에서 자식 컴포넌트처럼 실행되기 때문입니다. 따라서 아래에 나열된 과정처럼 모달에서 이벤트가 발생하면 부모 컴포넌트로 이벤트가 전파됩니다.
close second modal
버튼을 눌렀을 경우SecondModal
닫힘FirstModal
내부의 Modal
컴포넌트로 이벤트가 전파되어 이벤트 핸들러 실행Modal
컴포넌트의 클릭 이벤트 핸들러는 모달 외부를 클릭했을 때 닫히도록 하기 위해 걸어두었음)FirstModal
외부를 클릭했다고 판단하여 모달 닫힘SecondModal
외부를 클릭할 경우SecondModal
내부의 Modal
컴포넌트에 설정된 클릭 이벤트 핸들러 실행SecondModal
닫힘FirstModal
내부의 Modal
컴포넌트로 전파됨FirstModal
외부를 클릭했다고 판단하여 모달 닫힘이번 리팩토링의 핵심 목표는 모달을 사용하는 부모 컴포넌트가 모달의 상태 관리와 JSX 위치 설정에 신경 쓰지 않고, 오직 모달 렌더링에만 집중하는 것이었습니다.
export default function Page() {
const { open } = useModals();
return (
<>
<button
onClick={() => {
open(({ isOpen, onClose }) => <SecondModal isOpen={isOpen} onClose={onClose} />);
}}
>
open modal
</button>
</>
);
}
모달을 열고 싶을 때, useModals
커스텀 훅의 반환값인 open
함수를 호출하면 됩니다!
위처럼 구현하면, 모달에 대한 상태, 핸들러, JSX를 한 곳에서 정의할 수 있게 됩니다.
그럼 이제 useModals
를 만들어볼까요?
모달 컴포넌트들을 context로 관리하고, 이 모달들을 root 요소 바로 밑에 렌더링하는 방식으로 설계했습니다. 이렇게 관리하기 위해 크게 2가지를 구현하면 됩니다.
이 기능들이 이미 구현되어 있다고 가정하고 코드를 작성한 후, 구현 방법을 설계해보겠습니다.
const { open } = useModals();
open(({ isOpen, onClose }) => <SecondModal isOpen={isOpen} onClose={onClose} />);
위와 같은 useModals를 만들기 위해, open의 첫 번째 파라미터는 isOpen과 onClose를 props로 가지는 컴포넌트 함수여야 합니다.
type ModalComponentFunctionType = (params: { isOpen: boolean; onClose: () => void }) => JSX.Element;
open을 호출할 때 uuid 라이브러리로 모달의 고유 id를 생성하고, 나중에 해당 id로 모달을 식별합니다.
사용자가 모달 id를 직접 설정하고 싶어하는 경우를 고려하여, 두 번째 파라미터에 modal id를 받을 수 있도록 합니다.
(useModalContext는 modal context를 반환하는 훅으로 나중에 다시 설명하겠습니다. 이미 구현되어 있다고 가정하고 봐주세요.)
type ModalOptions = { modalId: ModalIdType };
const useModals = () => {
const { push } = useModalContext();
const open = (modalComponent: ModalComponentFunctionType, options?: ModalOptions) => {
const id = options?.modalId ?? uuidv4();
push({ id, modalComponent });
};
return { open };
};
import { v4 as uuidv4 } from 'uuid';
type ModalComponentFunctionType = (params: { isOpen: boolean; onClose: () => void }) => JSX.Element;
type ModalIdType = string;
type ModalOptions = { modalId: ModalIdType };
const useModals = () => {
const { push } = useModalContext();
const open = (modalComponent: ModalComponentFunctionType, options?: ModalOptions) => {
const id = options?.modalId ?? uuidv4();
push({ id, modalComponent });
};
return { open };
};
모달 컴포넌트들을 context에서 상태로 관리할 예정인데요. useState와 context로 충분히 구현 가능하고, 성능에 크게 문제가 없었기에 상태 관리 라이브러리로 구현하지 않아도 되겠다고 판단했습니다.
useModals의 open을 호출하면 다음 과정을 거칩니다.
// 1. open 호출
const { open } = useModals();
open(({ isOpen, onClose }) => <SecondModal isOpen={isOpen} onClose={onClose} />);
// 2. open에서 push 호출
const { push } = useModalContext();
push({ id, modalComponent: ({ isOpen, onClose }) => <SecondModal isOpen={isOpen} onClose={onClose} /> });
// 3. context에서 관리되는 모달들 상태 변경
이 3번 "context에서 관리되는 모달들 상태 변경"을 구현하면 되겠네요!
모달들을 관리하는 상태
모달들을 관리하는 modalList 상태를 선언해줍니다.
export type ModalIdType = string;
export type ModalComponentFunctionType = (params: { isOpen: boolean; onClose: () => void }) => JSX.Element;
interface ModalType {
id: ModalIdType;
isOpen: boolean;
modalComponent: ModalComponentFunctionType;
}
const [modalList, setModalList] = React.useState<ModalType[]>([]);
modalList는 아래와 같은 요소로 구성되어 있습니다. 위에서 본 코드의 ModalType이 아래 요소의 타입입니다.
{
id: 'abc-123',
isOpen: false,
modalComponent: ({ isOpen, onClose }) => <SecondModal isOpen={isOpen} onClose={onClose} />
}
modalList에 새로운 모달 추가하는 함수
open 함수에서 push를 호출했던 것 기억나시나요?
const useModals = () => {
const { push } = useModalContext();
const open = (modalComponent: ModalComponentFunctionType, options?: ModalOptions) => {
const id = options?.modalId ?? uuidv4();
push({ id, modalComponent });
};
return { open };
};
// open 사용
open(({ isOpen, onClose }) => <SecondModal isOpen={isOpen} onClose={onClose} />);
이렇게 작동하도록 push를 만들어 볼 겁니다.
open에서 모달 컴포넌트 함수와 모달 id를 인수로 받아서 modalList에 추가해줍니다.
만약 동일한 id의 모달이 이미 존재하면, 기존 모달을 삭제한 뒤 새로운 모달을 추가합니다.
type PushType = ({ modalComponent, id }: Omit<ModalType, 'isOpen'>) => void;
const push: PushType = ({ modalComponent, id: newModalId }) => {
document.body.style.overflow = 'hidden'
setModalList((prev) => [
...prev.filter(({ id }) => id !== newModalId),
{ modalComponent, id: newModalId, isOpen: true },
]);
};
modalList에 특정 모달을 삭제하는 함수
pop은 id에 해당하는 모달을 modalList에서 제거합니다.
const pop: PopType = (id) => {
setModalList((prev) => prev.filter((C) => C.id !== id));
if (modalList.length === 1) {
document.body.style.overflow = 'auto'
}
};
modalList 초기화하는 함수
ModalContext.Provider
가 unmount될 때마다 modalList를 초기화할 때 호출되는 함수입니다.
const removeAll = () => {
setModalList([]);
document.body.style.overflow = 'auto'
};
useEffect(() => {
return () => {
removeAll();
};
}, []);
open에서 push 함수를 사용하기 위해 context value에 push를 담아줍니다.
interface ModalContext {
push: PushType;
}
const ModalContext = createContext<ModalContext | null>(null);
const ModalProvider = ({ children }: ModalProviderProps) => {
const [modalList, setModalList] = React.useState<ModalType[]>([]);
const push: PushType = ({ modalComponent, id: newModalId }) => { ... };
const pop: PopType = (id) => { ... };
const removeAll = () => => { ... };
useEffect(() => {
return () => {
removeAll();
};
}, []);
return (
<ModalContext.Provider value={{ push }}>
{children}
{modalList.map(({ id, modalComponent, isOpen }) => (
<ModalController key={id} isOpen={isOpen} modalController={modalComponent} unmount={() => pop(id)} />
))}
</ModalContext.Provider>
);
};
children 바로 밑에 ModalController 컴포넌트가 보이실 겁니다.
이 컴포넌트는 모달을 렌더링하는 컴포넌트로, modalList 목록 렌더링을 통해 모달들이 렌더링됩니다.
const ModalController = ({
isOpen,
modalController: ModalComponent,
unmount,
}: {
isOpen: boolean;
modalController: ModalComponentFunctionType;
unmount: () => void;
}) => {
return <ModalComponent isOpen={isOpen} onClose={unmount} />;
};
이제 ModalContext를 사용하기 위한 커스텀 훅만 만들면 context는 완성입니다!
export const useModalContext = () => {
const context = useContext(ModalContext);
if (!context) {
throw new Error('useModalContext must be used within a ModalProvider');
}
return context;
};
import React, { ReactNode, createContext, useContext, useEffect } from 'react';
type ModalIdType = string;
type ModalComponentFunctionType = (params: { isOpen: boolean; onClose: () => void }) => JSX.Element;
interface ModalType {
id: ModalIdType;
isOpen: boolean;
modalComponent: ModalComponentFunctionType;
}
type PushType = ({ modalComponent, id }: Omit<ModalType, 'isOpen'>) => void;
type PopType = (id: ModalIdType) => void;
interface ModalContext {
push: PushType;
}
const ModalContext = createContext<ModalContext | null>(null);
export const useModalContext = () => {
const context = useContext(ModalContext);
if (!context) {
throw new Error('useModalContext must be used within a ModalProvider');
}
return context;
};
const ModalController = ({
isOpen,
modalController: ModalComponent,
unmount,
}: {
isOpen: boolean;
modalController: ModalComponentFunctionType;
unmount: () => void;
}) => {
return <ModalComponent isOpen={isOpen} onClose={unmount} />;
};
interface ModalProviderProps {
children: ReactNode;
}
const ModalProvider = ({ children }: ModalProviderProps) => {
const [modalList, setModalList] = React.useState<ModalType[]>([]);
const push: PushType = ({ modalComponent, id: newModalId }) => {
document.body.style.overflow = 'hidden'
setModalList((prev) => [
...prev.filter(({ id }) => id !== newModalId),
{ modalComponent, id: newModalId, isOpen: true },
]);
};
const pop: PopType = (id) => {
setModalList((prev) => prev.filter((C) => C.id !== id));
if (modalList.length === 1) {
document.body.style.overflow = 'auto'
}
};
const removeAll = () => {
setModalList([]);
document.body.style.overflow = 'auto'
};
useEffect(() => {
return () => {
removeAll();
};
}, []);
return (
<ModalContext.Provider value={{ push }}>
{children}
{modalList.map(({ id, modalComponent, isOpen }) => (
<ModalController key={id} isOpen={isOpen} modalController={modalComponent} unmount={() => pop(id)} />
))}
</ModalContext.Provider>
);
};
export default ModalProvider;
const FirstModal = ({ isOpen, onClose }) => {
const { open } = useModals();
return (
<Modal isOpen={isOpen} onClose={onClose}>
<span>First Title</span>
<button onClick={onClose}>close first modal</button>
<button
onClick={() => {
open(({ isOpen, onClose }) => <SecondModal isOpen={isOpen} onClose={onClose} />);
}}
>
next modal
</button>
</Modal>
);
};
const SecondModal = ({ isOpen, onClose }) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<span>Second Modal</span>
<button onClick={onClose}>close second modal</button>
</Modal>
);
};
export default function Page() {
const { open, close } = useModals();
return (
<>
<button
onClick={() => {
open(({ isOpen, onClose }) => <FirstModal isOpen={isOpen} onClose={onClose} />);
}}
>
open modal
</button>
</>
);
}
모달을 사용하는 컴포넌트들(Page, FirstModal)은 모달 관리에 대한 복잡한 내부 구현 사항을 신경 쓰지 않고 간단히 모달을 열고 닫을 수 있게 되었습니다!
전에 useModal를 여러번 호출하고 또 반환값에 네이밍을 짓는 게 고민이었는데,
리팩토링 하고 나니 더이상 그런 고민은 안해도 되니까 좋았습니다.
그리고, 모달에 대한 상태, 핸들러, 컴포넌트가 한 곳에 모여있으니 가독성과 유지보수성 측면에서 좋아졌습니다!