Build a Transaction Confirmation flow
State machine: idle → review → confirming → submitting → success | failure | timeout. Show a clear summary on review; require explicit confirm with an idempotency key; lock UI during submit; handle slow/failed network with retry; show distinct success/failure states with next actions. Disable double-submit. Server is source of truth — never trust a client 'success' flag.
A confirmation flow looks small but it's a safety-critical UI — the difference between "charged once" and "charged three times" is in the details. Frame it as a state machine and reason about every transition.
1. States
idle → review → confirming → submitting → { success | failure | timeout }
↑__________________________| (retry from failure)- idle — user hasn't initiated.
- review — show details ("You'll pay $52.30 to Anya"); Confirm + Cancel buttons.
- confirming — optional intermediate ("Are you sure?") for irreversible actions.
- submitting — request in flight; UI locked.
- success — done; show receipt + next action.
- failure — show error + retry / contact.
- timeout — submitted but no response; status unknown — special handling.
2. The component
function ConfirmFlow({ tx, onComplete }) {
const [state, setState] = useState("review");
const [error, setError] = useState(null);
const idempotencyKey = useRef(crypto.randomUUID()); // ONE key per attempt
const confirm = async () => {
setState("submitting");
try {
const result = await api.submit(tx, {
headers: { "Idempotency-Key": idempotencyKey.current },
timeout: 15000,
});
setState("success");
onComplete(result);
} catch (e) {
if (e.name === "TimeoutError") setState("timeout");
else { setError(e); setState("failure"); }
}
};
if (state === "review") return (
<Review tx={tx} onCancel={onComplete} onConfirm={confirm} />
);
if (state === "submitting") return <Spinner message="Processing..." />;
if (state === "success") return <SuccessReceipt result={result} />;
if (state === "failure") return (
<FailureCard error={error} onRetry={confirm} onCancel={onComplete} />
);
if (state === "timeout") return (
<TimeoutCard
onCheckStatus={() => api.getStatus(idempotencyKey.current)}
onContactSupport={...}
/>
);
}3. Idempotency — the critical correctness piece
The user clicks Confirm. The request takes 8 seconds. The button doesn't disable properly because of a race. User clicks again. Now two charges.
Defenses:
- Disable Confirm immediately on click; tie to
state === "submitting". - Generate one idempotency key per attempt (
useRef). Send it in theIdempotency-Keyheader. The server stores recent keys and returns the same result for a duplicate — no double-charge. - Retry uses the same key — the retry must be idempotent too. If you generate a new key on retry, you're creating a new attempt.
4. Timeout — the trickiest state
The request was sent; you never got a response. You don't know if it succeeded. Treat it specially:
- Don't show "Failed" — that's misleading.
- Offer a "Check status" action that queries by the idempotency key.
- Optionally auto-poll once.
- Recover the right state (success or failure) from the server.
5. UI rules
- One primary action per state.
- Show what you're charging / committing to in plain language.
- Lock the UI during submit — overlay, disabled buttons, no Esc-to-close.
- Show progress for slow operations (>2s) — at least a spinner with reassuring copy.
- Distinguish success from failure visually — color, icon, copy — and provide next steps.
- No auto-dismiss of success state if there's information (receipt) to preserve.
6. Server is source of truth
Never trust a client "I succeeded" flag. The receipt comes from the server response; on re-mount or refresh, fetch status from the server (by idempotency key or transaction id). The client UI is a view of server state.
7. Accessibility
role="dialog"for the modal shell;aria-modal="true".- Focus management — focus into the dialog; trap; return to trigger on dismiss.
role="alert"for the failure state — screen readers announce.- No color-only state — icon + text.
- Loading state announced to assistive tech.
8. Optional confirmations (UX)
- "Type the amount to confirm" for very large transfers.
- "Hold to confirm" — a button you press for ~1 second; reduces accidental taps.
- 2FA / biometric for elevated risk.
Interview framing
"State machine: review → submitting → success | failure | timeout. The two critical correctness pieces are (1) idempotency — generate a key per attempt, include in the header, retry with the same key; the server deduplicates, so a double-click can't double-charge; and (2) timeout handling — if you never got a response, you don't know the outcome, so offer a 'Check status' action instead of falsely showing failure. Lock the UI during submit, use one primary action per state, show what's being charged in plain language, and never rely on a client-side 'success' flag — the server is the source of truth. Accessibility: dialog roles, focus management, role='alert' for errors, no color-only states."
Follow-up questions
- •Why do you need an idempotency key? What does the server do with it?
- •Why is timeout special compared to failure?
- •Why retry with the *same* idempotency key?
- •How would you handle a page refresh mid-submit?
Common mistakes
- •No idempotency — double-click double-charges.
- •Treating timeout as failure — misleads the user.
- •Generating a new key on retry — multiple charges if the original succeeded.
- •Client-trusted success without server confirmation.
- •Allowing Esc to close mid-submit and leaving an orphan transaction.
Performance considerations
- •Not a performance question, but: don't block the main thread; show progress within 100ms; keep the submit endpoint fast (P95 < 2s for the happy path).
Edge cases
- •Page refresh between Confirm and response.
- •Network drops mid-flight.
- •User closes laptop, returns next day.
- •Slow backend — show progress, don't time out at 5s.
Real-world examples
- •Stripe Elements / payment confirmation.
- •Banking transfer flows (always idempotent + 2FA).
- •GitHub destructive-action confirmations (type repo name).