Back to Blog
Web3ReactTutorialethers.js

Smart Contract Integration with React and ethers.js: A Practical Guide

May 12, 2026
10 min read

React developers hit the same wall the first time they touch smart contracts. A smart contract is a program living at an address on a blockchain. You call its functions like an API. Simple idea. The first try rarely goes well.

The TypeScript compiler complains. The wallet popup never shows. The transaction fails without a word and the screen just sits there. Half the tutorials online use ethers.js v5. ethers.js is the library you use to talk to the chain from JavaScript, and v6 has a different API than v5. So you copy a Stack Overflow answer from 2022 and get a wall of red squiggles.

This is the guide I wanted back then. ethers.js v6 the whole way, TypeScript everywhere, and the parts that break in production, not just the happy path. By the end you have a real read-and-write flow against an ERC-20 token, wrapped in a React hook you can drop into any app.

Prerequisites

You should be fine with React 18+ (hooks, effects, basic state) and TypeScript. You don't need Solidity. You just need to know what a smart contract is, and you already do: a program at an address that exposes functions you can call.

Never connected a wallet to a website? Install MetaMask and put a tiny bit of test ETH in your account on a testnet like Sepolia. You need it to run the write example for real.

Setup

Install ethers.js v6. That's the only dependency for now.

npm install ethers
# or
bun add ethers

Make sure you're on v6, not v5. Check package.json:

"ethers": "^6.13.0"

The two are not API-compatible. If a tutorial shows imports like ethers.providers.Web3Provider or ethers.utils.parseEther, that's v5, and the rest of this guide won't match.

Core Concept: Provider → Signer → Contract

Everything in ethers v6 runs through three objects. Get this order straight and most of the library makes sense.

Provider  →  read-only connection to the blockchain
   ↓
Signer    →  a specific account that can sign and send transactions
   ↓
Contract  →  a typed wrapper around a deployed contract address,
             bound to either a Provider (read-only) or Signer (read + write)

A Provider is your window into the chain. It reads public state: balances, contract storage, block numbers. No user permission needed. A read-only Provider is enough to show data.

A Signer is a Provider plus an account that can sign messages and send transactions. In a browser app the Signer comes from MetaMask (or another wallet). You ask MetaMask for a Signer and it pops up a connect dialog.

A Contract is a typed handle on one deployed address. Build it with a Provider and you can only read. Build it with a Signer and you can call anything, including functions that change state and cost gas.

Most early Web3 bugs come from mixing these up. "It works in dev but not in prod" is often "I used a Provider when I needed a Signer."

Connecting to a Wallet

In v6 the browser wallet wraps the EIP-1193 window.ethereum object using BrowserProvider. Note that getSigner() is async in v6. That changed from v5.

import { BrowserProvider, type Signer } from 'ethers';

declare global {
  interface Window {
    ethereum?: import('ethers').Eip1193Provider;
  }
}

export async function connectWallet(): Promise<{
  provider: BrowserProvider;
  signer: Signer;
  address: string;
}> {
  if (!window.ethereum) {
    throw new Error('No wallet detected. Install MetaMask or another EIP-1193 wallet.');
  }

  const provider = new BrowserProvider(window.ethereum);

  // This triggers the MetaMask popup if the site isn't already connected.
  await provider.send('eth_requestAccounts', []);

  const signer = await provider.getSigner();
  const address = await signer.getAddress();

  return { provider, signer, address };
}

Two things to flag. eth_requestAccounts is what opens the connect popup. Calling getSigner() alone won't prompt the user. The declare global block tells TypeScript what window.ethereum is. Skip it and you get a type error.

Reading From a Contract

We'll use a minimal ERC-20 ABI. The ABI is the list of functions on the contract and how to call them. You only list the ones you actually use. It doesn't have to be complete.

import { Contract, formatUnits, type Provider } from 'ethers';

const ERC20_ABI = [
  'function balanceOf(address owner) view returns (uint256)',
  'function decimals() view returns (uint8)',
  'function symbol() view returns (string)',
  'function transfer(address to, uint256 amount) returns (bool)',
] as const;

export async function getTokenBalance(
  provider: Provider,
  tokenAddress: string,
  ownerAddress: string
): Promise<{ formatted: string; symbol: string }> {
  const token = new Contract(tokenAddress, ERC20_ABI, provider);

  // Run the three reads in parallel.
  const [raw, decimals, symbol] = await Promise.all([
    token.balanceOf(ownerAddress) as Promise<bigint>,
    token.decimals() as Promise<bigint>,
    token.symbol() as Promise<string>,
  ]);

  return {
    formatted: formatUnits(raw, decimals),
    symbol,
  };
}

Two ethers v6 details worth remembering. First, numbers coming back from contracts are native JavaScript bigint, not the BigNumber class from v5. No more .toNumber() or .toString() ceremony, but you can't mix bigint and number in math without converting. Second, formatUnits and parseUnits live at the top level now. In v5 they were ethers.utils.formatUnits. The ethers.utils namespace is gone.

Writing to a Contract

Writing means sending a transaction, which means a Signer. The flow has three stages: submitted, mined, confirmed. Your UI has to show each one or users will think the app is broken.

import { Contract, parseUnits, type Signer, type TransactionReceipt } from 'ethers';

type TransferResult =
  | { status: 'success'; receipt: TransactionReceipt }
  | { status: 'reverted'; receipt: TransactionReceipt }
  | { status: 'rejected'; reason: string }
  | { status: 'error'; reason: string };

export async function transferToken(
  signer: Signer,
  tokenAddress: string,
  to: string,
  amount: string // e.g. "1.5"
): Promise<TransferResult> {
  try {
    const token = new Contract(tokenAddress, ERC20_ABI, signer);

    const decimals = (await token.decimals()) as bigint;
    const value = parseUnits(amount, decimals);

    // Sends to mempool, MetaMask popup appears here.
    const tx = await token.transfer(to, value);

    // Wait for one confirmation.
    const receipt = await tx.wait();
    if (!receipt) {
      return { status: 'error', reason: 'No receipt returned' };
    }

    return receipt.status === 1 ? { status: 'success', receipt } : { status: 'reverted', receipt };
  } catch (err) {
    // User clicked "Reject" in MetaMask.
    if (isUserRejection(err)) {
      return { status: 'rejected', reason: 'User rejected the transaction' };
    }
    return { status: 'error', reason: extractMessage(err) };
  }
}

function isUserRejection(err: unknown): boolean {
  return (
    typeof err === 'object' && err !== null && 'code' in err && (err as { code: unknown }).code === 'ACTION_REJECTED'
  );
}

function extractMessage(err: unknown): string {
  if (err instanceof Error) return err.message;
  return 'Unknown error';
}

A few traps here. A successful tx.wait() does not mean the transaction did what you wanted. It means it was mined. Always check receipt.status === 1. A reverted transaction is mined and confirmed and did nothing. User rejection has its own error code, ACTION_REJECTED. Tell it apart from real failures so you don't throw a scary error toast at someone who just changed their mind.

A Clean React Hook

In a real app you don't call these functions straight from components. Wrap them in a hook that owns the connection state, hands back a typed contract, and handles loading and errors in one place.

import { useCallback, useEffect, useState } from 'react';
import { BrowserProvider, Contract, type Signer } from 'ethers';

const ERC20_ABI = [
  'function balanceOf(address owner) view returns (uint256)',
  'function decimals() view returns (uint8)',
  'function symbol() view returns (string)',
  'function transfer(address to, uint256 amount) returns (bool)',
] as const;

type Status = 'idle' | 'connecting' | 'ready' | 'error';

export function useErc20Contract(tokenAddress: string) {
  const [status, setStatus] = useState<Status>('idle');
  const [error, setError] = useState<string | null>(null);
  const [address, setAddress] = useState<string | null>(null);
  const [signer, setSigner] = useState<Signer | null>(null);

  const connect = useCallback(async () => {
    if (!window.ethereum) {
      setError('No wallet detected');
      setStatus('error');
      return;
    }

    setStatus('connecting');
    setError(null);

    try {
      const provider = new BrowserProvider(window.ethereum);
      await provider.send('eth_requestAccounts', []);
      const s = await provider.getSigner();
      setSigner(s);
      setAddress(await s.getAddress());
      setStatus('ready');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Connection failed');
      setStatus('error');
    }
  }, []);

  // React to account or chain changes from the wallet.
  useEffect(() => {
    if (!window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        setSigner(null);
        setAddress(null);
        setStatus('idle');
      } else {
        connect();
      }
    };

    const handleChainChanged = () => window.location.reload();

    const eth = window.ethereum as unknown as {
      on: (event: string, handler: (...args: unknown[]) => void) => void;
      removeListener: (event: string, handler: (...args: unknown[]) => void) => void;
    };

    eth.on('accountsChanged', handleAccountsChanged as (...a: unknown[]) => void);
    eth.on('chainChanged', handleChainChanged);

    return () => {
      eth.removeListener('accountsChanged', handleAccountsChanged as (...a: unknown[]) => void);
      eth.removeListener('chainChanged', handleChainChanged);
    };
  }, [connect]);

  const contract = signer ? new Contract(tokenAddress, ERC20_ABI, signer) : null;

  return { status, error, address, signer, contract, connect };
}

This is the pattern I end up at in nearly every project. One hook, one source of truth for connection state, a contract instance ready to use. Components call connect() and read contract, address, and status. The account-changed and chain-changed listeners matter. Without them, a user who switches accounts in MetaMask sees stale data and your writes hit the wrong address.

Handling the Hard Parts

The above gives you a working integration. Here's what tends to bite next.

Chain switching. Users will be on the wrong chain. Don't just show an error. Offer to switch them. Call window.ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: '0xaa36a7' }] }) (Sepolia). If they don't have the chain set up, that call fails with code 4902, and you fall back to wallet_addEthereumChain.

Transaction speed UX. A confirmed transaction can take two seconds (Base) or two minutes (Ethereum mainnet under load). Show the transaction hash right after tx resolves, link it to the block explorer, and let them close the modal. Making them stare at a spinner is bad UX.

Failed transactions. A transaction can be mined but reverted. A transfer that fails because the sender's balance is too low, for example. Always check receipt.status and show a useful message. Reading the revert reason in v6 means re-simulating the call against the block it failed in, which is more work than it sounds. For most apps a plain "transaction failed, no funds were moved" is fine.

Gas estimation. Ethers auto-estimates gas, but estimation runs a staticCall first, and that call can fail and throw confusing errors. If your transaction reverts during estimation, the user never sees a popup. You just get an error. Catch it and explain: "We can't simulate this transaction. You're probably out of balance or the contract is paused."

When to Reach for wagmi Instead

Everything above is raw ethers.js. That's what you want for a focused integration, for learning what happens under the hood, or for work outside React.

For a full dApp with many contracts, multi-chain support, caching, and a dozen developers, wagmi on top of viem is a better default. You get React Query integration, automatic caching, prebuilt hooks for the common patterns, and nicer TypeScript. The cost is more dependencies and one more abstraction layer to learn.

I use both. Raw ethers when I need tight control or the project is small. wagmi when the app has real scale and a team is shipping against it.

So which one fits the app you're building right now, the small focused one or the big team one? If you're not sure, or you're stuck on something that should work and doesn't, book a free 30-minute call. I'll tell you straight whether you need help or you're one console.log away from fixing it yourself.