One state machine
Treat every wallet action as a small state machine: idle, waiting for wallet, rejected, pending on-chain, confirmed, or failed.
Your UI should tell the user what is happening and what to do next. Never leave them guessing after they click a button.
Two pending states
There are two different waits. First, the wallet popup is waiting for the user to approve or reject. Second, the transaction hash is waiting to be included in a block.
MetaMask error code -32002 means a wallet request is already open. Do not send another request. Ask the user to open MetaMask and respond.
Handle wallet errors
Do each one yourself, then tap it to tick it off. The ticks are only a checklist for you: they are not marked or scored.
0 of 5 done
Error helper
Put this helper near your transaction component or in a small utils file. It keeps your button code clean.
Provider errors have a message, code, and sometimes data. For users, the code is usually enough.
function getWalletErrorMessage(error: unknown) {
const err = error as { code?: number; message?: string }
if (err.code === 4001) {
return 'You rejected the request in your wallet.'
}
if (err.code === -32002) {
return 'A wallet request is already open. Open MetaMask and approve or reject it.'
}
return err.message || 'Something went wrong. Please try again.'
}Safer write button
This pattern blocks double-clicks and separates wallet approval from on-chain confirmation.
useWriteContract sends the transaction. useWaitForTransactionReceipt waits for the hash to be included in a block.
import { useState } from 'react'
import { useWaitForTransactionReceipt, useWriteContract } from 'wagmi'
export function MintButton({ abi, address }: { abi: any; address: `0x${string}` }) {
const [hash, setHash] = useState<`0x${string}` | undefined>()
const [message, setMessage] = useState('')
const [waitingForWallet, setWaitingForWallet] = useState(false)
const { writeContractAsync } = useWriteContract()
const receipt = useWaitForTransactionReceipt({
hash,
})
const busy = waitingForWallet || receipt.isLoading
async function onMint() {
if (busy) return
setMessage('Check your wallet. You are about to send a transaction.')
setWaitingForWallet(true)
try {
const txHash = await writeContractAsync({
abi,
address,
functionName: 'mint',
args: [],
})
setHash(txHash)
setMessage('Transaction sent. Waiting for confirmation.')
} catch (error) {
setMessage(getWalletErrorMessage(error))
} finally {
setWaitingForWallet(false)
}
}
return (
<div>
<button onClick={onMint} disabled={busy}>
{waitingForWallet ? 'Waiting for wallet' : receipt.isLoading ? 'Confirming' : 'Mint'}
</button>
{hash && <p>Transaction hash: {hash}</p>}
{receipt.isSuccess && <p>Confirmed.</p>}
{receipt.isError && <p>Transaction failed or reverted.</p>}
<p>{message}</p>
</div>
)
}Avoid retry spam
A common bug is sending the same wallet request again because the user clicks twice or the popup is hidden behind another window.
Disable the button while waiting. If you see -32002, do not auto-retry. Tell the user to finish the open request.
Write for recruiters
Recruiters and judges may not test every edge case. Make the UI visible in your screenshots and README.
Add a short note: “Handles user rejection, already-pending wallet requests, pending transaction confirmation, and failed receipts.” This shows you understand real dapp UX.
Do this now
Do each one yourself, then tap it to tick it off. The ticks are only a checklist for you: they are not marked or scored.
0 of 5 done