Frontend UX rules for Ethereum dApps that prevent the most common AI agent UI bugs. Mandatory patterns for onchain buttons, approval flows, address UX, USD context, RPC reliability, theming, and pre-publish metadata. Use whenever you are building a frontend for an Ethereum dApp.
"The button works." A clickable button is not enough. It must disable immediately, show a clear pending state, and stay locked until onchain confirmation.
"Addresses are just strings." Address UX needs validation, safe formatting, copy support, explorer linking, and ENS/name handling where available.
"Token amounts are clear." Raw token values without USD context force users to guess risk and value. Show dollar context anywhere amounts matter.
Any button that triggers an onchain transaction must:
Approving..., Staking...)// Separate loading state per action
const [isApproving, setIsApproving] = useState(false);
const [isStaking, setIsStaking] = useState(false);
<button
disabled={isApproving}
onClick={async () => {
setIsApproving(true);
try {
await sendApproveTx();
} catch (e) {
notifyError("Approval failed");
} finally {
setIsApproving(false); // always release — even on rejection
}
}}
>
{isApproving ? "Approving..." : "Approve"}
</button>
Never use one shared isLoading state for multiple buttons. It causes wrong labels, wrong disabled states, and duplicate submissions.
For approval flows: isPending alone is not enough.
isPending drops to false when the wallet returns the tx hash — before on-chain confirmation. There is a window where isPending = false AND the allowance hasn't updated → button re-enables mid-flight and a user can double-submit.
Approval handlers need two states: approvalSubmitting (set on click, cleared in finally {}) to cover the wallet→confirmation gap, and approveCooldown (set after confirm, cleared after 4s + refetch) to cover the confirmation→cache gap. Both go on disabled. finally {} is required — without it a rejected tx locks the button permanently.
Show one primary action at a time:
1. Not connected -> Connect Wallet
2. Wrong network -> Switch Network
3. Needs approval -> Approve
4. Ready -> Execute action (Stake/Deposit/Swap/etc.)
Critical details:
Every displayed address should support:
Every address input should support:
If your UI kit includes dedicated address components, use them. Do not use a raw free-text field for critical address entry.
Every token/ETH amount shown to users should include USD context:
<span>0.5 ETH (~$1,250.00)</span>
<span>1,000 TOKEN (~$4.20)</span>
Do not show only token units without value context.
Healthy baseline: low, steady request volume. Spiky or sustained high QPS usually indicates frontend hook/config bugs.
Do not hardcode full-page dark backgrounds that ignore theme/system preference.
Use semantic theme tokens/classes so light/dark mode stays coherent across:
If you intentionally ship dark-only, remove or disable theme controls that no longer apply.
Users should never see raw revert selectors or silent failures.
Implement:
try {
await sendTx();
} catch (e) {
setTxError(parseContractError(e));
}
Before production release:
https://...)Always convert between contract units and display units:
import { formatEther, formatUnits, parseEther, parseUnits } from "viem";
formatEther(weiAmount);
formatUnits(tokenAmount, tokenDecimals);
parseEther("1.5");
parseUnits("100", 6); // USDC-style 6 decimals
Never show raw base units like 1500000000000000000.