Confirming destructive actions
A confirmation is not a speed bump. It is a question with two real answers, and it earns its interruption only when the answers lead somewhere different. Everything below comes from one hard case — removing a step from the middle of a flow graph, where what happens to the steps under it cannot be defaulted — and generalises to any structured delete.
1. Ask only when there is a question
Work out what the action would do before deciding to ask. If both answers produce the same outcome, do the thing.
const cascade = planRemoval(graph, nodeId, "cascade");
// Nothing runs after it, so both answers are the same answer and there is no
// question worth interrupting for.
if (cascade.removed.length === 1) {
remove(nodeId, "promote");
return;
}
// Both plans, drawn up now and carried by the pending record itself.
setPendingDelete({ nodeId, label, cascade, promote: planRemoval(graph, nodeId, "promote") });
Removing a leaf is one click. Removing a step with a subtree under it asks. Same button, same code path — the graph decides whether there is a question, not the button.
Corollaries:
- Reversible actions do not get a modal. If a restore lands as a new revision — so the rollback is itself undoable — do it and say what happened.
- State the person can already see gets an inline warning, not a dialog. "You have unsaved edits. Restoring discards them." belongs in the panel, above the Restore buttons.
- Never confirm a no-op. Deleting a lone connector is just the edge; do not raise the question that is about nodes.
2. Plan once. Describe and execute from the same plan
The figures in the sentence and the effect of the button must be one calculation. A dialog that says "2 steps go with it" from one function while the delete removes three from another is how a confirmation stops meaning anything.
export interface RemovalPlan {
removed: string[]; // every node that goes, this one included
edges: FlowEdge[]; // the edge list afterwards, gap-closing edges included
stranded: string[]; // steps left with nothing leading to them
promoted: string | null; // the step carried into the removed one's place
}
Hold the plans themselves on the pending record — not numbers copied off them — and have both the dialog and the executor read from there:
export interface PendingDelete {
nodeId: string;
label: string;
cascade: RemovalPlan; // what "remove everything below" would do
promote: RemovalPlan; // what "close the gap" would do
}
const below = (p: PendingDelete) => p.cascade.removed.length - 1;
Counting off the plan means the sentence cannot describe a removal other than
the one on offer, and onConfirm(pending, mode) applies the plan the copy was
written from. Draw them up at request time, not from live state under the
open dialog: the dialog is modal, so nothing can change beneath it, and a figure
recomputed on every frame is a figure that can disagree with the delete it is
describing.
3. Offer consequences, not verbs
Two verbs in a footer ("Keep" / "Remove all") read as a guess. Rows with the consequence written underneath do not.
┌─────────────────────────────────────────────────────────┐
│ REMOVE STEP │
│ │
│ Remove "Rule branch"? │
│ 4 steps run after it. Choose what happens to them. │
│ │
│ ▌Close the gap │
│ Removes this step only. One path moves up a level; │
│ 2 branches are left unconnected for you to rewire. │
│ │
│ ▌Remove all 5 steps (danger) │
│ This step and everything that runs after it. │
│ │
│ [ Cancel ] │
└─────────────────────────────────────────────────────────┘
- Each answer is a row: title, then the consequence in smaller text. Give the destructive one a danger-coloured left rule and danger-coloured title.
- Cancel is the only footer button. The answers are in the body, so there is no ambiguity about which control is the safe one.
- Reuse the shape your product already uses for "press this and it happens" — in a flow builder that is the palette row, minus the glyph. The same shape meaning the same thing is what makes a modal feel like part of the app.
- Count the total in the destructive label ("Remove all 5 steps"), because that is the number someone checks against the canvas.
4. Say what the safe answer actually costs
The point of planning first is that the safe row can be honest about its own side effects:
function gapDetail({ promote }: PendingDelete): string {
const stranded = promote.stranded.length;
if (stranded === 0)
return "Removes this step only. Everything below it moves up a level.";
return `Removes this step only. One path moves up a level; ${
stranded === 1 ? "1 branch is" : `${stranded} branches are`
} left unconnected for you to rewire.`;
}
An outlet holds one edge, so a fork's other paths have nowhere to be carried up to — they stay, unwired. Saying so is the whole reason the number was computed before the removal rather than after it. Get the singular/plural right; "1 branches" undermines the sentence it appears in.
5. Every entry point asks the same question
If a keypress, a context menu and a footer button can all remove a thing, they must all reach the identical question. Veto the library's built-in removal and hand the request to your own planner:
const onBeforeDelete = React.useCallback(async ({ nodes: doomed }) => {
const first = doomed[0];
if (!first) return true;
requestDelete(first.id); // may remove outright, may raise the dialog
return false; // the library never removes it itself
}, [requestDelete]);
Mount the dialog above both surfaces — at the editor, not inside the panel — so it does not depend on which one is open.
6. Mount it only while there is something to ask about
<AlertDialog open={pending !== null} onOpenChange={(open) => { if (!open) onCancel(); }}>
{pending && <AlertDialogContent>{/* reads `pending` with no fallbacks */}</AlertDialogContent>}
</AlertDialog>
Copy that has to cope with a state it is never shown in ends up hedged. Clear the pending record before carrying the action out, so a second press cannot land twice:
confirmDelete: (pending: PendingDelete, mode: RemovalMode) => {
setPendingDelete(null); // clear first: a second press cannot land twice
apply(pending.nodeId, mode === "cascade" ? pending.cascade : pending.promote);
}
7. Conflicts are confirmations too
An optimistic write that loses a race is the same problem: two real answers, and the app must not pick one. Send the revision you started from, and when the server rejects it, name both:
This flow changed since you opened it. You are on r41; it is now r43. Reload to pick up the newer version.
Never merge silently, and never overwrite silently. Same rule as the delete: if the outcomes differ, the person chooses.
Copy checklist
- Name the thing, in quotes, in the question: Remove "Rule branch"?
- State the scale before the choice: 4 steps run after it.
- Then hand over: Choose what happens to them.
- Consequence sentences use plain verbs — moves up a level, left unconnected for you to rewire — not "orphaned", "cascaded" or "pruned".
- No exclamation marks, no "Are you sure?", no "This action cannot be undone" unless it genuinely cannot.