Post 16
Getting the Transaction UI Right: Reorgs, Replacements, and Dropped Txs
A pending spinner and a hope is not a transaction UI. Confirmations, speed-ups, cancellations, reorgs, and drops each need a designed state and a clear next action. Here is the state machine and the viem code behind it.

Submitting a transaction is the easy part. What separates a product people trust from one they refresh nervously is how the interface behaves in the ninety seconds after — when the transaction is pending, or got sped up in the wallet, or was dropped by the mempool, or landed and then a reorg un-landed it.
The states, and what the user should see
| State | UI | Next action for the user |
|---|---|---|
| Signing | "Confirm in your wallet" | Approve or reject in the wallet |
| Submitted / pending | Hash + explorer link + honest estimate | Wait, or speed up — they can leave the page |
| Speeding up / cancelling | Detect the replacement, follow the new hash | None — the UI keeps up |
| Confirmed (1 block) | "Confirmed" — but not final yet on some chains | Proceed, cautiously |
| Finalised (N blocks) | "Done" | None |
| Reverted | Plain-language reason, nothing charged beyond gas | Retry, or fix the input |
| Dropped | "This didn't make it into a block" | Resubmit |
Wait for the receipt — and handle replacement
waitForTransactionReceipt is the workhorse. Its onReplaced callback fires when the user speeds up or cancels the transaction in their wallet, giving you the new hash so your UI does not sit forever waiting on a transaction that no longer exists.
1import { publicClient } from "@/lib/viem";2 3export async function trackTransaction(4 hash: `0x${string}`,5 { onReplaced }: { onReplaced?: (newHash: `0x${string}`, kind: string) => void } = {},6) {7 const receipt = await publicClient.waitForTransactionReceipt({8 hash,9 confirmations: 1,10 timeout: 120_000,11 onReplaced: (replacement) => {12 // 'repriced' = sped up, 'cancelled' = cancelled, 'replaced' = other13 onReplaced?.(replacement.transaction.hash, replacement.reason);14 },15 });16 17 if (receipt.status === "reverted") {18 throw new TxRevertedError(receipt);19 }20 return receipt;21}Detecting a drop
A dropped transaction never gets a receipt — waitForTransactionReceipt just times out. To tell 'still pending' from 'gone', check whether the transaction is still known to the node and whether its nonce has been used by something else.
1async function classifyPending(hash: `0x${string}`, from: Address, nonce: number) {2 const tx = await publicClient.getTransaction({ hash }).catch(() => null);3 if (tx?.blockNumber) return "mined";4 if (tx) return "pending"; // still in the mempool5 6 // node no longer knows the tx — did another tx take its nonce?7 const currentNonce = await publicClient.getTransactionCount({ address: from });8 return currentNonce > nonce ? "replaced-or-dropped" : "dropped";9}Persist the pending hash, the from-address, and the nonce to localStorage when you submit. If the user reloads mid-pending, you can re-attach waitForTransactionReceipt and restore the exact state instead of showing 'idle'.
Reorgs: confirmed is not final
On chains without fast finality, a transaction can be included in a block and then that block can be orphaned by a reorg — your 'confirmed' UI is now wrong. The mitigation is to require more confirmations for anything consequential, and to subtly distinguish 'included' from 'final'.
1function requiredConfirmations(chainId: number, usdValue: number) {2 if (chainId === mainnet.id) {3 if (usdValue > 10_000) return 12;4 if (usdValue > 100) return 3;5 return 1;6 }7 // most L2s inherit L1 finality; 1 is usually fine for display,8 // but a bridge withdrawal is a different conversation9 return 1;10}1// wagmi surfaces this via useWatchBlockNumber; the manual version:2const unwatch = publicClient.watchBlockNumber({3 onBlockNumber: async () => {4 const receipt = await publicClient5 .getTransactionReceipt({ hash })6 .catch(() => null);7 if (!receipt) {8 // our transaction is no longer in a block — reorged out9 setState({ kind: "reorged", hash });10 unwatch();11 }12 },13});Write the revert reason for a human
1import { BaseError, ContractFunctionRevertedError } from "viem";2 3function explainError(err: unknown): string {4 if (err instanceof BaseError) {5 const revert = err.walk((e) => e instanceof ContractFunctionRevertedError);6 if (revert instanceof ContractFunctionRevertedError) {7 const name = revert.data?.errorName;8 if (name === "SaleNotActive") return "The sale has ended — nothing was charged.";9 if (name === "InsufficientBalance") return "Not enough balance for this.";10 if (name === "SlippageExceeded") return "Price moved too much — try again or raise slippage.";11 }12 if (err.name === "UserRejectedRequestError")13 return "You declined the request in your wallet.";14 }15 return "The transaction didn't go through. Nothing was charged beyond gas.";16}The user does not need to know what a reorg is. They need the UI to never claim something happened that later un-happened — and to always tell them what to do next.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.