Search
😃

toss slash useOverlay 분석

react 공식 문서 복습한 것 기반으로 useOverlay 코드 분석하기
useOverlay.tsx
src

OverlayProvider.tsx

/** @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) { const [overlayById, setOverlayById] = useState<Map<string, ReactNode>>(new Map()); const mount = useCallback((id: string, element: ReactNode) => { setOverlayById(overlayById => { const cloned = new Map(overlayById); cloned.set(id, element); return cloned; }); }, []); const unmount = useCallback((id: string) => { setOverlayById(overlayById => { const cloned = new Map(overlayById); cloned.delete(id); return cloned; }); }, []); const context = useMemo(() => ({ mount, unmount }), [mount, unmount]); return ( <OverlayContext.Provider value={context}> {children} {[...overlayById.entries()].map(([id, element]) => ( <React.Fragment key={id}>{element}</React.Fragment> ))} </OverlayContext.Provider> ); }
TypeScript
복사
디버깅을 위해 프로덕션이 아닌 환경에서는 displayName을 OverlayContext로 설정한 듯.
if (process.env.NODE_ENV !== 'production') { OverlayContext.displayName = 'OverlayContext'; }
TypeScript
복사
new Map을 활용한 객체 state
react 공식 문서에서 소개된 객체 state의 업데이트 방법은 꽤나 복잡했는데,
Map으로 관리하니 훨씬 더 로직이 간결해보이는 듯.
const [overlayById, setOverlayById] = useState<Map<string, ReactNode>>(new Map());
TypeScript
복사
useMemo: Provider의 value prop에 context를 전달하고 있는데, context 객체가 재생성되는 것(객체, 함수는 내용물이 같더라도 재정의 되면 서로 다른 것으로 인식되기 때문)을 방지해 자식 컴포넌트가 불필요하게 리렌더링되는 것을 방지한다.
mount, unmount를 useCallback으로 감싼 이유: useMemo에서 의존성배열에 mount, unmount를 설정해놓았는데, 함수 역시 재생성되는 것을 막음으로써 useMemo 쪽에서 의존성이 달라졌다고 인식하지 않도록 한다.
const mount = useCallback((id: string, element: ReactNode) => { setOverlayById(overlayById => { const cloned = new Map(overlayById); cloned.set(id, element); return cloned; }); }, []); const unmount = useCallback((id: string) => { setOverlayById(overlayById => { const cloned = new Map(overlayById); cloned.delete(id); return cloned; }); }, []); const context = useMemo(() => ({ mount, unmount }), [mount, unmount]); return ( <OverlayContext.Provider value={context}> {children} {[...overlayById.entries()].map(([id, element]) => ( <React.Fragment key={id}>{element}</React.Fragment> ))} </OverlayContext.Provider> );
TypeScript
복사
https://jangky000.github.io/posts/모달컴포넌트와z-index,ReactPortal 모달 컴포넌트가 어떤 z-index를 가진 부모의 자손이 될 지 모르기 때문에 Portal을 이용하지 않고 이렇게 한 듯
return ( <OverlayContext.Provider value={context}> {children} {[...overlayById.entries()].map(([id, element]) => ( <React.Fragment key={id}>{element}</React.Fragment> ))} </OverlayContext.Provider> );
JavaScript
복사

useOverlay.tsx

import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { OverlayContext } from './OverlayProvider'; /** @tossdocs-ignore */ import { OverlayController, OverlayControlRef } from './OverlayController'; import { CreateOverlayElement } from './types'; let elementId = 1; interface Options { exitOnUnmount?: boolean; } export function useOverlay({ exitOnUnmount = true }: Options = {}) { 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); useEffect(() => { return () => { if (exitOnUnmount) { unmount(id); } }; }, [exitOnUnmount, id, unmount]); 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] ); }
TypeScript
복사
overlayRef를 생성해 OverlayController 컴포넌트의 DOM 노드를 조작한다.
react 공식문서에는 객체에 함수를 전달할 때 useCallback으로 함수들을 감싸줘야한다고 되어있는데, 이 코드처럼 객체를 useMemo로 한번에 감싸니 각 함수를 useCallback으로 감쌀 필요가 없어서 더 편리한 듯.
useMemo는 open, close, exit 등이 props로 전달되었을 때 불필요하게 리렌더링이 되는 걸 막기 위함.
const overlayRef = useRef<OverlayControlRef | null>(null); 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] );
TypeScript
복사

OverlayController.tsx

/** @tossdocs-ignore */ import { forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useState } from 'react'; import { 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} />; });
TypeScript
복사
forwardRef를 사용함으로써 해당 컴포넌트의 DOM 노드를 부모 컴포넌트에서 조작할 수 있다.
export const OverlayController = forwardRef(function OverlayController( { overlayElement: OverlayElement, onExit }: Props, ref: Ref<OverlayControlRef> ) {
TypeScript
복사
useImperativeHandle: 부모 컴포넌트에서 해당 컴포넌트의 dom 노드를 조작할 때 특정 함수만 사용할 수 있게 한다.
handleOverlayClose를 useCallback으로 감싼 이유는 useImperativeHandle에서 사용되기 때문에
const handleOverlayClose = useCallback(() => setIsOpenOverlay(false), []); useImperativeHandle( ref, () => { return { close: handleOverlayClose }; }, [handleOverlayClose] );
TypeScript
복사
setState를 하면 react에서 비동기적으로 상태 변경을 처리한다. 비동기적으로 처리하는 이유는 상태 변경 사항을 모아서 배치로 한꺼번에 업데이트하기 때문
상태 변경이 처리되면 브라우저에 DOM 업데이트를 요청하는데 이는 다음 브라우저 렌더링 주기에 반영된다.
상태 변경과 브라우저 렌더링 타이밍이 안맞으면 약간의 지연이 생길 수도 있다. 가끔 open 애니메이션이 실행되지 않는 이유는 지연으로 인해 애니메이션 프레임이 손실되서가 아닐까. (ㅎㅎ 추측;)
requestAnimationFrame은 브라우저가 repaint 하기 전에 task를 예약하므로 프레임 손실 없이 애니메이션이 시작되어 이렇게 짠 게 아닐까.
useEffect(() => { // NOTE: requestAnimationFrame이 없으면 가끔 Open 애니메이션이 실행되지 않는다. requestAnimationFrame(() => { setIsOpenOverlay(true); }); }, []);
TypeScript
복사