Frontend Transaction UX: Status, Errors, Recovery
Wrap a real kit-based send flow in a strict UI state machine so users always know what happened and what to do next.
Transactions do not fail in only one way.
Users reject signatures.
RPC requests time out.
Blockhashes expire.
Programs reject invalid inputs.
If your UI treats all of that as one generic red error banner, the product feels broken even when the chain is behaving normally.
The right model is simple.
A transaction UI is a state machine.
It should always tell the user:
- what phase they are in
- whether they can act again
- what the next safe action is
Step 1: Normalize Errors Before They Reach The UI
Create lib/solana/uiTxState.ts:
export type TxStatus =
| "idle"
| "preparing"
| "awaiting-signature"
| "sending"
| "confirmed"
| "failed";
export type TxErrorKind = "user-rejected" | "network" | "expired" | "program" | "unknown";
export function classifyTxError(err: string): TxErrorKind {
const message = err.toLowerCase();
if (message.includes("reject")) return "user-rejected";
if (message.includes("blockhash") || message.includes("expired")) return "expired";
if (message.includes("timeout") || message.includes("network")) return "network";
if (message.includes("instruction") || message.includes("program")) return "program";
return "unknown";
}
export function getRecoveryHint(kind: TxErrorKind) {
switch (kind) {
case "user-rejected":
return "Review the transaction and sign again when you are ready.";
case "expired":
return "Rebuild the transaction with a fresh blockhash and retry.";
case "network":
return "Retry after checking RPC health and wallet connection.";
case "program":
return "Check the inputs and on-chain account state before retrying.";
default:
return "Inspect logs, then retry only after you understand the failure.";
}
}This file does one job.
It turns messy raw failures into a small set of UI decisions.
That is what makes the component predictable.
Step 2: Wrap A Real Send Flow In Explicit Status States
Create components/solana/TxActionCard.tsx:
"use client";
import React, { useState } from "react";
import { address, lamports } from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";
import { useSendTransaction, useWallet } from "@solana/react-hooks";
import { classifyTxError, getRecoveryHint, type TxStatus } from "@/lib/solana/uiTxState";
type TxActionCardProps = {
destination: string;
};
export function TxActionCard({ destination }: TxActionCardProps) {
const wallet = useWallet();
const { send, isSending, signature } = useSendTransaction();
const [status, setStatus] = useState<TxStatus>("idle");
const [error, setError] = useState("");
const run = async () => {
setError("");
if (wallet.status !== "connected") {
setStatus("failed");
setError("Connect a wallet before sending a transaction.");
return;
}
try {
setStatus("preparing");
const instruction = getTransferSolInstruction({
source: wallet.session.account,
destination: address(destination),
amount: lamports(100_000n),
});
setStatus("awaiting-signature");
setStatus("sending");
await send({ instructions: [instruction] });
setStatus("confirmed");
} catch (e: any) {
const kind = classifyTxError(String(e?.message ?? e));
setStatus("failed");
setError(`Failed (${kind}). ${getRecoveryHint(kind)}`);
}
};
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={wallet.status !== "connected" || isSending}
className="px-3 py-2 rounded bg-cyan-700 text-white"
>
{isSending ? "Sending..." : "Send 0.0001 SOL"}
</button>
{signature ? <div className="text-xs">Signature: {signature}</div> : null}
{error ? <div className="text-xs text-red-300">{error}</div> : null}
</div>
);
}This is a real transaction path.
The button is not faking a signature anymore.
It builds a real system transfer instruction and sends it through the shared wallet-aware transport.
Read The State Machine In Order
preparing
The UI is building the instruction and validating local prerequisites.
This is where missing wallet state or bad destination input should fail early.
awaiting-signature
The wallet is about to ask the user for approval.
This phase matters because rejection here is not a network outage.
It is a user decision.
sending
The signed transaction is moving through the transport pipeline.
At this point, the UI must stay locked.
Double-submit bugs happen when the interface ignores this phase.
confirmed
The send flow completed successfully and you have a real signature to show.
That signature is not decoration.
It is how the user verifies the outcome in Explorer.
The Failure Mode To Avoid
Do not collapse all failures into one bucket like Something went wrong.
That hides the next safe action.
A user rejection should not look like a broken RPC.
An expired blockhash should not look like a program bug.
Your UI does not need perfect error parsing.
It does need useful recovery behavior.
What You Can Do After This
You can build a transaction card that sends a real instruction, exposes real state, and gives the user a recovery path instead of a dead end.
That is the baseline for every serious Solana frontend.
The next lesson uses the same discipline inside the final capstone page.
Quick Check
Why should a transaction UI use explicit phases like `preparing`, `awaiting-signature`, and `sending` instead of one generic loading state?
Why is classifying transaction failures useful before they reach the component?
Up Next in Solana Kit Clients
Client Testing Strategy: Lite Unit and Integration
Protect the same runtime, wallet, and transaction boundaries with a test strategy that is fast enough to use constantly and honest enough to catch real breakage.