가이드: 비동기 확인 모달

프로젝트 삭제처럼 되돌릴 수 없는 작업은 요청을 보내기 전에 명시적인 확인이 필요합니다. 이 가이드는 await로 그 흐름을 단계별로 만들어, 하나의 함수 안에서 위에서 아래로 읽히게 합니다.

상태 대신 Promise를 쓰는 이유

상태 기반 모달에서는 클릭 핸들러가 isOpen을 바꾸고, 모달은 다른 곳에서 렌더링되며, 실제 작업은 onConfirm 콜백으로 옮겨집니다. 하나의 동작이 세 곳에 흩어집니다.

SnappyModal.show()를 쓰면 핸들러가 모달을 열고, 답을 기다린 뒤 이어서 실행합니다. 지역 변수가 그대로 유지되고, early return을 쓸 수 있으며, try/catch 하나로 전체 흐름을 감쌀 수 있습니다.

tsx
1// State-driven: one action, three places
2const [isOpen, setIsOpen] = useState(false);
3<button onClick={() => setIsOpen(true)}>Delete</button>
4<ConfirmDialog open={isOpen} onConfirm={deleteProject} onCancel={() => setIsOpen(false)} />
5
6// Promise-driven: one function
7async function onDelete() {
8  if (!(await SnappyModal.show(<ConfirmDialog />))) return;
9  await deleteProject();
10}

1. 재사용 가능한 확인 모달

모달은 결정만 알려 줍니다. 확인이면 true, 취소면 false로 resolve하고 무엇을 삭제하는지는 알지 못하므로, 앱의 모든 확인 흐름에서 같은 컴포넌트를 쓸 수 있습니다.

ConfirmDialog.tsxtsx
1import { useCurrentModal } from 'react-snappy-modal';
2
3type Props = { title: string; message: string; confirmLabel?: string };
4
5export function ConfirmDialog({ title, message, confirmLabel = 'Confirm' }: Props) {
6  const { resolveModal } = useCurrentModal();
7
8  return (
9    <div role="dialog" aria-modal="true" className="dialog">
10      <h2>{title}</h2>
11      <p>{message}</p>
12      <button onClick={() => resolveModal(false)}>Cancel</button>
13      <button onClick={() => resolveModal(true)}>{confirmLabel}</button>
14    </div>
15  );
16}

2. 모든 결과 처리하기

결과는 세 가지이며, falsy 검사 하나로 두 가지 취소 방법을 모두 처리할 수 있습니다.

사용자 행동결과처리
삭제 클릭true요청 전송
취소 클릭false중단
모달 바깥 클릭undefined취소와 동일하게 중단
tsx
1const confirmed = await SnappyModal.show(
2  <ConfirmDialog title="Delete project?" message="This cannot be undone." confirmLabel="Delete" />,
3);
4
5if (!confirmed) return; // false (Cancel) or undefined (backdrop click)

3. 실패 알리기

fetch는 HTTP 에러에서 reject하지 않으므로 response.ok를 직접 확인해야 합니다. 요청이 실패하면 다른 모달로 에러를 보여 주세요. 사용자는 맥락을 잃지 않고, 함수도 한 곳에서 끝납니다.

tsx
1async function deleteProject(project: { id: string; name: string }) {
2  const confirmed = await SnappyModal.show(
3    <ConfirmDialog
4      title={`Delete "${project.name}"?`}
5      message="This cannot be undone."
6      confirmLabel="Delete"
7    />,
8  );
9  if (!confirmed) return;
10
11  try {
12    const response = await fetch(`/api/projects/${project.id}`, { method: 'DELETE' });
13    if (!response.ok) throw new Error(`Server responded with ${response.status}`);
14  } catch (error) {
15    await SnappyModal.show(<AlertDialog message={String(error)} />);
16  }
17}

4. 저장하는 동안 모달 유지하기

진행 상태나 서버 에러를 보여 주기 위해 서버가 응답할 때까지 모달을 유지해야 할 때가 있습니다. 이때는 요청을 모달 안으로 옮기고, 성공한 뒤에만 resolve하세요.

요청이 진행되는 동안 버튼을 비활성화하고 allowOutsideClick: false를 전달해, 바깥 클릭으로 모달이 중간에 닫히지 않게 하세요.

DeleteDialog.tsxtsx
1function DeleteDialog({ projectId }: { projectId: string }) {
2  const { resolveModal } = useCurrentModal();
3  const [saving, setSaving] = useState(false);
4  const [error, setError] = useState('');
5
6  const confirm = async () => {
7    setSaving(true);
8    setError('');
9    try {
10      const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
11      if (response.ok) return resolveModal(true);
12      setError(`Delete failed (${response.status}). Please try again.`);
13    } catch {
14      setError('Network error. Check your connection and try again.');
15    } finally {
16      setSaving(false);
17    }
18  };
19
20  return (
21    <div className="dialog">
22      <p>Delete this project?</p>
23      {error && <p role="alert">{error}</p>}
24      <button disabled={saving} onClick={() => resolveModal(false)}>Cancel</button>
25      <button disabled={saving} onClick={confirm}>{saving ? 'Deleting…' : 'Delete'}</button>
26    </div>
27  );
28}
29
30const deleted = await SnappyModal.show(<DeleteDialog projectId={id} />, {
31  allowOutsideClick: false,
32});

5. resolve 대신 reject해야 할 때

취소는 정상적인 결과로 보고 resolve하세요. rejectModal(error)와 SnappyModal.throw(error)는 호출한 쪽이 반드시 처리해야 하는 실제 실패에만 쓰고, 항상 try/catch로 감싸세요. 잡지 않은 rejection은 unhandled promise rejection으로 나타납니다.

tsx
1try {
2  const file = await SnappyModal.show(<UploadDialog />);
3  attach(file);
4} catch (error) {
5  // rejectModal(error) or SnappyModal.throw(error) lands here
6  report(error);
7}

6. 같은 모달이 두 번 열리지 않게 하기

빠르게 두 번 클릭하면 핸들러가 두 번 실행되어 같은 모달이 두 개 쌓입니다. SnappyModal.isShow()가 true이면 바로 반환하거나, 다른 모달이 열려 있을 수 있다면 지역 진행 플래그를 사용하세요.

tsx
1async function onDelete() {
2  if (SnappyModal.isShow()) return; // a dialog is already open
3  const confirmed = await SnappyModal.show(<ConfirmDialog title="Delete?" message="…" />);
4  // ...
5}

체크리스트

확인 흐름을 배포하기 전에 다음을 확인하세요.

  • 모든 버튼이 값을 resolve하며 Promise가 대기 상태로 남지 않습니다.
  • false와 undefined를 모두 취소로 처리합니다.
  • 네트워크 에러뿐 아니라 HTTP 에러도 response.ok로 확인합니다.
  • 요청을 보내는 파괴적 작업 모달은 allowOutsideClick: false를 사용합니다.
  • rejectModal이나 SnappyModal.throw를 쓰는 곳은 try/catch로 감쌉니다.
  • 반복 클릭으로 두 번째 모달이 열리지 않습니다.
다음가이드: 모달 레이어 →