Guide: Async Confirmation Dialogs

Destructive actions such as deleting a project need an explicit confirmation before the request is sent. This guide builds that flow step by step with await, so the whole decision reads top to bottom in one function.

Why a Promise instead of state

With a state-driven modal, the click handler sets isOpen, the dialog renders somewhere else, and the real work moves into an onConfirm callback. One action ends up spread across three places.

With SnappyModal.show(), the handler opens the dialog, waits for the answer, and continues. Local variables stay in scope, early returns work, and a single try/catch covers the whole flow.

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. A reusable confirm dialog

The dialog only reports the decision. It resolves true for confirm and false for cancel, and knows nothing about what is being deleted, so the same component works for every confirmation in your app.

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. Handle every outcome

There are three possible results, and one falsy check covers both ways of cancelling:

User actionResultWhat to do
Clicks DeletetrueSend the request
Clicks CancelfalseStop
Clicks outside the dialogundefinedStop, same as Cancel
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. Report failures

fetch does not reject on HTTP errors, so check response.ok yourself. When the request fails, show the error with another modal. The user stays in context and the function still ends in one place.

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. Keep the dialog open while saving

Sometimes the dialog should stay on screen until the server answers, for example to show progress or a server-side error. Move the request into the modal and resolve only after it succeeds.

Disable the buttons while the request is running, and pass allowOutsideClick: false so an outside click cannot close the dialog halfway through.

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. When to reject instead of resolve

Treat cancelling as a normal result and resolve it. Reserve rejectModal(error) and SnappyModal.throw(error) for real failures the caller has to handle, and always wrap those calls in try/catch; an uncaught rejection surfaces as an 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. Prevent opening the same dialog twice

A fast double click runs the handler twice and stacks two identical dialogs. Return early when SnappyModal.isShow() is true, or keep a local in-progress flag if other modals may legitimately be open.

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}

Checklist

Before shipping a confirmation flow, check that:

  • The dialog resolves a value for every button and never leaves the Promise pending.
  • Both false and undefined are treated as cancel.
  • HTTP errors are detected with response.ok, not only network errors.
  • Destructive dialogs that send requests use allowOutsideClick: false.
  • Any use of rejectModal or SnappyModal.throw is wrapped in try/catch.
  • Repeated clicks cannot open a second dialog.
NextGuide: Modal Layers →