React에서 효율적으로 모달 관리하기(feat. react-use-hook-modal)
iskkiri • 2024년 10월 29일
모달은 웹 애플리케이션에서 자주 사용되는 UI 컴포넌트로, 사용자와의 중요한 상호작용을 담당합니다. 그러나 여러 개의 모달을 관리하다 보면 각 모달의 상태를 컴포넌트 내에서 개별적으로 선언해야 하는 번거로움이 발생합니다. 이번 포스팅에서는 일반적으로 사용하는 모달 상태 관리 방식의 문제점과 이러한 문제를 해결하는 더 나은 방법을 소개합니다.
이 글은 react-use-hook-modal 라이브러리를 만들게 된 계기와 라이브러리의 코드 일부를 설명하는 글입니다.
실제 라이브러리의 예제 코드와 동작을 확인하고 싶은 경우에는 예제 목록에서 확인할 수 있습니다.
개별 컴포넌트에서 모달 관리의 문제점
모달의 ‘열림’에 대한 상태값 반복 선언
모달을 사용할 때마다 ‘열림’에 대한 상태값을 반복해서 선언해야 하는 불편함이 있습니다. 특히, 모달이 여러 개인 경우, 각각의 모달마다 상태와 핸들러를 별도로 정의해야 하므로 코드 중복이 발생합니다.
모달 상태를 컴포넌트 내에서 관리하는 예시는 다음과 같습니다.
export default function ExampleComponent() {
const [isOpen, setIsOpen] = useState(false);
const onOpen = () => setIsOpen(true);
const onClose = () => setIsOpen(false);
return (
<>
<button onClick={onOpen}>Open Modal</button>
<Modal isOpen={isOpen} onClose={onClose} />
</>
);
}
위와 같은 방식은 간단해 보이지만, 모달이 여러 개일 때는 다음과 같이 상태와 핸들러가 중복됩니다:
const [isFirstModalOpen, setIsFirstModalOpen] = useState(false);
const onOpenFirstModal = () => setIsFirstModalOpen(true);
const onCloseFirstModal = () => setIsFirstModalOpen(false);
const [isSecondModalOpen, setIsSecondModalOpen] = useState(false);
const onOpenSecondModal = () => setIsSecondModalOpen(true);
const onCloseSecondModal = () => setIsSecondModalOpen(false);
const [isThirdModalOpen, setIsThirdModalOpen] = useState(false);
const onOpenThirdModal = () => setIsThirdModalOpen(true);
const onCloseThirdModal = () => setIsThirdModalOpen(false);
이러한 반복을 줄이기 위해 다음과 같이 Custom Hook을 정의할 수 있습니다.
const useModal = () => {
const [isOpen, setIsOpen] = useState(false);
const onOpen = () => setIsOpen(true);
const onClose = () => setIsOpen(false);
return { isOpen, onOpen, onClose };
};
위와 같이 Custom Hook을 사용하면 코드 중복을 줄일 수 있지만, 아래에서 추가로 설명할 모달이 특정 컴포넌트에 종속되는 문제는 여전히 해결되지 않습니다.
모달이 특정 컴포넌트에 종속
모달은 특정 컴포넌트 내부에 한정되지 않고 페이지 전체에서 독립적으로 열리는 것이 일반적입니다. 예를 들어, 다음과 같은 컴포넌트를 생각해 봅시다.
export default function ExampleComponent() {
const [isOpen, setIsOpen] = useState(false);
const onOpen = () => setIsOpen(true);
const onClose = () => setIsOpen(false);
return (
<>
<button onClick={onOpen}>Open Modal</button>
<Modal isOpen={isOpen} onClose={onClose} />
</>
);
}
버튼을 클릭해 모달을 열더라도, 모달이 ExampleComponent 내부에 렌더링되지 않고 전체 화면을 덮는 형태로 나타나는 것이 자연스러운 동작입니다.
즉, ExampleComponent의 버튼은 단순히 모달을 여는 트리거 역할만 수행하며, 모달 자체는 페이지 또는 전역 레벨에서 관리되는 것이 바람직합니다. 이러한 이유로 모달의 상태를 전역에서 관리할 필요성이 제기됩니다.
Context API를 이용한 모달 관리
모달을 효율적으로 관리하기 위해 전역 상태에서 모달을 제어할 수 있어야 합니다. 전역 상태 관리를 위한 방법은 여러 가지가 있지만, 이번 포스팅에서는 Context API를 사용하여 구현해보겠습니다. 이를 위해 먼저 모달의 상태와 제어를 위한 몇 가지 인터페이스를 정의하겠습니다.
아래의 코드는 react-use-hook-modal 코드의 일부입니다. 전체 코드를 확인하고 싶을 경우에는 레퍼지토리에서 확인할 수 있습니다.
모달 상태 인터페이스 ModalState
ModalState 인터페이스는 각 모달의 상태를 정의하는 역할을 합니다.
export interface ModalState<TProps = any> {
Component: React.ComponentType<TProps>;
props: TProps & { isOpen: boolean };
key: ModalKey;
portalTarget?: HTMLElement | null;
}
Component: 렌더링할 모달 컴포넌트를 지정합니다. 제네릭을 통해 다양한 모달 컴포넌트와 해당 props 타입을 유연하게 지원할 수 있습니다.
props: 모달에 전달할 props로, isOpen 속성은 모달의 열림/닫힘 상태를 나타냅니다. 이를 통해 모달 컴포넌트가 필요한 데이터를 전달받습니다.
key: 모달을 고유하게 식별하는 키입니다. 여러 모달을 관리할 때 각 모달을 구분하는 데 사용됩니다.
portalTarget: 특정 DOM 요소에 모달을 렌더링하고자 할 때 사용되는 선택적 속성으로, 모달을 특정 위치에 렌더링할 수 있는 유연성을 제공합니다.
모달 제어 인터페이스 ModalDispatchContextType
모달을 열고 닫는 동작을 수행하는 openModal과 closeModal 메서드를 포함한 ModalDispatchContextType 인터페이스를 정의합니다. openModal 메서드는 OpenParams 타입을 통해 모달을 열 때 필요한 정보를 받아 설정합니다.
export interface OpenParams<TProps> extends Omit<ModalState<TProps>, 'props'> {
props?: Omit<TProps, 'isOpen'>;
}
export interface ModalDispatchContextType {
openModal: <TProps>(params: OpenParams<TProps>) => void;
closeModal: (key: ModalKey) => void;
}
OpenParams 타입
openModal 메서드에 전달되는 OpenParams 타입은 모달을 렌더링하기 위한 필수 정보를 담고 있습니다.
Component: 렌더링할 모달 컴포넌트를 지정합니다. 제네릭 타입을 통해 다양한 컴포넌트를 수용할 수 있으며, 호출 시 컴포넌트 타입에 따라 props 타입이 달라지도록 유연하게 설계되어 있습니다.
props: 모달 컴포넌트에 전달할 props입니다. isOpen 속성은 내부적으로 관리되므로 명시할 필요가 없으며, Omit을 사용해 이 속성을 제외합니다. 이 속성으로 모달 컴포넌트에 필요한 데이터를 전달할 수 있습니다.
key: 모달을 고유하게 식별하는 키입니다. 여러 모달을 관리할 때, 특정 모달을 쉽게 제어할 수 있도록 도와줍니다.
portalTarget: 모달을 특정 DOM 요소에 렌더링하고자 할 때 사용하는 선택적 속성입니다. 이를 통해 모달을 특정 위치에 표시하여 사용자 경험을 더욱 향상시킬 수 있습니다.
openModal과 closeModal 메서드
openModal: openModal 메서드는 params를 인수로 받아 모달을 엽니다. OpenParams 타입의 정보로 모달 컴포넌트와 필요한 props를 설정할 수 있어 안전한 타입 관리가 가능합니다.
closeModal: closeModal 메서드는 key 값을 받아 특정 모달을 닫는 역할을 합니다. 여러 개의 모달이 열려 있을 때 특정 모달을 지정하여 닫을 수 있습니다.
ModalStateContext와 ModalDispatchContext
ModalStateContext와 ModalDispatchContext는 각각 모달의 상태와 모달 제어 메서드를 제공하는 Context입니다.
export const ModalStateContext = createContext<ModalStateContextType | null>(null);
export const ModalDispatchContext = createContext<ModalDispatchContextType>({
openModal: () => {
throw new Error(
'ModalProvider is missing or useModal must be called within a ModalProvider. Please ensure that your component is wrapped within <ModalProvider>.'
);
},
closeModal: () => {
throw new Error(
'ModalProvider is missing or useModal must be called within a ModalProvider. Please ensure that your component is wrapped within <ModalProvider>.'
);
},
});
ModalStateContext: 현재 활성화된 모든 모달의 상태를 전역에서 공유할 수 있도록 하는 Context입니다. ModalStateContextType 타입을 따르며, 기본값은 null입니다.
ModalDispatchContext: 모달을 열고 닫는 기능을 제공하는 Context로, openModal과 closeModal 메서드를 포함합니다. ModalProvider 없이 useModal을 사용할 경우 오류 메시지를 통해 ModalProvider로 컴포넌트를 감싸야 함을 명시합니다.
ModalProvider 컴포넌트: 모달 상태 관리와 렌더링
ModalProvider는 Context API를 통해 모달의 상태를 전역에서 관리하고, 필요한 모달을 렌더링하는 역할을 수행하는 주요 컴포넌트입니다. 이 컴포넌트는 ModalStateContext와 ModalDispatchContext를 제공하여 모든 자식 컴포넌트가 모달을 열고 닫는 동작을 쉽게 제어할 수 있도록 합니다.
ModalProviderProps 인터페이스: 모달 설정을 위한 주요 속성
interface ModalProviderProps {
children: React.ReactNode;
container?: React.ComponentType<any>;
clearTime?: number;
}
children: ModalProvider로 감싸진 모든 자식 컴포넌트를 포함합니다. 이 속성은 일반적으로 앱의 나머지 요소들로, 모달과 상호작용하는 다른 컴포넌트들이 들어옵니다.
container: 모달을 렌더링할 때 사용할 커스텀 컨테이너 컴포넌트입니다. 기본값은 React.Fragment이며, 사용자가 직접 커스텀 컨테이너를 지정할 수 있습니다. 예를 들어, React Transition Group과 같은 애니메이션 라이브러리와 통합하여 모달 애니메이션을 관리할 수 있습니다. 이 속성은 모달의 렌더링 방식과 애니메이션 적용 방식을 제어할 수 있는 유연성을 제공합니다.
clearTime: 모달이 닫힌 후 내부 상태(modals 배열)에서 완전히 제거되기까지의 시간(밀리초 단위)입니다. 이 시간을 통해 모달의 종료 애니메이션이 자연스럽게 끝난 뒤 상태에서 제거되도록 하며, 기본값은 3000밀리초(3초)입니다. 이 설정은 애니메이션이 끝나기 전에 모달이 상태에서 사라지지 않도록 돕습니다.
모달 열기와 닫기: openModal과 closeModal
openModal과 closeModal 메서드는 모달을 전역 상태에서 열고 닫는 역할을 수행합니다.
openModal: 새로운 모달을 열거나 이미 열려 있는 모달을 업데이트하는 역할을 합니다. OpenParams 타입의 Component, props, key, portalTarget을 인수로 받아 모달을 열고, isOpen: true 상태를 설정하여 모달이 열려 있음을 명시합니다.
const openModal = useCallback(
<TProps,>({ Component, props, key, portalTarget }: OpenParams<TProps>) => {
const propsWithIsOpen = { ...props, isOpen: true };
setModals((modals) => {
const targetIndex = modals.findIndex((modal) => modal.key === key);
if (targetIndex !== -1) {
const updatedModals = [...modals];
updatedModals[targetIndex] = { ...updatedModals[targetIndex], props: propsWithIsOpen };
return updatedModals;
}
return [...modals, { Component, props: propsWithIsOpen, key, portalTarget }];
});
},
[]
);
closeModal: 특정 key를 통해 모달을 닫습니다. isOpen 속성을 false로 설정하여 닫힌 상태를 만들고, 일정 시간(clearTime)이 지난 후 modals 배열에서 완전히 제거합니다. clearTime은 setTimeout을 사용하여 지정되며, 애니메이션이 끝난 후 상태에서 모달이 제거되도록 설정합니다.
Context Provider 설정
ModalProvider는 ModalStateContext와 ModalDispatchContext를 통해 modals 상태와 dispatch 메서드를 자식 컴포넌트에 제공합니다. 이를 통해 어디서든 모달을 열고 닫을 수 있습니다.
const dispatch = useMemo(() => ({ openModal, closeModal }), [openModal, closeModal]);
return (
<ModalStateContext.Provider value={{ modals }}>
<ModalDispatchContext.Provider value={dispatch}>
{children}
<Modals container={container} modals={modals} />
</ModalDispatchContext.Provider>
</ModalStateContext.Provider>
);
dispatch: openModal과 closeModal을 포함하여 모달 제어를 위한 기능을 제공합니다.
Modals 컴포넌트: container와 modals를 prop으로 받아 모달들을 실제로 렌더링합니다. 이로써 ModalProvider로 감싸진 모든 자식 컴포넌트에서 전역적으로 모달을 관리할 수 있게 됩니다.
Modals 컴포넌트: 모달 렌더링을 위한 설정
Modals 컴포넌트는 ModalProvider 내부에서 현재 활성화된 모든 모달을 렌더링하는 역할을 합니다. 이 컴포넌트는 modals 배열을 순회하여 각 모달을 ModalItem 컴포넌트로 변환해 화면에 표시합니다.
Modals의 구조와 주요 역할
Modals 컴포넌트는 두 가지 주요 props를 받습니다.
interface ModalsProps {
modals: ModalState[];
container?: React.ComponentType<any>;
}
modals: ModalState 타입의 배열로, 현재 열려 있는 모든 모달의 상태가 담겨 있습니다. 이 배열의 각 요소는 렌더링할 모달의 Component, props, key, portalTarget을 포함하고 있습니다.
container: 모달을 감싸는 컨테이너 컴포넌트로, 기본값은 React.Fragment입니다. ModalProvider에서 전달된 container prop을 그대로 받아 사용하며, 이를 통해 모달의 렌더링 방식과 애니메이션을 제어할 수 있습니다.
모달 렌더링 과정
Modals 컴포넌트는 modals 배열을 순회하며 각 모달을 ModalItem으로 렌더링합니다.
export default function Modals({ container: Container = React.Fragment, modals }: ModalsProps) {
return (
<Container>
{modals.map((modal) => {
const { Component, props, key, portalTarget } = modal;
return (
<ModalItem key={key} component={Component} props={props} portalTarget={portalTarget} />
);
})}
</Container>
);
}
Container: Container는 기본적으로 React.Fragment로 설정되어 있으며, 필요에 따라 사용자 지정 컨테이너로 교체할 수 있습니다. Container는 모달의 컨테이너 역할을 하며, React Transition Group과 같은 애니메이션 라이브러리와 함께 사용할 수 있습니다.
modals.map: modals 배열을 순회하여, 각 모달의 정보를 ModalItem 컴포넌트에 전달하여 렌더링합니다.
ModalItem 컴포넌트: 개별 모달의 포털 렌더링
ModalItem 컴포넌트는 Modals 컴포넌트가 전달한 각 모달의 정보를 기반으로 실제 모달을 화면에 렌더링합니다. 특히, ModalItem은 createPortal을 사용하여 모달을 특정 DOM 요소(portalTarget)에 렌더링할 수 있습니다.
ModalItem의 구조와 주요 역할
interface ModalItemProps {
component: React.ComponentType<any>;
props: any;
portalTarget?: HTMLElement | null;
}
component: 렌더링할 모달 컴포넌트입니다. 이 컴포넌트는 component prop으로 전달받은 컴포넌트로서, 사용자가 원하는 모달의 구조와 UI를 정의할 수 있습니다.
props: 모달 컴포넌트에 전달할 props입니다. 이 props는 ModalState에서 설정한 상태를 포함하며, 모달의 데이터와 isOpen과 같은 상태를 제공합니다.
portalTarget: 모달을 특정 DOM 요소에 렌더링할 수 있도록 지정하는 선택적 속성입니다. 이를 통해 모달이 페이지의 특정 위치에 렌더링되게 할 수 있습니다.
포털을 통한 모달 렌더링
ModalItem 컴포넌트는 useEffect를 통해 portalTarget을 설정하고, createPortal을 사용하여 모달을 지정된 위치에 렌더링합니다.
export default function ModalItem({ component: Component, props, portalTarget }: ModalItemProps) {
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
useEffect(() => {
setPortalElement(portalTarget ? portalTarget : document.body);
}, [portalTarget]);
if (!portalElement) return null;
return createPortal(<Component {...props} />, portalElement);
}
portalElement 상태: portalElement는 useState로 관리되며, portalTarget이 주어지면 해당 요소로 설정되고, 지정되지 않았을 경우 기본적으로 document.body에 렌더링됩니다.
useEffect: useEffect는 portalTarget이 변경될 때마다 portalElement를 업데이트하여, 모달이 올바른 위치에 렌더링되도록 합니다.
createPortal: createPortal 함수는 Component와 props를 portalElement에 렌더링합니다. 이를 통해 모달이 특정 DOM 요소에 표시될 수 있으며, 모달의 위치를 유연하게 제어할 수 있습니다.
이로써 ModalItem은 Modals가 전달한 각 모달 정보를 개별적으로 렌더링하여, 모달의 포털 렌더링과 위치 설정을 담당합니다. Modals와 ModalItem 컴포넌트의 조합으로 모달을 원하는 위치와 스타일로 손쉽게 제어할 수 있으며, 이를 통해 애플리케이션 전체에서 일관된 모달 렌더링이 가능합니다.
useModal Hook: 모달 열기 및 닫기 기능 제공
useModal 훅은 특정 모달 컴포넌트를 열고 닫는 기능을 제공하며, ModalDispatchContext에서 제공하는 openModal과 closeModal 메서드를 사용해 모달을 제어할 수 있게 합니다. 이를 통해 컴포넌트가 개별 모달을 제어할 수 있으며, 보다 간편하게 모달 상태를 관리할 수 있습니다.
useModal의 구조와 주요 역할
export default function useModal<TProps extends { isOpen: boolean }>(
Component: React.ComponentType<TProps>
){
Component: useModal 훅의 인수로 전달되는 모달 컴포넌트입니다. TProps 제네릭을 사용하여 모달에 전달되는 props 타입을 유연하게 지정할 수 있으며, isOpen 속성을 필수로 포함하도록 타입이 제한됩니다.
open과 close 메서드
useModal은 모달의 열림/닫힘 상태를 제어하기 위해 open과 close 메서드를 반환합니다.
key: 고유한 모달 key를 생성합니다. 이 key는 nanoid를 통해 무작위로 생성되며, 모달의 고유 식별자로 사용됩니다.
const key = useMemo(() => nanoid(), []);
open 메서드: openModal을 호출하여 모달을 여는 함수입니다. props와 options를 인수로 받아 모달 컴포넌트와 필요한 설정값을 전달합니다.
OpenModal 타입 정의
OpenModal 타입은 open 메서드의 타입을 정의합니다. 이 타입은 IsPropsRequired 조건 타입을 사용하여 TProps의 모든 props가 필수인지 선택적인지에 따라 호출 방식을 구분합니다.
export type OpenModal<TProps> =
IsPropsRequired<TProps> extends true
? (props: Omit<TProps, 'isOpen'>, options?: OpenModalOptions) => void
: (props?: Omit<TProps, 'isOpen'>, options?: OpenModalOptions) => void;
props: 모달 컴포넌트에 전달될 props로, isOpen 속성은 내부에서 관리되므로 Omit을 사용해 제외됩니다. TProps의 props가 필수일 경우 props를 필수 인수로 설정하고, 선택적일 경우 props는 선택 인수로 설정됩니다.
options: 선택적인 OpenModalOptions 타입으로, 모달의 고유 key와 portalTarget을 포함하여 모달의 식별과 위치를 제어할 수 있습니다.
IsPropsRequired 타입 정의
IsPropsRequired는 TProps의 모든 속성이 필수인지 선택적인지 판별하는 타입입니다.
type IsPropsRequired<TProps> =
Exclude<keyof TProps, 'isOpen'> extends never
? false
: Partial<Pick<TProps, Exclude<keyof TProps, 'isOpen'>>> extends Pick<
TProps,
Exclude<keyof TProps, 'isOpen'>
>
? false
: true;
TProps에서 isOpen을 제외한 나머지 속성의 키(keyof TProps, 'isOpen'가 제외된)를 기반으로 모든 속성이 선택적인지 여부를 판단합니다.
나머지 속성이 선택적이라면 false를 반환하고, 필수 속성이 존재한다면 true를 반환하여 open 메서드에서 props가 필수인지 선택적인지를 구분합니다.
open 메서드
open 메서드는 props와 options를 받아 openModal에 전달하여 모달을 엽니다.
const open: OpenModal<TProps> = useCallback(
(props?: Omit<TProps, 'isOpen'>, options?: OpenModalOptions) => {
openModal({
Component,
props,
key: options?.key ?? key,
portalTarget: options?.portalTarget,
});
},
[Component, key, openModal]
);
openModal 호출: options에 key나 portalTarget이 있으면 이를 사용하고, 없을 경우 useModal에서 생성한 기본 key를 사용합니다.
close 메서드
close 메서드는 특정 key를 받아 모달을 닫습니다. close는 modalKey가 지정되지 않은 경우 기본적으로 useModal에서 생성된 key를 사용합니다.
const close: CloseModal = useCallback(
(modalKey) => {
const validKey =
typeof modalKey === 'string' || typeof modalKey === 'number' ? modalKey : key;
closeModal(validKey);
},
[closeModal, key]
);
useModal 훅은 특정 모달 컴포넌트를 열고 닫을 수 있는 기능을 제공합니다. OpenModal 타입과 IsPropsRequired 조건을 통해 props가 필수인지 선택적인지를 자동으로 구분하여 open 메서드의 사용 방식을 유연하게 처리합니다. OpenModalOptions를 통해 key와 portalTarget을 제어하여 모달의 식별과 위치를 지정할 수 있습니다. 이러한 구조로 개별 컴포넌트에서 모달을 간편하게 관리할 수 있으며, 모달 로직의 복잡성을 줄이고 유지보수성을 높일 수 있습니다.
react-use-hook-modal
이번 포스팅에서 설명한 코드는 React Context API만으로 전역에서 모달을 관리하는 구조로 설계되었습니다. 하지만 실제 코드로 구현할 때는 각 모달의 상태, 렌더링 타이밍, 포털 위치 등을 고려해야 할 요소가 많아 보일러플레이트 코드가 늘어나기 쉽습니다.
이 복잡함을 해결하고 누구나 쉽게 모달을 관리할 수 있도록 만든 것이 바로 react-use-hook-modal입니다. react-use-hook-modal은 선언적으로 모달을 관리할 수 있는 헤드리스(Headless) React Hook 라이브러리로, 최소한의 설정만으로도 복잡한 모달 상태를 간편하게 제어할 수 있게 설계되었습니다. UI 컴포넌트를 포함하지 않아 MUI, Chakra UI, Bootstrap과 같은 다양한 UI 라이브러리 또는 커스텀 모달 시스템과도 쉽게 통합이 가능합니다.
주요 기능
선언적 모달 관리: 모달을 열고 닫는 동작을 선언적으로 구현할 수 있어 코드가 간결해집니다.
UI 종속성 없음: 헤드리스 구현으로 어떤 UI 프레임워크와도 연동할 수 있습니다.
유연한 통합: 포털 타겟을 지정하거나 커스텀 컨테이너를 적용해 모달의 위치와 애니메이션을 자유롭게 조정할 수 있어 다양한 UI 요구 사항에 대응할 수 있습니다.
완전한 커스터마이징 지원: 애니메이션, 전환 효과 등 모달의 동작과 외형을 프로젝트에 맞게 조정할 수 있습니다.
이 라이브러리를 사용하면 복잡한 코드 작성 없이도 프로젝트 요구에 맞는 모달을 효율적으로 관리할 수 있으며, 일관된 상태 관리로 유지보수성을 높일 수 있습니다. react-use-hook-modal은 다양한 UI 라이브러리와의 통합이 가능해, 프로젝트의 모달 관리 방식을 단순화하고 필요한 커스터마이징을 손쉽게 적용할 수 있도록 도와줍니다.