프론트엔드

toss/slash의 useOverlay 분석

yoxxin 2023. 12. 19. 00:12

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;

현재 이 방식이 마음에 들지 않는 이유가 몇가지 있다.

  1. ModalApp 컴포넌트 리턴문에 선언되어 있다. (Overlay는 화면에 띄어지는 것이기 때문에 컴포넌트에 포함된 것이 어색하다)
  2. Modalrestaurant 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');
  1. useOverlay를 사용하는 컴포넌트를 OverlayProvider 로 감싼다.
  2. useOverlay 의 반환값인 overlay 를 이용해 Overlay 컴포넌트를 렌더링한다.
    1. overlay 가 제공하는 isOpen, close 는 이전 모달 사용방식의 isModalOpen, setIsModalOpen(false) 과 같은 의미이다.
  3. overlay.open 을 Promise로 감쌀 수 있다.
    1. onCloseonConfirm 에서 resolve(결과) 하여 Overlay를 선언한 곳에서 결과에 따라 다른 로직을 수행할 수 있다.
  4. 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]
  );
}

openoverlayElementOverlayController 로 감싸서 mount 함수를 호출하여 컴포넌트를 등록한다.(렌더링된다)

또한 cleanup을 통해 등록된 컴포넌트가 Unmount시 unmount 함수를 호출하여 메모리에서 제거한다.

closeOverlayController 에서 열어준 ref(useImperativeHandle) 기능을 통해 컴포넌트를 닫는다.

exitunmount 를 호출한다.

5. 정리

overlayController: 받은 Overlay 컴포넌트를 감싸서 공통으로 사용할 기능을 추가한다.

overlayProvider: 등록한 Overlay 컴포넌트들을 관리하고 렌더링한다.

useOverlay: 사용자가 사용하기 쉽게 overlayController와 overlayProvider 기능을 받아서 open, close, exit 로 추상화하여 내보낸다.

 

역할과 책임에 따라 적절하게 분리되어 있어서 좋은 구조였다.