[React] 컴포넌트를 화면 중앙에 오버레이하기 (modal 띄우기)

초코침·2023년 11월 15일
1

React

목록 보기
13/14

여행 계획 프로젝트를 진행하면서 구현했던 모달 로직에 대해 정리하려 합니다.

저는 아래 사진처럼 입력한 내용이 맞는지 확인하기 위한 다이얼로그를 띄우는 작업을 하고 있었는데요.

프로젝트에서 모달 UI를 사용할 일이 많지 않았어서 간단하게 구현하고 넘어갈 수도 있었지만

무언가를 화면 중앙에 띄우는 로직 자체를 추상화해 놓으면, 추후 모달을 띄워야 하는 상황이 왔을 때 유용하게 사용할 수 있겠다는 생각이 들었습니다.

그래서 작업 방향을 다이얼로그를 띄우는 것이 아니라 어떤 컴포넌트를 화면 중앙에 띄우기 위한 로직을 개발하는 것으로 수정했습니다.

일반적으로 모달을 띄울 때

아무 생각 없이 구현했다면 이렇게 띄우고 닫힘 상태를 관리하는 state를 두어서 빠르게 구현하고 넘어갔을 것 같습니다.

const App = () => {
	const [isOpen, setIsOpen] = useState(false);
	
	const openDialog = () => {
		setIsOpen(true);
	}

	return (
		<div>
			<button onClick={openDialog}>열기</button>
			{ isOpen && <Dialog />}
		</div>
	);
}

그런데 이 방식은 여러 개의 모달을 띄워야할 때 그 문제점이 잘 나타납니다.

const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isEditorModalOpen, setIsEditorModalOpen] = useState(false);
const [isViewerModalOpen, setIsViewerModalOpen] = useState(false);
const [isFormModalOpen, setIsFormModalOpen] = useState(false);
// ...

여러 모달의 띄우고 닫힘 상태를 관리하기 위해 state가 많이 필요할 것이고, 이 상태를 변경하는 핸들러 또한 모달의 개수만큼 필요합니다.

또한, 자식 컴포넌트에서 모달을 띄우고 닫는 로직을 필요로 한다면 부모에서 만들어 둔 핸들러들을 prop으로 넘겨야 합니다.


따라서

  1. 여러 개의 모달을 열 수 있도록
  2. props drilling 없이 간편하게 모달을 띄우고 닫을 수 있도록

모달을 띄우고 닫는 로직과 상태를 전역으로 관리해야겠다고 생각했어요.


모달 로직을 전역으로 관리하기 위해 Context API를 사용했습니다.

Context API를 사용한 이유는 상태 관리 라이브러리의 종류의 영향을 받지 않도록 작성하고 싶었기 때문입니다..!

구현 컨셉

구현 컨셉은 다음과 같습니다.

  • Context에서 현재 띄워져 있는 컴포넌트를 state로 관리하기
  • Context 내부 state에 컴포넌트를 추가하고 삭제하는 함수 포함하기
  • Context의 함수들은 커스텀 훅을 통해 컴포넌트에 제공하기

구현하기

먼저 모달 관련 로직을 갖는 context를 만듭니다.

export type OverlayMethod = { close: () => void; closeAll: () => void };

export type OverlayElementCreator = (props: OverlayMethod) => ReactNode;

export const OverlayContext = createContext<{
  mount(id: number, overlayElementCreator: OverlayElementCreator): void;
  unmount(id: number): void;
} | null>(null);

context 내부 state에서는 ({close, closeAll}) ⇒ <Modal close={close} /> 이렇게 생긴 함수를 관리할 것입니다. 앞으로 이 함수를 creator라고 지칭하여 설명하겠습니다.

띄워줄 컴포넌트를 이 로직을 사용할 때 결정하고 싶어서 찾아보다가 알게 된 패턴인데요.

이런 식으로 함수를 가지고 있다가, state를 순회하면서 해당 함수를 호출하여 렌더링하면 컴포넌트의 종류와 관계없이 해당 로직을 사용할 수 있습니다.

(여담으로 프로젝트에서 redux를 사용했는데, 이렇게 함수를 전역 상태로 저장하면 non-serialization 에러가 발생하기 때문에 Context API를 사용하게 되었습니다..ㅎㅎ)


다음은 Context API를 사용하기 위한 Provider입니다.

const OverlayProvider = ({ children }: PropsWithChildren) => {
  const [overlayId, setOverlayId] = useState<Map<number, OverlayElementCreator>>(new Map());

  const mount = useCallback((id: number, overlayElementCreator: OverlayElementCreator) => {
    setOverlayId((prev) => {
      const cloned = new Map(prev);
      cloned.set(id, overlayElementCreator);
      return cloned;
    });
  }, []);

  const unmount = useCallback((id: number) => {
    setOverlayId((prev) => {
      const cloned = new Map(prev);
      cloned.delete(id);
      return cloned;
    });
  }, []);

  const unmountAll = useCallback(() => {
    setOverlayId(new Map());
  }, []);

  const context = useMemo(() => ({ mount, unmount, unmountAll }), [mount, unmount, unmountAll]);

  return (
    <OverlayContext.Provider value={context}>
      {children}
      {[...overlayId.entries()].map(([id, overlayElementCreator]) =>
        createPortal(
          <React.Fragment key={id}>
            <Backdrop onClose={() => unmount(id)} />
            <div className={`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2`}>
              {overlayElementCreator({ close: () => unmount(id), closeAll: () => unmountAll() })}
            </div>
          </React.Fragment>,
          document.querySelector('#overlay')!
        )
      )}
    </OverlayContext.Provider>
  );
};

mount는 state에 creator 함수를 추가하는 역할(컴포넌트 띄우기)이고, unmount는 특정 creator를 state에서 제거하는 역할(컴포넌트 닫기)입니다. 그리고 unmountAll은 state를 비워주는 역할(컴포넌트 모두 닫기)입니다.

각 함수는 state가 변경됐을 때 새롭게 만들어질 필요가 없으므로 useCallback으로 감싸주었습니다.

그리고 이 Provider로 App을 감싸면 App은 children을 통해 렌더링되고, 그 아랫줄부터 context 내부의 state를 순회하며 createPortal을 통해 렌더링합니다.

이때 context가 가진 unmountunmountAll 메서드를 creator에 넣어줌으로써 컴포넌트 내부에서 닫기 기능을 구현할 수 있도록 합니다.


다음은 context를 활용하는 커스텀 훅입니다.

const useOverlay = () => {
  const context = useContext(OverlayContext);

  if (context === null) {
    throw new Error('OverlayProvider가 필요합니다.');
  }

  const { mount } = context;

  const overlay = useCallback(
    (overlayElementCreator: OverlayElementCreator) => {
      mount(Date.now(), overlayElementCreator);
    },
    [mount]
  );

  return overlay;
};

Provider로 감싸지지 않았다면 이 훅을 사용할 수 없기 때문에 에러를 발생시킵니다.

context에 접근할 수 있다면 mount 함수를 가져옵니다.

이 mount 함수는 컴포넌트의 IDcreator 함수를 인자로 받는데, creator 함수만을 받는 openOverlay 함수를 하나 더 만들어 이 훅을 사용할 때 컴포넌트의 id는 따로 넣어주지 않아도 되도록 합니다.


위 기능은 다음과 같이 사용합니다.

const App = () => {
  const overlay = useOverlay();

  const openFormModal = () => {
    overlay(({ close }) => <Form closeForm={close} />);
  };

  return (
    <Main>
      <Button type="button" onClick={openFormModal}>
        폼 모달 열기
      </Button>
    </Main>
  );
};

훅을 사용해 overlay 함수를 가져와 위처럼 사용하면 됩니다.

만약 폼 내부에서 다이얼로그를 띄우고 싶다면 내부에서 훅을 호출하면 됩니다.

const Form = ({ closeForm }: Props) => {
  const overlay = useOverlay();

  const openDialogBox = () => {
    overlay(({ close, closeAll }) => <DialogBox closeDialog={close} closeAllOverlay={closeAll} />);
  };

  return (
    <FormContainer>
      <H2>내용을 입력해 주세요.</H2>
      <Input placeholder="입력하세요." />
      <Input placeholder="입력하세요." />
      <Input placeholder="입력하세요." />
      <SaveButton type="button" onClick={openDialogBox}>
        저장
      </SaveButton>
      <CancelButton type="button" onClick={() => closeForm()}>
        닫기
      </CancelButton>
    </FormContainer>
  );
};

이렇게 사용하면 여러 컴포넌트를 겹쳐 띄울 수 있습니다.


깃 레포지토리에서 위 전체 코드를 보실 수 있습니다.

감사합니돠.

Reference

https://nakta.dev/how-to-manage-modals-1
https://slash.page/ko/libraries/react/use-overlay/src/useoverlay.i18n/

profile
블로그 이사중 🚚 (https://sungjihyun.vercel.app)

0개의 댓글