Post 06
Wallet-Connect UX: Designing for the Unhappy Path
Connect, sign, done — that is the demo. Real Web3 UX is rejected signatures, wrong networks, stuck transactions, and RPCs that go quiet. Design the failure states first and the product feels trustworthy. Here is the code for each one.

The reason so many Web3 interfaces feel janky is that they were built happy-path first. Connect wallet, sign, success screen — ship it. Then real users arrive with a wallet on the wrong network, a pending transaction from yesterday, a hardware wallet that times out, and an RPC endpoint that is rate-limiting. None of that was designed.
Design the unhappy path first. Here is the map, with viem and wagmi.
Connection is a state machine, not a button
| State | What the user needs to see |
|---|---|
| Disconnected | Clear CTA, list of supported wallets, no jargon |
| Connecting | Which wallet, a way to cancel, “check your wallet” prompt |
| Connected, wrong network | A button that switches, not just a warning |
| Connected, right network | Truncated address, balance, disconnect — quiet, not celebratory |
| Account changed mid-session | Detect it, update the UI, no reload |
| Connection rejected | “You declined — try again”, not an error toast |
1"use client";2import { useAccount, useConnect, useDisconnect, useSwitchChain } from "wagmi";3import { mainnet } from "wagmi/chains";4 5export function ConnectButton() {6 const { address, chainId, isConnected } = useAccount();7 const { connectors, connect, status, error } = useConnect();8 const { disconnect } = useDisconnect();9 const { switchChain } = useSwitchChain();10 11 if (!isConnected) {12 return (13 <div>14 {connectors.map((c) => (15 <button key={c.uid} onClick={() => connect({ connector: c })}16 disabled={status === "pending"}>17 {status === "pending" ? "Check your wallet…" : c.name}18 </button>19 ))}20 {error?.name === "UserRejectedRequestError" && (21 <p>You declined the request — no problem, try again.</p>22 )}23 </div>24 );25 }26 27 if (chainId !== mainnet.id) {28 return (29 <button onClick={() => switchChain({ chainId: mainnet.id })}>30 Switch to Ethereum31 </button>32 );33 }34 35 return (36 <button onClick={() => disconnect()}>37 {address!.slice(0, 6)}…{address!.slice(-4)}38 </button>39 );40}The account-change and chain-change listeners are the ones teams forget. wagmi handles them for you if you use its hooks — but if you drop to a raw provider, you must wire provider.on("accountsChanged", …) and "chainChanged" yourself, or users see stale balances after switching accounts in MetaMask.
Simulate before you ask for a signature
If a contract call will revert, find out before the user pays gas and waits. simulateContract runs the call against current state and either returns a prepared request or throws with the revert reason.
1import { simulateContract, writeContract, waitForTransactionReceipt } from "@wagmi/core";2import { config } from "@/lib/wagmi";3import { abi } from "@/lib/abi";4 5export async function mint(quantity: bigint) {6 // 1. simulate — throws with a human revert reason if it would fail7 const { request } = await simulateContract(config, {8 address: CONTRACT, abi, functionName: "mint", args: [quantity],9 });10 11 // 2. send — this is the only wallet prompt12 const hash = await writeContract(config, request);13 14 // 3. wait — with a timeout so the UI is not stuck forever15 const receipt = await waitForTransactionReceipt(config, {16 hash, timeout: 90_000,17 });18 19 if (receipt.status === "reverted") {20 throw new Error("The transaction reverted on-chain.");21 }22 return receipt;23}Every transaction has a lifecycle
1type TxState =2 | { kind: "idle" }3 | { kind: "simulating" }4 | { kind: "awaiting-signature" }5 | { kind: "pending"; hash: `0x${string}` }6 | { kind: "success"; hash: `0x${string}` }7 | { kind: "error"; message: string };8 9function TxStatus({ state }: { state: TxState }) {10 switch (state.kind) {11 case "simulating": return <p>Checking the transaction…</p>;12 case "awaiting-signature": return <p>Confirm in your wallet</p>;13 case "pending":14 return (15 <p>16 Submitted.{" "}17 <a href={`https://etherscan.io/tx/${state.hash}`} target="_blank">18 Track on Etherscan19 </a>. You can safely leave this page.20 </p>21 );22 case "success": return <p>Done. Your mint is confirmed.</p>;23 case "error": return <p role="alert">{state.message}</p>;24 default: return null;25 }26}Persist the pending hash to localStorage. If the user closes the tab and comes back, you can pick the transaction back up with waitForTransactionReceipt and restore the correct state instead of showing “idle”.
Write the copy for someone who does not think in nonces
The transaction and approval text is UX, and it is usually written by whoever wired up the contract call. It should not be.
| Default | Rewritten |
|---|---|
Approve USDC | Allow this app to spend up to 250 USDC. You can revoke this anytime. |
Transaction failed: execution reverted | This didn’t go through — the sale may have ended. Nothing was charged. |
Insufficient funds for gas | Not enough ETH to cover the network fee (~$3.20). Nothing was sent. |
Do not make the UI wait on the chain
Reading state directly from the chain on every render is slow and gets rate-limited. Put an indexer between the chain and the UI — a subgraph, a hosted indexer, or your own event-log processor — so the interface reads from a fast database and only hits an RPC for the write path and for confirmations.
If a normal person cannot read your transaction modal and understand what they are agreeing to, the interface has failed — no matter how correct the contract call is.
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.