react 공식 문서 복습한 것 기반으로 useOverlay 코드 분석하기
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
복사