Hamza Yerrou
Hamza Yerrou
Software Engineer
2026 · 03 · 22 · 7 min read

Wallet UX in eight unforgivable mistakes

What I learned shipping five Web3 apps. Almost every wallet flow gets the same eight things wrong.

I've shipped five Web3 front-ends in the past year. Wallet connections, smart-contract reads and writes, multi-chain switching, real-time on-chain data. In that time I've read more crypto support threads than I'd like to admit, and the same eight mistakes show up everywhere.

These aren't edge cases. They're predictable failures that happen when you treat wallet UX as plumbing rather than product.

1. The connect button has no state

Press "Connect Wallet." Nothing happens. Is it loading? Did the MetaMask popup get blocked? Did the RPC fail silently? The user presses the button again. Now there are two pending requests fighting each other.

The fix is a state machine. Four states minimum:

hljs tsx
type WalletStatus = 'idle' | 'connecting' | 'connected' | 'error'

Every state gets visible UI. Idle: the button. Connecting: a spinner with "waiting for wallet." Connected: address plus a disconnect option. Error: what went wrong and how to retry. Not hard. Just consistently skipped.

2. Chain mismatch with no path forward

"Wrong network." That's it. No button to fix it. The user has to figure out what network you need, add it to MetaMask manually, and come back.

hljs tsx
async function switchToChain(chainId: string) { try { await window.ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId }], }) } catch (err: unknown) { // Chain not added yet, so add it if ((err as { code: number }).code === 4902) { await window.ethereum.request({ method: 'wallet_addEthereumChain', params: [BSC_CHAIN_CONFIG], }) } } }

One button that calls this. Done. MetaMask handles the rest.

3. Raw hex error codes

Transaction failed. Error output:

0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000164696e73756666696369656e7420746f6b656e2062616c616e6365

Shown to the user verbatim. But that hex is just an encoded string. Three lines to decode it:

hljs ts
import { decodeErrorResult } from 'viem' const readable = decodeErrorResult({ abi: contractAbi, data: error.data }) // returns: "Insufficient token balance"

Show that instead.

4. No gas estimate before confirmation

The user clicks "Swap." MetaMask pops open with $47 in gas on a $15 transaction. They cancel. They're annoyed, and they're right to be.

hljs ts
const gasUnits = await contract.swap.estimateGas(fromToken, toToken, amount) const { gasPrice } = await provider.getFeeData() const estimatedFee = gasUnits * (gasPrice ?? 0n) // Show this in your UI before triggering the wallet popup

Let people decide if the trade is worth it before they're surprised by a MetaMask dialog.

5. The pending state is a black box

Submitted. The button goes grey. Nothing changes. Is it pending? Did it get dropped? Did the node never pick it up? On a congested network this can take minutes. Users refresh the page and lose the transaction hash.

Poll for the receipt. Link the hash to a block explorer. Show it confirming. Handle "dropped and replaced" as its own case because it happens.

6. Mobile wallets are an afterthought

Desktop flow: works perfectly. Mobile: the WalletConnect QR doesn't render properly, the deeplink to the wallet app doesn't fire, and after connecting, navigating back to the browser drops the session.

Test on an actual phone with an actual wallet app before shipping. Use the official WalletConnect v2 modal instead of rolling your own QR flow. The library handles device detection and deeplinks. You just have to use it correctly.

7. WalletConnect session expiry is silent

Sessions expire. When they do, the next transaction call returns undefined with no thrown error. The user hits a wall with no explanation.

hljs ts
client.on('session_delete', () => clearWalletState()) client.on('session_expire', () => clearWalletState())

Two event listeners. They're in the WalletConnect documentation. Read it.

8. No way to disconnect

The user wants to switch accounts or just disconnect on a shared machine. There's no button. They have to go into MetaMask settings, find your site in the connected sites list, revoke access manually, and then come back.

hljs ts
async function disconnect() { await provider.request({ method: 'wallet_revokePermissions', params: [{ eth_accounts: {} }], }) clearLocalWalletState() setStatus('idle') }

Add a disconnect button. This is a solved problem.

The pattern under all of them

Every mistake here comes from the same root: treating the wallet as a checkbox ("does the user have one connected?") rather than as an interface with its own states, failure modes, and platform differences.

Wallet connection is async. Async things fail. Async things have intermediate states. Build it like you'd build anything else that can fail in at least three interesting ways: with a state machine, with explicit error handling, and with real device testing.

← Older
Three years of Next.js: what I'd start with today
Newer →
The craft of shipping is mostly the craft of stopping