비동기 confirm 모달 만들기 (with Zustand)

김채은·2024년 5월 13일
0
post-thumbnail

배경

Text Me! 이벤트 페이지 개발 중, 1인 당 최대 3개의 편지를 열람할 수 있도록 제한하는 요구사항이 생겼다. 서비스 안내 페이지가 존재하긴 하지만 3개의 편지를 무심코 눌렀다가 더 이상 편지를 열 수 없다는 것을 알게 되면 사용자가 당황할 것이라 생각해서 편지를 열기 전 확인 절차를 거치는 모달이 필요하다고 생각했다.

브라우저에서 제공하는 confirm 메서드를 사용해도 되지만, 기존에 사용하던 알림 모달 디자인이 있기 때문에, UI/UX를 고려하여 confirm 모달을 만드는 게 좋겠다고 생각했다.

구현

모달은 전체 서비스에서 한번에 하나만 노출된다고 가정하고 전역 상태로 관리했다. 라이브러리는 Zustand를 사용했지만, 어떤 상태 관리 라이브러리를 사용해도 무방하다.

  • content: 모달에 띄워질 내용 문장
  • yesButtonText: 클릭 시 true를 반환하는 버튼의 텍스트
  • noButtonText: 클릭 시 false를 반환하는 버튼의 텍스트
  • clickYesButton: Yes 버튼 이벤트 핸들러
  • clickNoButton: No 버튼 이벤트 핸들러
  • openConfirmModal: 모달을 여는 메서드
import { create } from "zustand";

interface OpenModalProps{
  content: string;
  yesButtonText?: string;
  noButtonText?: string;
}

interface ConfirmModal {
  content: string;
  yesButtonText: string;
  noButtonText: string;
  clickYesButton: (value: unknown) => void;
  clickNoButton: (value: unknown) => void;
  openConfirmModal: ({ content, yesButtonText, noButtonText }: OpenModalProps) => Promise<boolean>;
}

const useConfirmModal = create<ConfirmModal>((set, get) => ({
  content: null,
  yesButtonText: null,
  noButtonText: null,
  clickYesButton: null,
  clickNoButton: null,
  openConfirmModal: () => { ... }
}));

export { useConfirmModal };

이렇게 전역 상태를 만들어주고 ConfirmModal 컴포넌트를 작성했다. 루트 컴포넌트에 배치하여 전역적으로 노출 혹은 비노출되는 컴포넌트이다.

const ConfirmModal = () => {
  const { content, yesButtonText, noButtonText, clickYesButton, clickNoButton } = useConfirmModal();

  if (!content){
    return <></>
  }

  return (
    <Container>
      <TextParser text={content} />
      <RowLayout>
        <Button type="button" onClick={clickNoButton}>
          {noButtonText}
        </Button>
        <Button type="button" onClick={clickYesButton}>
          {yesButtonText}
        </Button>
      </RowLayout>
    </Container>
  );
};

export default ConfirmModal;

컴포넌트 내에서 이벤트 핸들러 안에 openConfirmModal을 await로 호출하여 사용할 수 있다.

const { openConfirmModal } = useConfirmModal();

const confirmOpen = async () => {
  const confirm = await openConfirmModal({ 
    content: "편지를 열면 열 수 있는 편지 개수가 차감돼요. 열람하시겠어요?\n(현재 열람 가능 편지 수: 3)", 
    yesButtonText: "열기"
  });
  
  if (!confirm) {
    return;
  }
  
  open(letter.id);
};

이제 핵심 기능인 openConfirmModal의 구현을 함께 보겠다. confirm 창이 열려있는 동안 다음의 메서드들이 실행되지 않고 대기하고 있어야 하므로 Promise와 async/await을 이용해 구현했다.


const useConfirmModal = create<ConfirmModal>((set, get) => ({
  // ...
  openConfirmModal: ({ content, yesButtonText = "확인", noButtonText = "취소" }) => {
    set({ content, yesButtonText, noButtonText });
    
    const promise = new Promise((resolve, reject) => {
      set({
        clickYesButton: resolve, 
        clickNoButton: reject 
      });
    });

    return promise.then(
      () => {
        set({ content: null, yesButtonText: null, noButtonText: null });
        return true;
      },
      () => {
        set({ content: null, yesButtonText: null, noButtonText: null });
        return false;
      }
    );
  },
}));

openConfirmModal을 선언하면 Promise가 Pending 상태에 진입한다. 인자로 넘어오는 resolve와 reject를 각각 clickYesButton, clickNoButton에 할당하면, 컴포넌트 상에서 Yes 버튼을 눌렀을 때 Promise가 resolve되고, No 버튼을 눌렀을 때 reject 된다.
resolve가 됐을 때는 promise.then의 첫번째 인자인 onfulfilled가, reject이 됐을 때는 두 번째 인자인 onrejected가 수행되고 이들은 각각 true와 false를 리턴한다. 이것이 위 컴포넌트 코드에서 받아온 confirm이 된다.

열기 버튼을 누르면 편지가 열리지만 취소를 누르면 아무 일도 일어나지 않는 confirm 모달이 완성됐다.

마치며

서버와 통신할 때 Promise를 자주 쓰지만, Promise를 이용해서 프론트엔드 단의 플로우를 만들어 본 경험은 드문 것 같다.
간단한 모듈을 구현하면서 Promise의 개념도 다시 한번 복습할 수 있는 기회가 되었고, pending, fulfilled, rejected라는 상태가 어떻게 정의되고 진입하는지 모호했던 부분을 확실히 이해하게 됐다.

profile
배워서 남주는 개발자 김채은입니다 ( •̀ .̫ •́ )✧

0개의 댓글