Skip to main content

Command Palette

Search for a command to run...

React Context로 모달 관리하기

각 모달의 상태를 더 빠르게 파악하기 위해 React Context를 적용한 과정을 기록했습니다.

Updated
5 min read

모달 컴포넌트 예시

예전에는 리액트 프로젝트에서 모달 컴포넌트를 렌더링할 때는 다음과 같은 방법을 주로 적용했다.

// Modal.tsx
interface ModalProps {
  isOpen: boolean;
  onClick: () => unknown;
  onClose: () => unknown;
}
const Modal = (props: ModalProps) => {
  const { isOpen, onClick, onClose } = props;

  if (!isOpen) {
    return <></>;
  }
  return (
    <div>
      <h2>Here is the modal.</h2>
    </div>
  );
};

export default Modal;

// Page.tsx
import Modal from "@/components/Modal";

const Page = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <main></main>
      {isOpen &&
        createPortal(
          <Modal
            isOpen={isOpen}
            onClick={() => setIsOpen(false)}
            onClose={() => setIsOpen(false)}
          />,
          document.getElementById("modal")
        )}
    </div>
  );
};

export default Page;

문제점 인식

페이지 내에서 모달이 열려야 하는지 판단하는 상태를 정의한 다음 해당 값과 모달 내 버튼을 클릭했을 때, 그리고 모달을 종료했을 때의 콜백 함수를 각각 Props로 전달함으로써 모달과 상호작용할 수 있었다.

프로젝트 내 모달의 종류가 점점 증가하게 되면서 다음과 같은 문제점들을 발견했다.

  1. 모달의 개수가 증가할 때마다 정의해야 하는 상태가 늘어난다. 각각의 모달이 어떤 컴포넌트의 상태와 연결되어 있는지, 어디서 렌더링되는지 파악하기 힘들어졌다.

  2. 페이지 내 모달이 한번에 여러 개 중첩돼서 보인다.

  3. 모달 하나를 닫을 때 여러 모달이 한꺼번에 닫히는 경우가 있었다.

3번의 경우 모달은 보통 페이지에서 모달 바깥 영역을 클릭할 때 닫히게 개발되어 있는 경우가 많다.

const modalRef = useRef(null as HTMLElement | null);
useEffect(() => {
  const onClickOutside = function (event: MouseEvent) {
    const { target } = event;
    if (!(target instanceof HTMLElement)) {
      return;
    }
    if (modalRef.current?.contains(target)) {
      return;
    }
    onClose();
  };
  window.addEventListener("click", onClickOutside);
  return () => {
    window.removeEventListener("click", onClickOutside);
  };
}, [onClose]);

브라우저 내 임의의 영역을 클릭하면 사용자가 클릭한 영역이 모달 내부에 위치하지 않을 때 콜백 함수를 호출하여 모달을 종료할 수 있다.

여러 개의 모달이 순서대로 출력되어야 하는 상황일 때 첫번째 모달을 종료할 때 나머지 모달까지 전부 비활성화되는 것을 확인했다.

React Context

이를 방지하기 위해 각 컴포넌트 내부에 상태를 정의하지 않고 React Context로 한 영역에서 관리해주기로 결정했다.

  • AppModal은 각 모달이 열렸는지의 여부 그리고 필수적인 데이터 타입이 명시되어 있다.

  • ModalContext의 Provider가 전체 컴포넌트에 상태를 제공하고 상태는 useReducer로 선언한다.

    • dispatch 함수에 인자를 전달할 때 타입을 보장하기 위한 Action 타입

    • 컨텍스트를 변경하는 modalReducer

  • createContext로 컨텍스트를 선언할 때는 Provider에서 값을 주입할 수 있어 우선 undefined!로 타입 에러가 발생하지 않게 한다.

  • ModalProvider로 진입점이 되는 컴포넌트를 포함하도록 작성한다.

// ./contexts/modal.tsx
export interface AppModal {
  alarm: {
    isOpen: boolean;
    message?: string;
  };
  success: {
    isOpen: boolean;
    message?: string;
  };
  fail: {
    isOpen: boolean;
    reason?: string;
  };
  update: {
    isOpen: boolean;
  };
}
export interface AppModalAction<Key extends keyof AppModal = keyof AppModal> {
  payload: {
    key: Key;
    value: AppModal[Key];
  };
}

export const ModalContext = createContext({
  store: undefined! as AppModal,
  dispatch: undefined! as Dispatch<ModalAction<keyof AppModal>>,
});

const modalReducer = (state: AppModal, action: ModalAction) => {
  const {
    payload: { key, value },
  } = action;
  switch (key) {
    case "alarm": {
      state = {
        ...state,
        [key]: value as AppModal["alarm"],
      };
      break;
    }
    case "success": {
      state = {
        ...state,
        [key]: value as AppModal["success"],
      };
      break;
    }
    case "fail": {
      state = {
        ...state,
        [key]: value as AppModal["fail"],
      };
      break;
    }
    case "update": {
      state = {
        ...state,
        [key]: value as AppModal["update"],
      };
      break;
    }
  }
  return state;
};

export const ModalProvider = (props: PropsWithChildren) => {
  const { children } = props;
  const [store, dispatch] = useReducer(modalReducer, {
    alarm: { isOpen: false, message: undefined },
    success: { isOpen: false, message: undefined },
    fail: { isOpen: false, reason: undefined },
    update: { isOpen: false },
  } satisfies AppModal);

  return (
    <ModalContext.Provider value={{ store, dispatch }}>
      {children}
    </ModalContext.Provider>
  );
};

// ./src/App.tsx
const App = () => {
  return (
    <ModalProvider>
      <RouterProvider router={Router} />
    </ModalProvider>
  );
};

각 컴포넌트에서 모달의 상태를 확인 및 업데이트할 수 있는 Hook을 작성해줬다.

  • Props로 전달되는 key는 undefined도 될 수 있기 때문에, 컨텍스트를 업데이트할 때 undefined인지 확인하는 과정이 필요했다.
import { useCallback, useContext, useMemo } from "react";
import { AppModal, ModalContext } from "~/contexts/modal";

interface UseModalContextProps<K extends keyof AppModal> {
  key?: K;
}

const useModalContext = <K extends keyof AppModal>(
  props: UseModalContextProps<K>
) => {
  const { key } = props;

  const { store, dispatch } = useContext(ModalContext);

  const state = useMemo(() => {
    if (key === undefined) {
      return;
    }
    return store[key];
  }, [store, key]);

  const onChange = useCallback(
    (value: AppModal[K]) => {
      if (key === undefined) {
        return;
      }
      dispatch({ payload: { key: key, value: value } });
    },
    [dispatch, key]
  );

  return { state, onChange };
};

export default useModalContext;

특정 모달이 비활성화 되어야 렌더링되는 컴포넌트가 있을 때의 Hook

interface UseModalsCloseProps<Keys extends string[]> {
  keys?: Keys;
}
const useModalsClose = <Keys extends Array<keyof AppModal>>(
  props: UseModalsCloseProps<Keys>
) => {
  const { keys = [] } = props;

  const isModalsClosed = useMemo(() => {
    return keys.every((key) => !isModalOpens[key].isOpen);
  }, [isModalOpens, keys]);

  return { isModalsClosed };
};

이후 리팩토링

React Context의 Provider를 전체 컴포넌트에 적용할 때에는 상태가 업데이트될 때마다 전체 컴포넌트가 다시 렌더링되기 때문에

  • Redux Toolkit, Jotai 등의 전역 상태 관리 라이브러리를 대신 채택하는게 사용자 경험에 더 좋을 것이라고 생각했다.

  • 만약 React Context를 채택해야 한다면 다음과 같은 작업이 필요할 거 같았다.

    • 페이지가 렌더링될 때 자동으로 보여져야 하는 모달만 컨텍스트에 관리.

    • 나머지 모달도 열렸는지 상태를 Props로 받지 않고 Hook에서 관리.

const useConfirmModal = () => {
  const { isModalsClosed } = useModalsClose({
    keys: ["alarm", "success", "fail", "update"],
  });
  const [isInnerOpen, setIsInnerOpen] = useState(true);

  const isOpen = isModalsClosed && isInnerOpen;
  const onClose = () => setIsInnerOpen(false);

  return { isOpen, onClose };
};
const ConfirmModal = () => {
  const { isOpen, onClose } = useConfirmModal();

  if (!isOpen) {
    return null;
  }
  return (
    <Modal>
      <h2>This is a confirm modal.</h2>
    </Modal>
  );
};

export default ConfirmModal;

More from this blog

AI 에이전트에서 작업 맥락을 관리하기 위해 사용한 방법

AI 에이전트의 불편함 Codex CLI와 같은 AI 코딩 에이전트를 통해서 프로젝트 개발 속도를 높일 수 있다. 그리고 혼자 작업했을 때는 놓칠 수 있는 부분도 발생할 수 있는데 AI 에이전트를 통해 보완할 수 있었다. 컴포넌트에 대해서 접근성 태그 작성 API 호출하는 비동기 코드 작성할 때 에러 처리 그리고 AI 에이전트를 잘 활용하면 불가능하

Mar 8, 202610 min read

지시사항을 분할하여 Codex로 효율적인 작업하기

이슈 Codex와 같은 AI 기반 코드 에이전트는 출력 토큰의 수가 제한되어 있다. 그래서 다양한 작업을 하나의 프롬프트에 전부 몰아서 작성하면 원하는 결과가 나오지 않을 수 있다. Codex와 같은 LLM 기반 서비스는 출력 토큰 수를 초과하게 되면 일부 단계가 누락되거나 특정 요소를 과하게 요약할 수 있고 이는 전체적인 답변 퀄리티를 낮출 수 있기 때문이다. 그래서 다양한 AI 공식문서는 특정 작업을 더 작은 단위로 분할하는 방식을 강조한다...

Nov 3, 20255 min read

Key를 활용하여 컴포넌트 상태를 초기화하기

개요 컴포넌트의 상태가 업데이트되어도 컴포넌트 내부에 배치된 컴포넌트의 상태는 그대로 유지되는 경우가 있었다. 예전에는 Props 전달 및 내부에서 useEffect를 통해서 상태를 초기 상태로 업데이트하도록 했는데 useEffect를 남용하게 되면 상태 변화 추적이 어려워지는 문제가 있었다. 그런데 useEffect 외에도 Key를 통해서 컴포넌트 상태를 초기화할 수 있다는 사실을 알게 되었다. 리스트 렌더링 React 프론트엔드에서 목록 형...

Sep 21, 20253 min read

내가 AGENTS.md를 작성하는 방법

Codex CLI 요즘 프로젝트 개발에 Codex CLI를 활용하는 이유는 다음과 같다. 특정 프로젝트를 작업할 때 Claude Code는 종종 사용량을 초과한 것과 다르게 Codex CLI는 ChatGPT Plus 요금제만으로도 토큰 사용량 초과 걱정 없이 충분하게 활용할 수 있었다. 타 CLI 기반 AI 개발 도구에 비해서 요구사항을 더 정확하게 구현하고 꼭 필요한 작업만 진행하기 때문에 코드를 검토하는 시간을 줄일 수 있다. 반복적인 코드...

Sep 21, 20254 min read
N

Nowon Lee

22 posts