dApp Frontend Development with React
The main mistake when developing a dApp frontend — transferring architectural patterns from regular web applications. In a dApp, there's no "user" in the classical sense, no sessions, no server-based authorization. There's a wallet, there are signatures, there are transactions that can hang for an hour. This fundamentally changes how state management and UX are organized.
Stack: Why wagmi + viem, Not ethers.js Directly
wagmi v2 is not just a wrapper over viem. It's an opinionated layer for React with:
- Automatic connection state management (connected/disconnecting/reconnecting)
- Cache invalidation after transactions (via TanStack Query under the hood)
- SSR compatibility out of the box
- Type-safe ABI encoding through viem
viem replaced ethers.js v5 as the de-facto standard for low-level operations. Better TypeScript support, tree-shakeable, significantly smaller bundle size.
// wagmi v2 configuration
import { createConfig, http } from "wagmi";
import { mainnet, arbitrum, optimism } from "wagmi/chains";
import { injected, metaMask, coinbaseWallet } from "wagmi/connectors";
export const config = createConfig({
chains: [mainnet, arbitrum, optimism],
connectors: [
injected(),
metaMask(),
coinbaseWallet({ appName: "MyDApp" }),
],
transports: {
[mainnet.id]: http(process.env.NEXT_PUBLIC_RPC_MAINNET),
[arbitrum.id]: http(process.env.NEXT_PUBLIC_RPC_ARBITRUM),
[optimism.id]: http(process.env.NEXT_PUBLIC_RPC_OPTIMISM),
},
});
Managing Transaction State
A transaction in EVM is not an HTTP request. It goes through stages: pending in mempool → included in block → confirmed (N confirmations). User should understand what's happening at each stage.
Pattern for tracking transactions:
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
function MintButton() {
const { writeContract, data: hash, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
confirmations: 2, // wait for 2 blocks
});
// Three UI states:
// isPending = transaction sent, waiting for signature in wallet
// isConfirming = signed, waiting for inclusion in block
// isSuccess = confirmed
return (
<button disabled={isPending || isConfirming}>
{isPending ? "Signing..." : isConfirming ? "Waiting for block..." : "Mint"}
</button>
);
}
Reading Contract Data: useReadContract and Multicall
To read multiple values use useReadContracts with multicall — this batches requests into one RPC call:
import { useReadContracts } from "wagmi";
const { data } = useReadContracts({
contracts: [
{ ...tokenContract, functionName: "balanceOf", args: [userAddress] },
{ ...tokenContract, functionName: "totalSupply" },
{ ...stakingContract, functionName: "pendingRewards", args: [userAddress] },
],
// Automatically uses multicall3 if available
});
Important: wagmi uses TanStack Query for caching. By default, data is considered fresh for 4 seconds. For DeFi dashboards with fast-changing data, lower staleTime to 0 and enable refetchInterval.
Error Handling: Typical Cases
User rejected — user clicked Cancel in MetaMask. error.code === 4001. Just close the modal, don't show error toast.
Insufficient funds — error.code === -32000. Show a clear message with the missing amount.
Revert with reason string — contract threw require("Insufficient allowance"). Parse through viem:
import { ContractFunctionRevertedError } from "viem";
if (error instanceof ContractFunctionRevertedError) {
const reason = error.data?.errorName ?? error.shortMessage;
// Show reason to user
}
Stuck transaction — transaction pending > 5 minutes. Need UI for speed up (replace with same nonce and +10% gas) or cancel (send 0 ETH to self with same nonce). wagmi doesn't provide this out of the box, write via viem sendTransaction with explicit nonce.
Wallet UX: Common Mistakes
Don't check chainId — user is connected to mainnet, but dApp runs on Arbitrum. Without explicit check and switchChain call, user sees cryptic error. Use useChainId hook and guard component:
import { useChainId, useSwitchChain } from "wagmi";
function ChainGuard({ requiredChainId, children }) {
const chainId = useChainId();
const { switchChain } = useSwitchChain();
if (chainId !== requiredChainId) {
return (
<button onClick={() => switchChain({ chainId: requiredChainId })}>
Switch Network
</button>
);
}
return children;
}
Hydration mismatch with SSR — wallet state unknown on server, connected on client. Next.js throws hydration error. Solution: wrap wallet-dependent components in dynamic import with ssr: false, or use mounted state.
EIP-712 Signatures (Typed Data)
For off-chain actions (permit, gasless mint, snapshot voting) use typed data signatures instead of regular transactions:
import { useSignTypedData } from "wagmi";
const { signTypedData } = useSignTypedData();
const signature = await signTypedData({
domain: { name: "MyProtocol", version: "1", chainId: 1, verifyingContract },
types: { Order: [{ name: "tokenId", type: "uint256" }, { name: "price", type: "uint256" }] },
primaryType: "Order",
message: { tokenId: 42n, price: parseEther("0.1") },
});
Project Structure
src/
abi/ # JSON ABI files for contracts
config/ # wagmi config, chain configs
contracts/ # typed contract instances (viem getContract)
hooks/ # custom hooks for each contract
useStaking.ts
useTokenBalance.ts
components/ # UI components
lib/ # utilities (formatUnits wrappers, address shortener)
Generating types from ABI through wagmi CLI (wagmi generate) — eliminates manual type writing and automatically generates hooks.
Performance: What Matters in dApp
- Bundle size: viem + wagmi ≈ 60KB gzipped. Avoid duplication (don't pull ethers.js if you already have viem)
- RPC rate limits: don't make requests in useEffect without debounce, use wagmi cache
-
Suspense: TanStack Query under wagmi supports Suspense — use for skeletons instead of manual
isLoadingchecks -
Wallet detection:
injected()connector finds all injected providers, no need for separate packages for each wallet







