1. 분석 이유
Modal이나 BottomSheet같은 Overlay는 브라우저에서 굉장히 자주쓰이는 컴포넌트이다.
나는 Overlay를 잘 사용하고 싶어 더 나은 사용 방법을 분석하고 앞으로 프로그래밍에 사용하고자한다.
2. 이전 Modal 로직 작성 방식
이전 프로젝트를 진행할때 사용했던 모달 열고 닫기 로직이다.
모달을 사용하는 App에서 isModalOpen
상태를 선언해서 Modal
을 조건부 렌더링한다.
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
...
const handleRestaurantClick = (e: React.MouseEvent<HTMLElement>) => {
...
setIsModalOpen(true);
};
const handleModalCloseButtonClick = () => {
...
setIsModalOpen(false);
};
return (
...
{clickedRestaurant && isModalOpen && (
<Modal restaurant={clickedRestaurant} onCloseButtonClick={handleModalCloseButtonClick} />
)}
...
);
}
export default App;
현재 이 방식이 마음에 들지 않는 이유가 몇가지 있다.
Modal
이App
컴포넌트 리턴문에 선언되어 있다. (Overlay는 화면에 띄어지는 것이기 때문에 컴포넌트에 포함된 것이 어색하다)Modal
이restaurant
prop을 받는다. 도메인에 종속적이다.
2번은 따로 RestaurantModal
컴포넌트를 만들어야 한다고 생각하고, 1번을 해결하기 위해서는 Modal
렌더 링 코드를 따로 빼야할 것 같다.
3. useOverlay 사용법
분석하기 전에 useOverlay 사용방법과 개념을 알아보자.
// _app.tsx
import { OverlayProvider } from '@toss/use-overlay';
export default function App({ Component, pageProps }: AppProps) {
return (
<OverlayProvider>
<Component {...pageProps} />
</OverlayProvider>
);
}
// Page.tsx
import { useOverlay } from '@toss/use-overlay';
const overlay = useOverlay();
const openFooConfirmDialog = () => {
return new Promise<boolean>(resolve => {
overlay.open(({ isOpen, close }) => (
<FooConfirmDialog
open={isOpen}
onClose={() => {
resolve(false);
close();
}}
onConfirm={() => {
resolve(true);
close();
}}
/>
));
});
};
await openFooConfirmDialog();
// ConfirmDialog의 confirmButton을 누르거나 onClose가 호출된 후
console.log('dialog closed');
useOverlay
를 사용하는 컴포넌트를OverlayProvider
로 감싼다.useOverlay
의 반환값인overlay
를 이용해 Overlay 컴포넌트를 렌더링한다.overlay
가 제공하는isOpen
,close
는 이전 모달 사용방식의isModalOpen
,setIsModalOpen(false)
과 같은 의미이다.overlay.open
을 Promise로 감쌀 수 있다.onClose
와onConfirm
에서 resolve(결과) 하여 Overlay를 선언한 곳에서 결과에 따라 다른 로직을 수행할 수 있다.useOverlay
를 여러 번 호출해서 여러 개의 Overlay를 띄울 수 있다. (const overlay2 = useOverlay();
)
나는 특히 Promise
를 이용해 유저의 반응에 대응하는 로직이 마음에 든다.
적절하게 단일책임원칙을 만족하는 느낌이랄까..
그럼 이제 useOverlay
를 분석해보자
4. 분석
코드는 toss/slash에서 살펴볼 수 있다.
크게 OverlayController
, OverlayProvider
, useOverlay
로 나뉜다.
그림으로 보면 useOverlay가 Controller와 Provider를 사용하는 구조이다.
types.tsx
useOverlay
로 화면에 띄우려는 컴포넌트는 CreateOverlayElement
타입이어야한다.
export type CreateOverlayElement = (props: {
isOpen: boolean;
close: () => void;
exit: () => void;
}) => JSX.Element;
보통 overlay.open
(({isOpen, close}) ⇒ 모달)
와 같이 감싸서 호출한다.
OverlayController
OverlayController
는 외부에서 받은 OverlayElement
를 감싸는 역할을 한다.
/** @tossdocs-ignore */
import { forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useState } from 'react';
import type { CreateOverlayElement } from './types';
interface Props {
overlayElement: CreateOverlayElement;
onExit: () => void;
}
export interface OverlayControlRef {
close: () => void;
}
export const OverlayController = forwardRef(function OverlayController(
{ overlayElement: OverlayElement, onExit }: Props,
ref: Ref<OverlayControlRef>
) {
const [isOpenOverlay, setIsOpenOverlay] = useState(false);
const handleOverlayClose = useCallback(() => setIsOpenOverlay(false), []);
useImperativeHandle(
ref,
() => {
return { close: handleOverlayClose };
},
[handleOverlayClose]
);
useEffect(() => {
// NOTE: requestAnimationFrame이 없으면 가끔 Open 애니메이션이 실행되지 않는다.
requestAnimationFrame(() => {
setIsOpenOverlay(true);
});
}, []);
return <OverlayElement isOpen={isOpenOverlay} close={handleOverlayClose} exit={onExit} />;
});
Overlay를 열고 닫는 useState
를 담고있다.
또한 외부에서 handleOverlayClose
를 호출할 수 있도록 useImepativeHandle
로 열어두어 사용하는 쪽에서 ref 주입을 통해 close
를 호출할 수 있다.
또한 useCallback
을 통해 함수 재선언이 되지 않도록 해서 OverlayElement
가 리렌더링 되지 않도록 처리 해두었다.
OverlayProvider
Map에 담긴 OverlayController
들을 렌더링한다.
/** @tossdocs-ignore */
import React, {
createContext,
PropsWithChildren,
ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
export const OverlayContext = createContext<{
mount(id: string, element: ReactNode): void;
unmount(id: string): void;
} | null>(null);
if (process.env.NODE_ENV !== 'production') {
OverlayContext.displayName = 'OverlayContext';
}
export function OverlayProvider({ children }: PropsWithChildren) {
// Map에 <id, OverlayComponent> 저장
const [overlayById, setOverlayById] = useState<Map<string, ReactNode>>(new Map());
// Map에 Component 저장
const mount = useCallback((id: string, element: ReactNode) => {
setOverlayById((overlayById) => {
const cloned = new Map(overlayById);
cloned.set(id, element);
return cloned;
});
}, []);
// Map에서 Component 제거
const unmount = useCallback((id: string) => {
setOverlayById((overlayById) => {
const cloned = new Map(overlayById);
cloned.delete(id);
return cloned;
});
}, []);
const context = useMemo(() => ({ mount, unmount }), [mount, unmount]);
// Map.entires()로 Component 전체 렌더링
return (
<OverlayContext.Provider value={context}>
{children}
{[...overlayById.entries()].map(([id, element]) => (
<React.Fragment key={id}>{element}</React.Fragment>
))}
</OverlayContext.Provider>
);
}
Overlay 컴포넌트를 렌더링, 메모리에서 제거하는 로직을 mount
, unmount
함수로 구현했다.
이 함수들은 context
를 내려주어 useOverlay
가 이용한다.
여기서도 useMemo
를 이용해 리렌더링을 방지한다.
useOverlay
Overlay를 조작하는 open
, close
, exit
함수를 제공하는 훅이다.
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { OverlayContext } from './OverlayProvider';
import { OverlayController, OverlayControlRef } from './OverlayController';
import type { CreateOverlayElement } from './types';
let elementId = 1;
interface Options {
exitOnUnmount?: boolean;
}
export function useOverlay({ exitOnUnmount = true }: Options = {}) {
// Provider의 Context 사용
const context = useContext(OverlayContext);
if (context == null) {
throw new Error('useOverlay is only available within OverlayProvider.');
}
const { mount, unmount } = context;
const [id] = useState(() => String(elementId++));
const overlayRef = useRef<OverlayControlRef | null>(null);
// cleanup을 통해 컴포넌트 Unmount시 unmount 함수 호출
useEffect(() => {
return () => {
if (exitOnUnmount) {
unmount(id);
}
};
}, [exitOnUnmount, id, unmount]);
// open, close, exit 함수 리턴
return useMemo(
() => ({
open: (overlayElement: CreateOverlayElement) => {
mount(
id,
<OverlayController
// NOTE: state should be reset every time we open an overlay
key={Date.now()}
ref={overlayRef}
overlayElement={overlayElement}
onExit={() => {
unmount(id);
}}
/>
);
},
close: () => {
overlayRef.current?.close();
},
exit: () => {
unmount(id);
},
}),
[id, mount, unmount]
);
}
open
은 overlayElement
를 OverlayController
로 감싸서 mount
함수를 호출하여 컴포넌트를 등록한다.(렌더링된다)
또한 cleanup을 통해 등록된 컴포넌트가 Unmount시 unmount
함수를 호출하여 메모리에서 제거한다.
close
은 OverlayController
에서 열어준 ref(useImperativeHandle
) 기능을 통해 컴포넌트를 닫는다.
exit
는 unmount
를 호출한다.
5. 정리
overlayController
: 받은 Overlay 컴포넌트를 감싸서 공통으로 사용할 기능을 추가한다.
overlayProvider
: 등록한 Overlay 컴포넌트들을 관리하고 렌더링한다.
useOverlay
: 사용자가 사용하기 쉽게 overlayController와 overlayProvider 기능을 받아서 open
, close
, exit
로 추상화하여 내보낸다.
역할과 책임에 따라 적절하게 분리되어 있어서 좋은 구조였다.
'프론트엔드' 카테고리의 다른 글
react-router-dom 으로 첫번째 페이지 라우팅하기 (2) | 2024.01.01 |
---|---|
react-router-dom에서 404페이지 보여주기 (0) | 2023.12.30 |
StorageEvent와 CustomEvent로 useLocalStorage 훅 만들기 (1) | 2023.11.29 |
[번들 사이즈 최적화] 웹팩으로 생성한 번들 파일을 분석해보자(BundleAnalyzerPlugin) (0) | 2023.09.11 |
회원가입 폼에서 주로 쓰이는 필수입력사항에* 별표 표시하기 (0) | 2023.07.16 |