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.
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.
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 action | Result | What to do |
|---|---|---|
| Clicks Delete | true | Send the request |
| Clicks Cancel | false | Stop |
| Clicks outside the dialog | undefined | Stop, same as Cancel |
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.
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.
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.
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.
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
falseandundefinedare 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
rejectModalorSnappyModal.throwis wrapped intry/catch. - Repeated clicks cannot open a second dialog.