모달이란 무엇인가?
모달은 이목을 집중시키기 위해 사용하는 화면 전환 기법이다.
프론트엔드에서는 반드시 구현하게 되는 그것이다.
공통 모달을 위하여.
왜?
모달이 딱 하나라면 딱히 고민하지 않겠지만, 우리 서비스는 정말 모달이 많다.
반복해서 사용되기에 이를 공통 컴포넌트로 만들어줄 필요가 있었다. 더불어, 모달 자체 규격이 존재하지 않았기에 조금 더 고민해볼 필요가 있다.
어떻게 구현할 것인가?
모달을 구성하는 방법을 찾아보니 여러 방법이 있지만 크게 두 가지로 분류되는 것 같았다.
1. 루트에 하나만 생성하여 전역 상태(type, props)를 변경하며 재사용
이런 느낌으로 사용할 수 있을 것이다.
export const ModalWrapper = () => {
const { setModalState } = useModal();
const openModal = () => {
setModalState({ type: 'confirm', props: 'something' });
};
return <div onClick={openModal}></div>;
};
⇒ 위처럼 각 컴포넌트에서 모달의 전역 상태를 변경하여 내용을 바꾸는 등의 작업을 수행하면 App과 같은 컴포넌트에서 modal의 type과 props에 따라 다르게 렌더링 해주는 것이다.
이러면 type에 따른 모달을 따로 관리해주어야 한다.
장점
1. root 요소 외에 DOM요소를 추가하여 독립적인 modal구성 가능
2. Modal에서는 type에 따라 모달 내용을 바꿔주고, modal 을 띄워주는 컴포넌트는 전역 모달의 type과 props만을 설정하므로 컴포넌트간의 역할과 책임이 명확해짐
단점
1. 모달이 하나 추가될때마다 Modal컴포넌트에서 모달 type을 추가해주어야함 (런타임에서 모달을 추가해줄 수 없음)
2. 구현이 쉽지않음
2. 컴포넌트 하위에 모달을 삽입하여 사용
export const ModalWrapper = () => {
const { ModalTemplate, isOpen, setIsOpen } = useModal();
return (
<>
<div onClick={() => setIsOpen(true)}></div>;
{isOpen && (
<ModalTemplate>
<div>모달 내용</div>
</ModalTemplate>
)}
</>
);
};
⇒ 위처럼 useModal훅에서 템플릿을 제공하고, 이를 사용하는 컴포넌트에서 유동적으로 작성하는 형태로 재사용 할 수 있도록 구성한다.
장점
modal은 root내부에 렌더링되나 , useModal을 통해 Modal 템플릿을 꺼내고, Modal 의 내부 요소는 modal을 띄우는 컴포넌트에서 결정해주기에 조금 더 유연하고, 사용하기 쉬움
단점
1. 컴포넌트의 역할과 책임이 모호해짐 (Modal을 띄워주는 컴포넌트에 모달 내부의 정보도 있어야하므로)
2. root내부에 모달이 렌더링됨 (다만, useModal훅에서 createPortal을 사용해서 렌더링하면 극복가능하다)
어떻게 구현했는가?
이렇게 고민하던 중 리뷰어님께 조언을 구했고, 모달이 여러개 뜨는 경우까지 고려해볼만하다는 의견과 함께 좋은 라이브러리 코드를( https://github.com/eBay/nice-modal-react )하나 주셨다.
이 코드를 참고해서 코드를 작성했다.
모달관리하기
핵심은 배열로 모달을 관리하는 것이다.
모달을 여러개 띄울 것이고, 이를 중첩해서 렌더링시킬 것이기때문이다.
또한, 루트에 하나만 생성하여 전역 상태(type, props)를 변경하며 재사용하는 경우의 단점이었던 컴파일타임에 모달의 type를 정의해야하는 문제를 해결하기 위해 런타임에 모달 ID를 uuid로 등록했다.
그리고 모달이 show되는 경우 modals에 해당 모달의 ID를 넣는 전역 배열의 형태로 관리했다.
상태관리 라이브러리는 Zustand를 사용했다.
type ModalState = {
modals: string[];
showModal: (id: string) => void;
hideModal: (id: string) => void;
};
const useModalState = create<ModalState>((set) => ({
modals: [],
showModal: (id: string) =>
set((state) => ({
...state,
modals: [...state.modals, id],
})),
hideModal: (id: string) =>
set((state) => ({
...state,
modals: [...state.modals.filter((modalId) => modalId !== id)],
})),
}));
그리고 실제 모달 컴포넌트를 담아줄 전역 객체를 하나 만들어주었다.
export const MODAL_COMPONENTS: { [id: string]: { Component: React.ComponentType<any>; props?: Record<string, unknown> } } = {};
key로는 uuid를 받을 것이고, 객체 내부 프로퍼티로 Component와 Props를 저장할 수 있게했다.
이러면 모달하나가 등록된 경우 이렇게 관리된다.
MODAL_COMPONENTS = {
'ca33edf7-0edb-4bfe-854b-d342191e4ab0': {
Component:({count})=>{return <div>{count}</div>},
props:{count:1}
},
};
이 모달이 show되면 modals state배열에 ID가 push되는 것이다.
모달 등록하기
모달 등록하기를 보여주기에 앞서서 내가 목표로하는 useModal의 사용코드를 적어보자면 아래와 같다.
const SomethingModal = () => {
const { hide } = useModal(); // 인자가 없으면 자신을 닫아주는 함수를 받아옴
return <button onClick={() => hide()}>닫기</div>;
};
const ModalWrapper = () => {
const { show } = useModal(SomethingModal); //인자가 있으면 해당 요소를 닫는 함수를 받아옴
return <button onClick={()=>show()}>열기</button>
};
자 그럼 모달을 등록해보자.(인자가 있는 경우의 useModal)
function useModal<P extends Record<string, unknown>>(Component: React.ComponentType<P>) {
const { showModal, hideModal } = useModalState((state) => state);
const [modalId] = useState(uuidv4()); //modalId를 생성
useEffect(() => {
MODAL_COMPONENTS[modalId] = {
Component, //모달을 전역 객체에 등록함!
};
}, [modalId]);
...
}
이제 show와 hide함수를 만들어서 내보내주면된다.
function useModal<P extends Record<string, unknown>>(Component: React.ComponentType<P>) {
...
const show = (props?: P) => { //호출시에 props를 받을 수 있게한다.
MODAL_COMPONENTS[modalId].props = props;
showModal(modalId);
};
const hide = () => {
hideModal(modalId);
};
return { show, hide };
}
Modal렌더링
모달렌더링은 Modals라는 컴포넌트를 통해 해줄 것이다.
여기서 그냥 전역객체에 등록된 모달을 넣어도 좋지만, 우리 서비스는 공통으로 X버튼이 있고, 닫히는 경우 애니메이션을 적용시켜 줄 것이기 때문에 Modal 컴포넌트도 따로 만들어 주었다.
portal을 통해 root말고 modal id를 가진 요소에 렌더링 해주었다.
import { createPortal } from 'react-dom';
import useModalState, { MODAL_COMPONENTS } from '@/stores/useModalState';
import Modal from './Modal';
const portalElement = document.getElementById('modal') as HTMLElement;
export default function Modals() {
const { modals, hideModal } = useModalState((state) => state);
return createPortal(
<>
{modals.map((id) => (
//여기서 hide함수로 ModalID가 바인딩된 hideModal이 넘어가게된다.
<Modal hide={() => hideModal(id)} key={id} Component={MODAL_COMPONENTS[id].Component} modalProps={MODAL_COMPONENTS[id].props} />
))}
</>,
portalElement,
);
}
Modal
편의상 모든 tailwind css는 다 제거해주었다.
const ANIMATION_RENDER = 'relative p-4 bg-white rounded-2xl animate-render';
const ANIMATION_REMOVE = 'relative p-4 bg-white rounded-2xl animate-remove';
export default function Modal({
Component,
modalProps,
hide, //아까 바인딩된 hide함수(렌더링된 모달이 닫히는 함수)
}: {
Component: React.ComponentType<any>;
modalProps?: Record<string, unknown>;
hide: () => void;
}) {
const [className, setClassName] = useState(ANIMATION_RENDER);
//esc로 꺼지게 하기위해 mount되면 focus처리한다.
const focusRef = useLayoutFocus<HTMLDivElement>();
//취소를 하면 애니메이션을 발생
const handleCancel = useCallback(() => setClassName(ANIMATION_REMOVE), []);
//애니메이션이 끝나면 모달을 닫음
const handleAnimationEnd = () => className === ANIMATION_REMOVE && hide();
//ESC를 누른경우
const handleKeyDown = (e: React.KeyboardEvent) => e.key === 'Escape' && handleCancel();
return (
<div onClick={handleCancel}>
<div
tabIndex={-1}
ref={focusRef}
className={className}
onClick={(e) => e.stopPropagation()}
onAnimationEnd={handleAnimationEnd}
onKeyDown={handleKeyDown}
>
<button onClick={handleCancel} type="button">
...
</button>
<Component {...modalProps} /> //모달을 렌더링
</div>
</div>
);
}
여기까지하면 이제 중첩모달을 구현했다!!!
하지만, useModal에 넘어간 Modal 컴포넌트는 스스로를 닫을 수 없다.
Context로 hide함수 넘겨주기
이것을 해결하기위해 정말 많이 고민했다.
결국 내가 구현한 방법은 ContextAPI로 hide함수를 주입시켜주고, 이걸 useContext로 받아오는 것이다.
Modal컴포넌트에서 ModalHideContext를 만들어주고, 실제 모달을 그리기 전에 Provider로 감싸서 렌더링해줄 것이다.
이러면, 렌더링된 모달컴포넌트 (useModal의 인자로 넘어갈 컴포넌트)는 Context를 통해 항상 자신을 닫는 함수를 받을 수 있다!
...
export const ModalHideContext = createContext(() => {});
export default function Modal({
Component,
modalProps,
hide,
}: {
Component: React.ComponentType<any>;
modalProps?: Record<string, unknown>;
hide: () => void;
}) {
...
return (
...
<ModalHideContext.Provider value={handleCancel}>
<Component {...modalProps} />
</ModalHideContext.Provider>
...
);
}
useModal 오버로딩
매번 컴포넌트에서 Context를 받아와서 useModalState를 불러 state를 바꿔주는것은 비효율적이므로 useModal의 인자가 없는 경우에 show와 hide함수를 다르게 반환하게 하는 형태로 구현할 것이다.
useModal의 인자가 없는경우를 구현해주면된다.
type CalledByModalInner = { hide: () => void }; //인자가 없으면 hide함수만 반환된다.
type CalledByModalOuter<P> = { show: (props?: P) => void; hide: () => void }; //인자가 있다면 show, hide둘다 반환된다.
function useModal(): CalledByModalInner;
function useModal<P extends Record<string, unknown>>(Component?: React.ComponentType<P>): CalledByModalOuter<P>;
function useModal<P extends Record<string, unknown>>(Component?: React.ComponentType<P>): CalledByModalInner | CalledByModalOuter<P> {
const { showModal, hideModal } = useModalState((state) => state);
const [modalId] = useState(uuidv4());
// Modals에서 주입시킨 Provider로부터 Modal에 애니메이션을 적용시키는 함수 Context를 가져온다.
const hideThisModal = useContext(ModalHideContext);
useEffect(() => {
if (!Component) return; //useModal로 넘어온 인자가 없는경우 등록하지 않는다.
MODAL_COMPONENTS[modalId] = {
Component,
};
}, [modalId]);
const show = (props?: P) => {
if (!Component) return; //useModal로 넘어온 인자가 없는경우 호출하지 않는다.
MODAL_COMPONENTS[modalId].props = props;
showModal(modalId);
};
const hide = () => {
if (Component) hideModal(modalId); //인자가 있으면 hideModal로 modals 배열의 상태를 변화시킨다.
else hideThisModal(); //인자가 없다면 Provider를 통해 받은 함수를 수행시킨다.
};
if (Component) return { show, hide }; //반환도 분기처리해준다.
return { hide };
}
export default useModal;
이제 목표한대로 useModal을 사용할 수 있다!
const SomethingModal = () => {
const { hide } = useModal(); // 인자가 없으면 자신을 닫아주는 함수를 받아옴
return <button onClick={() => hide()}>닫기</div>;
};
const ModalWrapper = () => {
const { show } = useModal(SomethingModal); //인자가 있으면 해당 요소를 닫는 함수를 받아옴
return <button onClick={()=>show()}>열기</button>
};
이를 통해 앞서 알아봤던 두 방식의 장점은 챙기고, 단점은 보완한 형태로 중첩 모달이 해결되는 모달을 구현할 수 있었다!
(물론 구현은 매우매우 어려웠지만...)
정리해보면
1. 모달을 사용하는 컴포넌트에서 show를 통해 직관적으로 모달띄우기 가능
2. 모달컴포넌트 내부에서도 hide가능
3. 따로 type, props를 컴파일 타임에 미리 설정해주지 않아도됨
4.root와 독립적인 부분에서 렌더링이 일어남
5. 컴포넌트의 역할과 책임이 명확해짐 (모달을 띄워주는 컴포넌트는 띄워주는 것만, 모달내부의 일은 모달이 알아서!)