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
복사