DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Twelve confirmations behind a 50 line hook, and not one of them returns a promise

Twelve places in Nakodo ask "are you sure". They all go through this:

export type Ask = {
  title: string;
  text: string;
  action: string; // the confirm button
  cancel?: string;
  destructive?: boolean;
  run: () => void;
};

// One confirmation dialog per component, kept filled while it animates out.
// Render `dialog` anywhere in the component and call `confirm` to open it.
export function useConfirm() {
  const [ask, setAsk] = useState<Ask | null>(null);
  const [open, setOpen] = useState(false);
  const confirm = (a: Ask) => {
    setAsk(a);
    setOpen(true);
  };
  const dialog = (
    <AlertDialog open={open} onOpenChange={setOpen}>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{ask?.title}</AlertDialogTitle>
          <AlertDialogDescription>{ask?.text}</AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>{ask?.cancel ?? "Cancel"}</AlertDialogCancel>
          <AlertDialogAction variant={ask?.destructive ? "destructive" : "default"} onClick={() => ask?.run()}>
            {ask?.action}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
  return [confirm, dialog] as const;
}
Enter fullscreen mode Exit fullscreen mode

50 lines with the imports. Four components use it, twelve call sites between them. There is no provider, no context, no portal of my own and no global dialog state.

The thing I did not build: await confirm()

The pattern you see most often turns a dialog into a promise:

if (!(await confirm("Delete this campaign?"))) return;
await deleteCampaign(id);
Enter fullscreen mode Exit fullscreen mode

It reads beautifully in a blog post. Three things put me off it in this app.

A promise that may never settle. The dialog resolves on confirm and on cancel, but a component can unmount between the question and the answer: the user navigates, a parent re-renders a list and the row disappears, a route change happens underneath. Now there is an awaited promise with nobody to resolve it, and the code after the await is code that may simply never run. That is fine when it is only a return. It is not fine when there is cleanup after it.

Transitions. Every one of these actions runs inside useTransition, and the thing that starts the transition should be the click on the confirm button, not a continuation resumed some time later in a handler that has already returned. Passing run keeps the Server Action call lexically inside the component that owns the pending state:

confirm({
  title: "Delete this campaign?",
  text: "This removes the brief, results, conversations and drafts for this campaign. It can't be undone.",
  action: "Delete",
  destructive: true,
  run: () => run(() => deleteCampaignAction(campaignId)),
});
Enter fullscreen mode Exit fullscreen mode

The call site reads the same either way. With a callback the question and the consequence sit in one object literal, in the order a reader wants them: what we ask, what we explain, what the button says, what happens. The await version splits that across a condition and the lines after it.

What I give up is honest: sequential flows. Two questions in a row, or a question inside a loop, would be much nicer with promises, and if I ever need one I will add a second hook rather than change this one. Twelve call sites, zero of them sequential.

Why the dialog keeps its text after you close it

The comment at the top of the hook is the only non obvious line in it:

// One confirmation dialog per component, kept filled while it animates out.
Enter fullscreen mode Exit fullscreen mode

onOpenChange={setOpen} flips open to false. It does not clear ask. The obvious looking version, which I wrote first, resets both:

onOpenChange={(o) => { setOpen(o); if (!o) setAsk(null); }}
Enter fullscreen mode Exit fullscreen mode

That version has a visible bug. The dialog animates out over 100ms with a fade and a slight zoom:

duration-100 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95
Enter fullscreen mode Exit fullscreen mode

Clear ask on close and the title and body become undefined before those 100ms have run, so the dialog you watch sliding away is an empty box with two buttons in it. Nobody files a bug about this. It just feels broken, and you cannot see why in a screenshot.

So the state stays until the next confirm() replaces it. The cost is that the last question's words sit in the DOM, inside a closed dialog, until another question is asked. For a confirmation, where the text is UI copy and not data, that is a fine price. If one of these dialogs ever shows something sensitive, clearing it on an exit animation callback is the fix, not clearing it on close.

AlertDialog, not Dialog

Radix has both, and the differences are the whole reason the right one matters:

  • The role is alertdialog, which tells a screen reader this is a question that interrupts, and the title and description are wired to aria-labelledby and aria-describedby by the primitive.
  • Clicking the overlay does not close it. A plain dialog dismisses on an outside click, which is correct for a details panel and wrong for a question: a stray click should not be able to answer it, in either direction.
  • Focus goes to the dialog and Escape cancels. Cancel is the safe answer, so the keyboard default is the one you want.

Our footer is flex-col-reverse on phones and sm:flex-row sm:justify-end above that. On a narrow screen the primary action ends up at the bottom, nearest the thumb, with cancel above it; on a wide screen they sit right aligned in the conventional order. One class pair, written once, applies to all twelve.

The copy rules, which matter more than the hook

Every Ask has an action string, and the rule is that it repeats the verb from the title. The set, with the runtime numbers and names filled in:

Title Confirm Cancel
Don't send this email? Don't send Keep
Rewrite this email? Rewrite Cancel
Stop this conversation? Stop Keep
Reopen this conversation? Reopen Keep stopped
Say no politely? Say no Keep talking
Agree [fee] and introduce them? Agree [fee] Not yet
Delete this campaign? Delete Cancel
Contact 24 creators? Contact 24 Cancel
Move to Pro on 4 November? Move to Pro Keep Business

Two of those are worth dwelling on.

"Don't send this email?" with buttons "OK" and "Cancel" is a logic puzzle: cancelling a negative. With "Don't send" and "Keep" there is nothing to work out, and the word "Keep" says what the alternative is rather than that you are abandoning something. Eight of the twelve override the default "Cancel" for exactly this reason, and the override exists in the type because I kept needing it.

"Contact 24" is the other one. The number is in the button, not only in the title, because the title is what you read first and the button is what you read last, right before you commit. The same dialog's body does the arithmetic out loud: how many have no email address and are being left out, how many of your plan's allowance remain, and whether the list is longer than that allowance. What happens next is a sequence the user is starting on somebody else's behalf, so the dialog is the last honest moment to say how big it is.

One of the twelve is destructive

variant={ask?.destructive ? "destructive" : "default"}
Enter fullscreen mode Exit fullscreen mode

Exactly one call site sets it: deleting a campaign. Not stopping a conversation, not declining an offer, not cancelling a subscription.

The test is not "is this important" or "is this scary". It is "can this be undone". Stopping a conversation has a Reopen button right next to it, and there is a dialog for that too. Declining politely closes a thread that can be reopened. Moving to a smaller plan takes effect at the renewal date and can be changed back before then, and the plans page shows you exactly what you would keep. Deleting a campaign removes the brief, the results, the conversations and the drafts, and there is no undo, so that one button is red.

A red button on everything important is a red button on nothing. Once two of the twelve were red I could feel the dilution, and the rule above is what I use to keep it at one.

Where we deliberately do not ask

The plan switcher is the clearest case, because it has a branch that decides whether to ask at all:

const choose = (id: PlanId) => {
  if (planRank(id) > planRank(current)) {
    if (billedByStripe) upgrade.ask(id);
    else change(id); // Checkout shows the price and confirms it
  } else if (!billedByStripe && id !== "free") {
    change(id); // a granted plan moving to a smaller paid one: Checkout confirms it
  } else {
    confirm({ /* the downgrade question */ });
  }
};
Enter fullscreen mode Exit fullscreen mode

Upgrades that go through Stripe Checkout get no confirmation dialog from us, because Checkout is a confirmation: it states the price, it has a button, and it is a page the user has to act on. A dialog in front of it would be a question about whether you want to see a question.

Downgrades get asked, and the body of that question is generated rather than written, because what you lose depends on where you are going:

title: cancel ? `Cancel ${currentName}?` : later ? `Move to ${PLANS[id].name} on ${renews}?` : `Move to ${PLANS[id].name}?`,
text: later
  ? `You keep ${currentName} until ${renews}${id === "free" ? " and won't be charged again" : ""}. Then: ${downgradeEffects(id)}`
  : downgradeEffects(id),
Enter fullscreen mode Exit fullscreen mode

The date is in the title. The first time I wrote this the title was "Are you sure?" and the date was in the body, and the thing people needed to know, that nothing changes today, was the thing they were least likely to read.

Four hooks, not one provider

Each of the four components calls useConfirm() and renders its own {dialog}. That means four AlertDialog roots mounted in the app at once, all closed, each holding the words of whatever it last asked.

A global provider with a confirm() in context would be one dialog and one piece of state. I did not do it because of what the callback form implies: run closes over the component's own action runner, its selection state, its transition. Hoisting the dialog to a provider while the behaviour stays local means passing a callback up to a context to be invoked from a tree you do not control, which is more machinery than four mounted dialogs cost.

If a fifth and sixth component need it, nothing changes. If one needs two independent dialogs at once, it calls the hook twice and gets two, which is the one thing a global provider cannot do at all.

Top comments (0)