Connect a React App to Ethereum with wagmi and viem
Most wagmi tutorials stop too early. They show a connect button, log an address to the console, and call it done. Then you go to build something real. Read a token balance. Send a transaction. Handle the wallet being on the wrong chain. None of the demos prepared you for that part. The loading states, the bigint math, the error codes, the typed ABIs that make the compiler help you instead of fight you: that is the actual work. This post walks through all of it.
I build React DeFi frontends for a living. wagmi and viem are what I use on nearly every project. By the end of this you will have a working app that connects a wallet, reads an ERC-20 token's balance and symbol off-chain, and sends a transfer with proper pending, success, and error handling. All of it typed. All of it current for wagmi v2 and viem.
What you're building with
If you used wagmi v1 (or v0.x), forget the API you remember. wagmi is the React layer for Ethereum: hooks, caching, wallet connection. v2 was a full rewrite on top of viem, and the hooks changed names and return shapes. The old useContractRead is now useReadContract. Hooks return data, isPending, error instead of the v1 grab-bag. Copy a snippet from a 2023 blog post and it will not compile against v2.
viem is the low-level engine. It replaced ethers.js as wagmi's foundation. It gives you native bigint everywhere, tree-shakeable functions, and very good TypeScript inference. wagmi sits on top: hooks, caching via TanStack Query, connector management. You will touch both.
Setup
Three packages. wagmi pulls in viem as a peer dependency, and every wagmi hook is built on TanStack Query, so you need that too.
bun add wagmi viem @tanstack/react-query
Check your package.json afterwards. You want wagmi v2 and viem v2:
{
"dependencies": {
"wagmi": "^2.14.0",
"viem": "^2.21.0",
"@tanstack/react-query": "^5.59.0"
}
}
Now create the config. This is the single object that tells wagmi which chains you support and how users can connect. I keep it in its own file because you import it in a few places.
// src/wagmi.ts
import { http, createConfig } from 'wagmi';
import { mainnet, base, sepolia } from 'wagmi/chains';
import { injected, walletConnect } from 'wagmi/connectors';
const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID as string;
export const config = createConfig({
chains: [mainnet, base, sepolia],
connectors: [injected(), walletConnect({ projectId })],
transports: {
[mainnet.id]: http(),
[base.id]: http(),
[sepolia.id]: http(),
},
});
// This makes wagmi's TypeScript inference aware of your exact config.
declare module 'wagmi' {
interface Register {
config: typeof config;
}
}
A few things here that the demos skip. The transports object is required in v2. http() with no argument uses the chain's default public RPC, which is fine for development but will rate-limit you in production. Drop your Alchemy or Infura URL in there before you ship: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'). A connector is the thing that talks to a wallet. The injected() connector covers MetaMask, Rabby, Brave, and any browser-extension wallet. walletConnect needs a project ID, free from cloud.reown.com (formerly WalletConnect Cloud).
That declare module block does real work. It registers your config with wagmi's type system, so hooks like useSwitchChain know your chains are exactly mainnet | base | sepolia and not some generic union. Skip it and you lose half the type safety.
Wrapping the app
wagmi needs two providers: its own WagmiProvider and a QueryClientProvider from TanStack Query. Order matters. WagmiProvider goes on the outside.
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from './wagmi';
import App from './App';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</WagmiProvider>
</React.StrictMode>
);
That is the whole plumbing to connect a React app to Ethereum. Everything below is hooks inside <App />.
Connecting and disconnecting a wallet
Three hooks own the connection lifecycle. useConnect opens a wallet. useAccount reads the current connection. useDisconnect tears it down. Here is a real connect UI that lists every configured connector and shows the account once you are in.
// src/components/WalletConnect.tsx
import { useAccount, useConnect, useDisconnect } from 'wagmi';
export function WalletConnect() {
const { address, isConnected, chain } = useAccount();
const { connectors, connect, isPending, error } = useConnect();
const { disconnect } = useDisconnect();
if (isConnected && address) {
return (
<div className="wallet">
<span>
{address.slice(0, 6)}…{address.slice(-4)}
</span>
<span>{chain?.name ?? 'Unsupported network'}</span>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
);
}
return (
<div className="wallet">
{connectors.map(connector => (
<button key={connector.uid} onClick={() => connect({ connector })} disabled={isPending}>
{isPending ? 'Connecting…' : `Connect ${connector.name}`}
</button>
))}
{error && <p className="error">{error.message}</p>}
</div>
);
}
Notice what useAccount gives you for free: address, isConnected, and the active chain object. You do not manage any of this in your own state. wagmi tracks it, persists it across reloads, and re-renders when the user switches accounts in their wallet. The accountsChanged and chainChanged listener dance you would hand-roll with raw ethers? wagmi does it inside.
Key the buttons on connector.uid, not connector.id. With multiple injected wallets installed, the id can collide. uid is unique per connector instance. And isPending from useConnect is true while the wallet popup is open. Wire it to your button's disabled state so users cannot fire five connect requests by mashing the button.
Typed ABIs: the part that pays off later
Before reading any contract, define its ABI with as const. An ABI is the list of a contract's functions and their input and output types, the thing your app reads to know how to call it. Defining it with as const is the single most useful thing you can do for developer experience in a wagmi codebase, and almost every tutorial leaves it out.
// src/abi/erc20.ts
export const erc20Abi = [
{
type: 'function',
name: 'balanceOf',
stateMutability: 'view',
inputs: [{ name: 'account', type: 'address' }],
outputs: [{ name: '', type: 'uint256' }],
},
{
type: 'function',
name: 'symbol',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'string' }],
},
{
type: 'function',
name: 'decimals',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'uint8' }],
},
{
type: 'function',
name: 'transfer',
stateMutability: 'nonpayable',
inputs: [
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
outputs: [{ name: '', type: 'bool' }],
},
] as const;
The as const is not decoration. It freezes the array into a literal type. That lets viem infer, at compile time, that balanceOf takes one address and returns a bigint, that symbol returns a string, that transfer needs [address, bigint]. Misspell a function name, pass a number where a bigint is wanted, or forget an argument, and the compiler stops you before the code runs. Without as const, every contract call falls back to any and you are flying blind.
viem ships a canonical erc20Abi you can import directly (import { erc20Abi } from 'viem'), so for plain ERC-20 work you do not even need to write the above. I am showing the long form so you can do the same for your own contracts.
Reading contract state
useReadContract reads a single view function. It is a thin wrapper over TanStack Query, so you get data, isLoading, isError, and error with caching and automatic refetching built in. Here is a component that reads a token's symbol, decimals, and the connected user's balance.
// src/components/TokenBalance.tsx
import { useAccount, useReadContract } from 'wagmi';
import { formatUnits } from 'viem';
import { erc20Abi } from '../abi/erc20';
// USDC on Ethereum mainnet
const TOKEN = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as const;
export function TokenBalance() {
const { address, isConnected } = useAccount();
const { data: symbol } = useReadContract({
address: TOKEN,
abi: erc20Abi,
functionName: 'symbol',
});
const { data: decimals } = useReadContract({
address: TOKEN,
abi: erc20Abi,
functionName: 'decimals',
});
const {
data: balance,
isLoading,
isError,
error,
} = useReadContract({
address: TOKEN,
abi: erc20Abi,
functionName: 'balanceOf',
args: address ? [address] : undefined,
query: { enabled: Boolean(address) },
});
if (!isConnected) return <p>Connect your wallet to see your balance.</p>;
if (isLoading) return <p>Loading balance…</p>;
if (isError) return <p className="error">Failed to read balance: {error.message}</p>;
const formatted = balance !== undefined && decimals !== undefined ? formatUnits(balance, decimals) : '0';
return (
<p>
Balance: {formatted} {symbol}
</p>
);
}
The non-obvious bits. balance comes back as a raw bigint in the token's smallest unit. For USDC that is six decimals, so 1 USDC is 1000000n. You never show that to a user. formatUnits(balance, decimals) converts it to the human-readable string. You pass the on-chain decimals rather than hardcoding 18, because USDC is 6 and assuming 18 is one of the most common DeFi frontend bugs I get hired to fix.
The query: { enabled: Boolean(address) } guard stops the balance read from firing before a wallet is connected. args is [address]. wagmi infers from the as const ABI that balanceOf wants exactly one address argument, and it will yell at you if you pass the wrong shape. That is the typed-ABI payoff in action.
For reading several values at once, use useReadContracts (plural). It batches them into a single multicall instead of three separate RPC round-trips. Worth it the moment you read more than two things.
Writing a transaction
Writes are a two-step dance, and mixing up the steps is where most UIs break. useWriteContract submits the transaction and hands you a hash. useWaitForTransactionReceipt watches that hash until the transaction is mined. You need both. The first resolves the instant the user confirms in their wallet. The second resolves when it is actually on-chain, which can be two seconds on Base or two minutes on a congested mainnet.
// src/components/SendToken.tsx
import { useState } from 'react';
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseUnits, isAddress, type Address } from 'viem';
import { erc20Abi } from '../abi/erc20';
const TOKEN = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as const;
const DECIMALS = 6; // USDC
export function SendToken() {
const { isConnected } = useAccount();
const [to, setTo] = useState('');
const [amount, setAmount] = useState('');
const { data: hash, writeContract, isPending, error: writeError, reset } = useWriteContract();
const {
isLoading: isConfirming,
isSuccess: isConfirmed,
error: receiptError,
} = useWaitForTransactionReceipt({ hash });
function handleSend() {
if (!isAddress(to)) return;
writeContract({
address: TOKEN,
abi: erc20Abi,
functionName: 'transfer',
args: [to as Address, parseUnits(amount, DECIMALS)],
});
}
if (!isConnected) return <p>Connect a wallet to send tokens.</p>;
return (
<div className="send">
<input placeholder="0x… recipient" value={to} onChange={e => setTo(e.target.value)} />
<input placeholder="Amount" value={amount} onChange={e => setAmount(e.target.value)} />
<button onClick={handleSend} disabled={isPending || isConfirming}>
{isPending ?
'Confirm in wallet…'
: isConfirming ?
'Sending…'
: 'Send'}
</button>
{isConfirmed && (
<p className="success">
Sent. <a href={`https://etherscan.io/tx/${hash}`}>View transaction</a>
<button onClick={reset}>Send another</button>
</p>
)}
{writeError && <p className="error">{getRevertMessage(writeError)}</p>}
{receiptError && <p className="error">Transaction failed on-chain: {receiptError.message}</p>}
</div>
);
}
Walk through the states, because this is the whole point. isPending is true while the wallet popup is open and the user has not confirmed yet. That is your "Confirm in wallet…" label. Once they confirm, writeContract resolves with a hash and isPending flips false. Now useWaitForTransactionReceipt picks up that hash and isConfirming goes true until it is mined. Only then does isConfirmed become true. Three distinct phases, three distinct labels. Collapse them into a single loading spinner and your users will think the app froze.
parseUnits(amount, DECIMALS) is the inverse of formatUnits. It turns the string "1.5" into the bigint 1500000n. Never build that number with parseFloat and multiplication. Floating point will quietly corrupt large amounts and you will send the wrong value. parseUnits does it as integer math and is the only correct way.
I validate the recipient with viem's isAddress before submitting. It is a free guard against the classic "user pasted a truncated address" support ticket.
Handling the errors the demos pretend don't exist
A user clicking "Reject" in their wallet is not a crash, and you should not show them a red stack trace for it. viem throws typed errors you can inspect. The UserRejectedRequestError is the one you will hit constantly.
import { BaseError, UserRejectedRequestError } from 'viem';
function getRevertMessage(error: unknown): string {
if (error instanceof BaseError) {
const rejected = error.walk(e => e instanceof UserRejectedRequestError);
if (rejected) return 'You rejected the transaction.';
// Surfaces the Solidity revert reason when the contract reverted.
return error.shortMessage;
}
return error instanceof Error ? error.message : 'Something went wrong.';
}
error.walk() traverses viem's nested error chain to find a specific cause. viem wraps the raw RPC error in layers, so the rejection you care about is buried a few levels down. error.shortMessage is the clean one-liner you can show in the UI. The full error.message includes the entire request payload and is for your logs, not your users. When a contract reverts with a reason string, shortMessage surfaces it. Far better than the opaque "execution reverted" you get from a naive catch.
Chain switching
Users will be on the wrong network. Do not just disable everything and show "wrong chain". Offer to fix it. useSwitchChain does exactly that.
import { useAccount, useSwitchChain } from 'wagmi';
import { mainnet } from 'wagmi/chains';
export function ChainGuard() {
const { chain } = useAccount();
const { switchChain, isPending } = useSwitchChain();
if (chain?.id === mainnet.id) return null;
return (
<div className="banner">
<span>You're on {chain?.name ?? 'an unsupported network'}.</span>
<button onClick={() => switchChain({ chainId: mainnet.id })} disabled={isPending}>
{isPending ? 'Switching…' : 'Switch to Ethereum'}
</button>
</div>
);
}
switchChain sends the wallet_switchEthereumChain request to the wallet. If the chain is not configured in the user's wallet, wagmi handles the addEthereumChain fallback on its own, as long as that chain is in your createConfig list. Another thing you would hand-roll with raw ethers, and viem just does it. Render this banner near the top of your app and the whole class of "transaction failed because I was on the wrong network" bugs goes away.
Where this goes next
You now have the full loop: a config, providers, connect and disconnect, a typed read, and a write with honest pending and error states. That is a real foundation to connect a React app to Ethereum, not a console.log demo. From here you would add useReadContracts for batched multicalls, optimistic UI on writes, and an ENS lookup with useEnsName to show human names instead of hex addresses.
The patterns above are what I drop into client projects. If you are staring at a wagmi v1 codebase that needs to move to v2, or your reads work but your writes hang with no feedback, that is the kind of code refactoring I do every week. Migrating tangled ethers integrations onto clean, typed wagmi and viem, and fixing the loading-state bugs that make a dApp feel broken.
So where does your app break right now: the connect, the read, or the write?
If your team is building a DeFi frontend and wants a senior pair of hands on the wagmi/viem layer before you ship, hire me for a free 30-minute call and I'll tell you straight what it needs.