2023년 1월

JSX as State

콜백과 이벤트 핸들러에서도 모달을 열 수 있게, JSX를 state로 다루는 방법

선언형 모달의 한계

React에는 다이얼로그나 모달을 만들 때, 여러가지 방법이 있습니다. boolean state를 만들어 컴포넌트를 mount / unmount 할 수도 있고, Shadcn의 모달의 패턴처럼 modal의 trigger와 content를 묶어 추상화하여 선언형으로 관리하는 패턴도 있습니다. 더 나아가서는 <dialog /> 태그를 사용해서 미리 선언하고, <button /> 태그의 command / commandfor 속성을 사용해서 모달을 여는 패턴도 있습니다.

function MyModal() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Open</button>
      {isOpen && <div aria-role="dialog">Hello</div>}
    </div>
  );
}

하지만 세 방식은 모두 공통된 한계점을 가지고 있습니다. 모달을 열어야 하는 시점이 컴포넌트 트리 깊숙한 콜백 안에 있거나, API 응답 핸들러 또는 비동기의 결과로 모달을 열어야하는 경우 곧바로 복잡해집니다. 또한 근본적으로 trigger와 content가 1:1 또는 N:1 관계를 가지고 있어 modal이나 dialog를 여는 버튼은 여러개 일 수 있지만, 열리는 dialog는 하나만 가져야하는 한계가 있습니다.

function MyModal() {
  const [isOpen, setIsOpen] = useState(false);

  const somthing = async () => {
    await fetch(...);
    setIsOpen(true);
  }

  return (
    <div>
      <button onClick={somthing}>Open</button>
      {isOpen && <div aria-role="dialog">Hello</div>}
    </div>
  );
}

물론 위와 같이 비동기 이후에 상태를 변경하거나, dialog의 내용을 state로 관리한다면 문제를 해결할 수도 있습니다. 하지만 비동기 과정이 끝나서 모달을 열기전에 부모 컴포넌트가 Unmount 될 수 있고, state의 관리부담이 커져 더욱 복잡해집니다. 뿐만 아니라 여전히 1:1, N:1 의 관계에서 오는 한계가 있어, 하나의 trigger로 dialog를 여러개 열 수 없습니다.

JSX를 state로

어떻게하면 이 문제를 해결할 수 있을까요? 저는 modal의 사용패턴을 선언형에서 명령형으로 변경하여 해결했습니다. 핵심 아이디어는 JSX 자체를 state로 관리하는 것입니다. React의 virtual DOM이 거대한 js object라는 사실은 잘 알려진 사실입니다. 그렇다면 JSX를 외부 스토어에 넣어 관리하면 어떻게 될까요? virtual DOM의 구성을 원하는 시점에 원하는 형태로 조작할 수 있지 않을까요? 저는 이 아이디어를 바로 실험해보았습니다.

type ModalEntry = {
  key: string;
  element: React.ReactElement;
};

let modals: ModalEntry[] = [];
const listeners = new Set<() => void>();

const modalStore = {
  getSnapshot: () => modals,
  subscribe: (listener: () => void) => {
    listeners.add(listener);
    return () => {
      listeners.delete(listener);
    };
  },
  publish: (mutateFn: (modals: ModalEntry[]) => ModalEntry[]) => {
    modals = mutateFn(modals);
    listeners.forEach((listener) => listener());
  },
};

라이브 데모

더 나아가기

NextJS 에서는 SSR의 이점을 극대화하기 위해서는, hook을 사용하는 컴포넌트를 최대한 dom tree의 leaf로 배치하는 것이 중요합니다. 때문에 modal 을 여는 동작, 여는 동작을 컴포넌트로 추상화하여 선언형으로 관리하는 것이 효과적입니다.

import { useModal } from "@/shared/lib/modal";
import * as Slot from "@radix-ui/react-slot";
import type { ComponentProps, MouseEvent, ReactElement } from "react";

interface ModalTriggerProps extends ComponentProps<"button"> {
  asChild?: boolean;
  render: ReactElement;
}

export function ModalTrigger(props: ModalTriggerProps) {
  const { render, asChild = false, onClick, ...rest } = props;
  const modal = useModal();
  const Comp = asChild ? Slot.Root : "button";

  return <Comp onClick={() => modal.open(render)} {...rest} />;
}

언제 쓰면 좋을까

전체 구현과 README는 jsx-jump-modal GitHub 저장소에서 확인할 수 있습니다.