Week 4 • Frontend Transaction Ux Status Errors Recovery
Frontend Transaction UX: Status, Errors, Recovery
Implement a strict transaction UX state machine so users always know what is happening and what to do next.
Step 1: 0-to-1 Theory
Primitives
status machine: controlled UI states for tx lifecycleerror category: actionable error bucketrecovery action: explicit next step users can take
Mental Model
Good transaction UX behaves like a checkout flow:
- it never double-charges
- it always shows where you are
- it always tells you what to do if something fails
That is exactly what a simple state machine gives you.
Invariants
- Every failure state must expose one next action.
- In-flight actions must disable duplicate submission.
- Success must show signature.
Quick Checks
- Why is a state machine better than random booleans?
- Why should success always include signature?
Step 2: Real-World Implementation (Code Solution)
Create lib/solana/uiTxState.ts:
// Keep status values small and explicit so the UI cannot drift.
export type TxStatus =
| "idle"
| "preparing"
| "awaiting-signature"
| "broadcasting"
| "confirming"
| "confirmed"
| "failed";
// Error kinds are user-facing categories. The raw error stays in logs.
export type TxErrorKind = "user-rejected" | "network" | "expired" | "program" | "unknown";
export function classifyTxError(err: string): TxErrorKind {
const e = err.toLowerCase();
// The goal is not perfect parsing. The goal is actionable UX.
if (e.includes("reject")) return "user-rejected";
if (e.includes("blockhash")) return "expired";
if (e.includes("timeout") || e.includes("network")) return "network";
if (e.includes("instruction") || e.includes("program")) return "program";
return "unknown";
}Create components/solana/TxActionCard.tsx:
"use client";
import React, { useState } from "react";
import { classifyTxError, type TxStatus } from "@/lib/solana/uiTxState";
export function TxActionCard() {
// Keep tx UI state in a single place so it is impossible to show conflicting statuses.
const [status, setStatus] = useState<TxStatus>("idle");
const [signature, setSignature] = useState("");
const [error, setError] = useState("");
const run = async () => {
// Clear previous attempt output.
setError("");
setSignature("");
try {
// These status transitions are the UX contract.
setStatus("preparing");
setStatus("awaiting-signature");
setStatus("broadcasting");
setStatus("confirming");
// Replace with real service call:
const fakeSig = "3Ab...demo-signature";
setSignature(fakeSig);
setStatus("confirmed");
} catch (e: any) {
const kind = classifyTxError(String(e?.message ?? e));
// Always show a next action to the user.
setError(`Failed (${kind}). Suggested action: retry or rebuild.`);
setStatus("failed");
}
};
return (
<div className="rounded-xl border border-zinc-700 p-4 space-y-2">
<div className="text-sm">Status: {status}</div>
<button onClick={run} disabled={status !== "idle" && status !== "failed" && status !== "confirmed"} className="px-3 py-2 rounded bg-cyan-700 text-white">
Run Transaction
</button>
{signature && <div className="text-xs">Signature: {signature}</div>}
{error && <div className="text-xs text-red-300">{error}</div>}
</div>
);
}Expected result:
- deterministic status progression and actionable error output.
Step 3: Mastery Test
- Easy: where do we prevent double-submit?
- Medium: how do we map raw errors to actionable categories?
- Hard: how would you persist tx status across route navigation?