본문 바로가기
개발/React

[React/TypeScript] Context API를 활용한 전역 모달 관리하기

by char_lie 2023. 11. 21.
반응형

프로젝트를 진행하는 과정에서 경고창 등으로 React의 Alert을 직접적으로 사용할 경우 사용자 경험(UX) 적으로 부정적이라는 이슈가 있고, 커스터마이징을 할 수 없단 이슈가 있었다.

특히, Alert 함수를 직접 사용 시에 동기적인 실행으로 인해 경고창이 닫히기 전까지 코드 실행이 중단되는 이슈가 발생하기에, 모달을 이용해서 알림 창을 커스텀하고자 하였다.

이 과정에서 모달 하나를 만들 경우 컴포넌트에 계속해서 귀속되는 현상으로 모달을 관리하기 힘들고, 코드가 굉장히 지져분해져서 모달을 전역으로 관리하기로 했고, 이 과정에서 Context API를 적용해 보았기에 기록으로 남겨보고자 한다.

 

 

Context API

API Context란?

React에서 지원하는 기능으로 부모 - 자식 간에 props를 날려 상태를 변화시키는 방법과 다르게 컴포넌트끼리 값을 공유할 수 있게 해주는 기능

 

사용하는 이유?

일반적으로 리액트는 컴포넌트에게 데이터를 전달하기 위해서 Porps를 통해 전달하지만, 깊은 위치에 컴포넌트에 데이터를 전달해서 사용할 경우 여러 컴포넌트를 거쳐 전달해야함. 다른 컴포넌트에 공유되어 바로 값을 시켜서 사용할 수 있게 해주는 방법

 

  • 일반적으로 리액트는 위와 같은 구조로 부모 - 자식 간의 데이터 흐름
  • 부모 컴포넌트에서 자식으로 Props로 내려서 사용
  • 여기서 D 컴포넌트에서 G 컴포넌트로 데이터를 전달해야할 필요가 있을 경우 어떻게 하면 좋을까?

위와 같이 Context를 사용하여 전역적으로 값을 관리하여 사용할 수 있음

내가 사용한 방법

1. 우선 Context는 리액트의 패키지에서 CreateContext라는 함수를 이용한다.

import { createContext } from 'react';
// modalInterface.tsx

export interface ModalClassTypeInterface {
  modalOpen: { [key: string]: boolean };
  openModal: (modalName: string) => void;
  closeModal: (modalName: string) => void;
}

// modalClass.tsx
const ModalContext = createContext<ModalClassTypeInterface>({
  modalOpen: {},
  openModal: () => {},
  closeModal: () => {},
});

 

2. Context 객체 안에는 Provider라는 컴포넌트가 들어있어서, 컴포넌트 간 공유하고자 하는 값을 Value라는 Props로 설정하면 자식 컴포넌트에서 해당 값에 바로 접근할 수 있다.

특히, 이 과정에서 Modal을 열고, 닫고하는 상태를 변경해 줄 함수가 필요했고, 각 모달의 이름을 지정해서 리스트로 모아서 같은 모달을 재사용할 경우, 이름만 호출해서 사용할 수 있도록 만들어줬다.

호출 시에, useModal()을 사용하여 각 함수를 호출 할 수 있도록 구성했다.

// modalInterface.tsx
import { ReactNode } from 'react';
export interface ModalProviderPropsInterface {
  children: ReactNode;
}

export interface ModalClassTypeInterface {
  modalOpen: { [key: string]: boolean };
  openModal: (modalName: string) => void;
  closeModal: (modalName: string) => void;
}


// modalClass.tsx
export function ModalProvider({ children }: ModalProviderPropsInterface) {
  const [modalOpen, setModalOpen] = useState<{ [key: string]: boolean }>({});

  const openModal = (modalName: string) => {
    setModalOpen(prevState => ({ ...prevState, [modalName]: true }));
  }; 

  const closeModal = (modalName: string) => {
    setModalOpen(prevState => ({ ...prevState, [modalName]: false }));
  };

  const contextValue = useMemo(
    () => ({ modalOpen, openModal, closeModal }),
    [modalOpen],
  );

  return (
    <ModalContext.Provider value={contextValue}>
      {children}
    </ModalContext.Provider>
  );
}

export const useModal = (): ModalClassTypeInterface => {
  const context = useContext(ModalContext);

  if (!context) {
    throw new Error('useModal must be used within a ModalProvider');
  }

  return context;
};

 

3. 이제 전체 내부에서 context를 사용할 수 있게 index파일에서 app을 provider로 감싸면 된다.

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { ModalProvider } from './components/modal/modalClass';

ReactDOM.render(
    <ModalProvider>
      <App />
    </ModalProvider>,
  document.getElementById('root'),
);
반응형

 

4. 기본적으로 사용할 모달의 틀을 잡아준다. 모달을 만들면서 모달의 크기와 높이를 받아서 사용할 수 있게 만들었고, Transaction을 활용해 모달에 약간의 애니메이션 및 배경이 흐려지는 효과를 집어넣었다.

import { Fragment, useRef } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { ModalPropsInterface } from '../../interface/common/modalInterface';

function Modal({
  closeModal,
  OpenModal = false,
  children,
  width,
  height,
}: ModalPropsInterface) {
  const cancelButtonRef = useRef(null);

  return (
    <Transition.Root show={OpenModal} as={Fragment}>
      <Dialog as="div" initialFocus={cancelButtonRef} onClose={closeModal}>
        <div className="fixed inset-0 z-50 bg-black bg-opacity-50 backdrop-brightness-50 overflow-y-scroll">
          <div className="flex min-h-full justify-center text-center items-center">
            <Transition.Child
              as={Fragment}
              enter="ease-out duration-300"
              enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
              enterTo="opacity-100 translate-y-0 sm:scale-100">
              <Dialog.Panel className={`rounded-3xl ${width} ${height}`}>
                {children}
              </Dialog.Panel>
            </Transition.Child>
          </div>
        </div>
      </Dialog>
    </Transition.Root>
  );
}

export default Modal;

 

5. 이제 내가 원하는 형태로 모달을 커스텀해서 사용해 준다. 알림 창으로 사용하고자 구성하였기에, 나는 크게 2가지로 확인 버튼과 취소 버튼이 있는 내용만을 만들었다.

// modalContent.tsx
import {
  ModalContentInterface,
  ModalSelectInterface,
} from '../../interface/common/modalInterface';

export function ConfirmContents({ content, okAction }: ModalContentInterface) {
  return (
    <div className="h-full p-5 bg-white rounded-2xl shadow-md flex flex-col justify-center items-center">
      <p className="text-xl text-gray-500 my-2">{content}</p>
      <div className="flex justify-center mt-3 w-[90%] ">
        <button
          type="button"
          className="rounded-md w-full py-2 mx-2 hover:bg-green-500 bg-green-400"
          onClick={okAction}>
          확인
        </button>
      </div>
    </div>
  );
}

export function ConfirmSelect({
  content,
  okAction,
  cancelAction,
}: ModalSelectInterface) {
  return (
    <div className="h-full p-5 bg-white rounded-2xl shadow-md flex flex-col justify-center items-center">
      <p className="text-xl text-gray-500 my-2">{content}</p>
      <div className="flex justify-center mt-3 w-[90%] ">
        <button
          type="button"
          className="rounded-md w-full py-2 mx-2 hover:bg-green-500 bg-green-400"
          onClick={cancelAction}>
          취소
        </button>
        <button
          type="button"
          className="rounded-md w-full py-2 mx-2 hover:bg-green-500 bg-green-400"
          onClick={okAction}>
          확인
        </button>
      </div>
    </div>
  );
}

6. 커스텀해서 사용할 모달들을 모아서 커스텀 후 원하는 곳에서 호출할 수 있도록 구성해 줬다.
아래는 예시로 내가 커스텀해서 만든 에러가 났을 때 띄워줄 모달과, 확인 버튼만 누르면 되는 확인 모달 두 개로 만들었다.

// 에러 모달
export function ErrorModal({ content }: ErrorModalInterface) {
  const { modalOpen, closeModal } = useModal();
  const errorModal = 'error';

  return (
    <div>
      <Modal
        closeModal={() => {
          closeModal(errorModal);
        }}
        OpenModal={modalOpen[errorModal]}
        width="w-[20%] bg-color-white"
        height="h-200px">
        <ConfirmContents
          content={content as string}
          okAction={() => {
            closeModal(errorModal);
          }}
        />
      </Modal>
    </div>
  );
}

// 확인 모달
export function ConfirmUpdate({ content, id, url }: UpdateConfirmInterface) {
  const { modalOpen, closeModal } = useModal();
  const updateModal = 'updateconfirm';

  return (
    <div>
      <Modal
        closeModal={() => {
          closeModal(updateModal);
        }}
        OpenModal={modalOpen[updateModal]}
        width="w-[20%] bg-color-white"
        height="h-200px">
        <ConfirmContents
          content={content as string}
          okAction={() => {
            closeModal(updateModal);
            window.location.href = url;
          }}
        />
      </Modal>
    </div>
  );
}

 

7. 사용이 필요한 곳에서 해당 모달만 호출하면 바로 형태가 지정된 모달을 호출할 수 있다.

다만, 모달을 여는 과정과 내용은 지정해 줄 필요가 있다.

아래는 예시로 내가 로그인 시 에러가 발생할 경우 모달을 띄울 때 사용한 코드이다.

아래와 같이 커스텀한 모달을 호출하고, 내용만 지정해 준 뒤 해당 모달의 이름만 openModal을 통해 호출 시에 모달을 바로 사용할 수 있다.

import { ErrorModal } from '../../components/modal/customModal';
import { useModal } from '../../components/modal/modalClass';

function LoginPage() {
  const [userId, setUserId] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>('');
  const { openModal } = useModal();

  const handleLogin = (event: React.FormEvent) => {
  	 openModal('error');
  };

  return (
    <div className="Login flex items-center justify-center h-screen">
     //
       ...
     //
      <ErrorModal content={error} />
    </div>
  );
}

export default LoginPage;

 

Context API를 채택한 이유

Redux나 Recoil을 사용하여 모달을 관리할 수 있겠지만, 이미 Redux로 로그인 토큰을 관리하는 형태로 구성을 해봤고, 다른 방법을 학습하는 과정에서 Context API란게 있단 걸 알아서 학습을 목표로 사용해 보았다.

확실히 Context API를 사용한 모달 관리가 조금 더 사용에 있어서는 편했고, Context API를 이해하는데 도움이 됐다.

반응형

댓글