Let’s talkLet’s talk

June 19th, 2026

Building Multichain Apps in React with Wagmi

Kamil Pyszkowski

Kamil Pyszkowski

15 mins

A practical guide to shipping production dApps across Ethereum, Base, Optimism, and Arbitrum — covering config, reads, writes, chain switching, and testing.

Building Multichain Apps in React with Wagmi

There are now more EVM-compatible chains with real users and real liquidity than any single team can reasonably target one at a time. Base, Optimism, Arbitrum, and Ethereum each have distinct user bases, distinct gas economics, and distinct DeFi ecosystems that have grown up around them. Polygon, zkSync, Scroll, and Blast are right behind. The days of "we're an Ethereum app" as a complete answer to "which chain?" are effectively over.

This creates a real problem for frontend developers. Users don't choose chains out of principle — they follow the liquidity, the fees, and whatever network their wallet happened to be on when they clicked the link. If your app only works on one chain, you're not making a technical choice so much as a user acquisition decision, and probably not a deliberate one.

The good news is that the tooling has caught up. wagmi v3 and viem make multichain a first-class concern rather than an afterthought bolted on with if/else chain ID checks scattered across your components. The patterns are not complicated once you see them — they just aren't well documented in one place.

This post fixes that. We'll build a token dashboard that reads from the same contract deployed across four chains simultaneously, handles network switching without losing the user, and sends transactions correctly regardless of which network the wallet happens to be on. Everything uses wagmi v3 and viem, which are the tools the React/EVM community has largely converged on.

Three things wagmi doesn't explain but you need to know

Before touching any code, there are three concepts worth internalizing. They're not in the getting started guide, but they explain a lot of otherwise mysterious behavior once you're mid-feature.

Every hook is TanStack Query in disguise. useReadContract isn't making a fresh RPC call every render. It's a query with a cache key, a stale time, and a background refetch policy — exactly like any TanStack Query useQuery call. Once that clicks, a lot of things stop being confusing: why two different components reading the same contract data don't fire two separate RPC calls (the cache key matches, so the second one is a cache hit), why data from the last session sometimes flashes back on mount, and why you can call invalidateQueries after a write to trigger a fresh read. The TanStack Query options you'll actually reach for in this post are staleTime, gcTime, and enabled — keep them in mind as you read.

viem's chain objects are the source of truth. Don't hardcode chain IDs as magic numbers. Don't reference chains by name strings. Import mainnet, base, optimism, arbitrum from viem/chains and use those objects everywhere — in your config, in your address registry, and when constructing block explorer links. Each chain object already contains the RPC endpoints, the native currency, the block explorer URL, and the chain ID. If you treat them as the canonical reference throughout, a huge class of "wrong network" bugs becomes impossible.

In v3, you own your connector dependencies. Until v2, wagmi bundled the MetaMask SDK, WalletConnect, and Coinbase Wallet packages for you. In v3 they're all optional peer dependencies. You install only what you need, which is a real benefit — smaller bundles, explicit auditing of what ships to users, and you control the version bump cadence for each SDK. But it also means your first npm install wagmi@3 leaves you with no connectors wired up, which feels broken until you know why. We'll cover the install step properly in the next section.

Setting up the multichain config

Installing

pnpm add wagmi@3 viem@2

Then install whichever connector packages your app needs. You don't have to install all of them:

# WalletConnect (most common for multichain — covers MetaMask, Rainbow, etc.)
pnpm add @walletconnect/ethereum-provider@^2

# MetaMask SDK (if you want MetaMask-specific features)
pnpm add @metamask/sdk

# Coinbase Wallet
pnpm add @coinbase/wallet-sdk

# Porto (smart wallet, EIP-5792 support)
pnpm add porto
Note

Because you're now responsible for these dependencies, take a minute to review their licenses and run them through Socket before you ship to production. This is the flip side of the control Wagmi gives you.

Creating the config

// lib/wagmi.ts
import { createConfig, http, fallback } from "wagmi";
import { mainnet, base, optimism, arbitrum } from "viem/chains";
import { injected, walletConnect, coinbaseWallet } from "wagmi/connectors";

const projectId = process.env.NEXT_PUBLIC_WC_PROJECT_ID!;

export const config = createConfig({
  chains: [base, optimism, arbitrum, mainnet],
  connectors: [
    injected(),
    walletConnect({ projectId }),
    coinbaseWallet({ appName: "Token Dashboard" }),
  ],
  transports: {
    [mainnet.id]: fallback([
      http(`https://eth-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`),
      http(), // public fallback, rate-limited
    ]),
    [base.id]: http(`https://base-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`),
    [optimism.id]: http(`https://opt-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`),
    [arbitrum.id]: http(`https://arb-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_KEY}`),
  },
});

A few things worth calling out here:

Chain order matters. The first entry in the chains array is the default — what WalletConnect will suggest and what wagmi falls back to when no chain is explicitly specified. We put base first because it's where most of our users are. If your users are predominantly on mainnet, put that first.

Use private RPC endpoints. The public fallback in the mainnet transport above is there for development only. Public RPC endpoints will start throwing 429s the moment you get any real traffic. Alchemy, Infura, and Drpc all have generous free tiers — pick one before you go to staging.

Don't commit your projectId. If a WalletConnect project ID ends up in a public repo, it'll get rate-limited or abused within hours. Keep it in environment variables and add .env.local to .gitignore on day one.

Wiring up the providers

// app/providers.tsx
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "@/lib/wagmi";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 4, // 4 minutes — roughly 20 Base blocks
      gcTime: 1000 * 60 * 10,
    },
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  );
}

The staleTime of four minutes is a reasonable starting point for a token dashboard — it means data won't be refetched more than once every four minutes unless you explicitly invalidate it. Adjust it down if your app displays rapidly changing state like live auction prices. The WagmiProvider must wrap the QueryClientProvider, not the other way around.

Chain-aware reads: querying the same contract on every chain

The address registry

The contract we're reading is an ERC-20 token deployed at different addresses on each chain. The cleanest way to manage this is a registry keyed by chain ID:

// lib/contracts.ts
import { mainnet, base, optimism, arbitrum } from "viem/chains";
import type { Address } from "viem";

export const TOKEN_ADDRESS: Record<number, Address> = {
  [mainnet.id]: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC Ethereum
  [base.id]: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",    // USDC Base
  [optimism.id]: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // USDC Optimism
  [arbitrum.id]: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC Arbitrum
};

export const ERC20_ABI = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "decimals",
    type: "function",
    stateMutability: "view",
    inputs: [],
    outputs: [{ name: "", type: "uint8" }],
  },
] as const;

Using viem/chains chain objects as the keys means you can't accidentally use a chain ID that isn't in your config — the TypeScript types will catch it.

Reading from all chains in parallel

useReadContract accepts a chainId parameter. Pass it explicitly and wagmi will use the transport you configured for that chain, regardless of what network the user's wallet is connected to. This is the key insight: reads are not tied to the connected wallet chain.

// components/MultiChainBalances.tsx
import { useReadContracts } from "wagmi";
import { formatUnits } from "viem";
import { mainnet, base, optimism, arbitrum } from "viem/chains";
import { TOKEN_ADDRESS, ERC20_ABI } from "@/lib/contracts";
import type { Address } from "viem";

const CHAINS = [base, optimism, arbitrum, mainnet];

interface Props {
  address: Address;
}

export function MultiChainBalances({ address }: Props) {
  const { data, isLoading } = useReadContracts({
    contracts: CHAINS.map((chain) => ({
      address: TOKEN_ADDRESS[chain.id],
      abi: ERC20_ABI,
      functionName: "balanceOf",
      args: [address],
      chainId: chain.id,
    })),
  });

  if (isLoading) return <p>Loading balances...</p>;

  return (
    <ul>
      {CHAINS.map((chain, i) => {
        const result = data?.[i];
        const balance = result?.status === "success"
          ? formatUnits(result.result, 6) // USDC has 6 decimals
          : "error";

        return (
          <li key={chain.id}>
            {chain.name}: {balance} USDC
          </li>
        );
      })}
    </ul>
  );
}

useReadContracts batches the calls through multicall under the hood, so this fires a single multicall request per chain rather than one RPC call per balanceOf. At four chains that's four multicall requests instead of four individual eth_calls — meaningfully cheaper on the RPC quota.

When to use watch

Both useReadContract and useReadContracts accept a watch option that subscribes to new blocks and refetches on each one. It's useful for live data, but each watched hook opens a WebSocket subscription per chain — four chains means four subscriptions. Use it sparingly, only for the data that genuinely needs to be live, and make sure your transport config includes a webSocket() transport for those chains.

// Only watch the chain the user is actively interacting with
const { data: liveBalance } = useReadContract({
  address: TOKEN_ADDRESS[activeChainId],
  abi: ERC20_ABI,
  functionName: "balanceOf",
  args: [address],
  chainId: activeChainId,
  watch: true, // opens a WebSocket subscription — use sparingly
});

Chain switching: guiding users without losing them

Chain switching is where most multichain apps lose users. The technical side is straightforward; the UX side requires care.

The basics

// components/ChainSwitcher.tsx
import { useSwitchChain, useChainId } from "wagmi";
import { base, optimism, arbitrum, mainnet } from "viem/chains";

const CHAINS = [base, optimism, arbitrum, mainnet];

export function ChainSwitcher() {
  const currentChainId = useChainId();
  const { switchChain, isPending, error } = useSwitchChain();

  return (
    <div>
      {CHAINS.map((chain) => (
        <button
          key={chain.id}
          onClick={() => switchChain({ chainId: chain.id })}
          disabled={chain.id === currentChainId || isPending}
          aria-current={chain.id === currentChainId ? "true" : undefined}
        >
          {isPending && chain.id !== currentChainId ? "Approve in wallet…" : chain.name}
        </button>
      ))}
      {error && <p role="alert">{error.message}</p>}
    </div>
  );
}

Note the useConnection hook (renamed from useAccount in v3) for reading the full connection state — address, chain, connector:

import { useConnection } from "wagmi";

export function WalletInfo() {
  const { address, chainId, connector } = useConnection();

  return (
    <p>
      {address} on chain {chainId} via {connector?.name}
    </p>
  );
}

The "wrong network" guard

Rather than checking the chain inside every feature component, wrap anything chain-specific in a guard that handles the redirect once:

// components/ChainGuard.tsx
import { useChainId, useSwitchChain } from "wagmi";
import type { Chain } from "viem";

interface Props {
  requiredChain: Chain;
  children: React.ReactNode;
}

export function ChainGuard({ requiredChain, children }: Props) {
  const currentChainId = useChainId();
  const { switchChain, isPending } = useSwitchChain();

  if (currentChainId === requiredChain.id) {
    return <>{children}</>;
  }

  return (
    <div>
      <p>This feature requires {requiredChain.name}.</p>
      <button onClick={() => switchChain({ chainId: requiredChain.id })} disabled={isPending}>
        {isPending ? "Approve in wallet…" : `Switch to ${requiredChain.name}`}
      </button>
    </div>
  );
}

Usage:

<ChainGuard requiredChain={base}>
  <MintButton />
</ChainGuard>

What to do when the wallet can't switch

Some wallets — Ledger hardware wallets in particular — don't support wallet_switchEthereumChain. The useSwitchChain hook will reject with a SwitchChainNotSupportedError. Catch it and fall back gracefully:

const { switchChain } = useSwitchChain();

async function handleSwitch(chainId: number) {
  try {
    await switchChain({ chainId });
  } catch (err) {
    if (err instanceof SwitchChainNotSupportedError) {
      // Tell the user to switch manually in their wallet app
      setMessage("Please switch networks manually in your wallet.");
    }
  }
}

Never silently redirect. Always show the user what chain they're on and why you're asking them to switch. The drop-off from an unexpected redirect is worse than the drop-off from a clear "you need to be on Base for this" prompt.

Writing cross-chain: transactions, gas, and write patterns

The write lifecycle as a state machine

Before showing any code, it helps to model the transaction lifecycle explicitly. We use five states:

StateWhat's happening
idleNo transaction in progress
simulatingRunning useSimulateContract preflight
signingWaiting for wallet signature
pendingTransaction submitted, waiting for receipt
confirmed / failedTerminal states

This maps directly to wagmi's hook states, and having names for each state makes the UI copy obvious.

Simulate before you write

useSimulateContract does a dry-run of the transaction against the current chain state before asking the user to sign. It catches reverts, permission errors, and insufficient balance issues without costing any gas:

// hooks/useMint.ts
import { useSimulateContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { base } from "viem/chains";
import { TOKEN_ADDRESS, MINT_ABI } from "@/lib/contracts";

export function useMint(amount: bigint) {
  const { data: simulateData, error: simulateError } = useSimulateContract({
    address: TOKEN_ADDRESS[base.id],
    abi: MINT_ABI,
    functionName: "mint",
    args: [amount],
    chainId: base.id,
    query: {
      enabled: amount > 0n, // don't simulate until there's a real amount
    },
  });

  const {
    writeContract,
    data: txHash,
    isPending: isSigning,
  } = useWriteContract();

  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
    chainId: base.id,
  });

  function mint() {
    if (!simulateData?.request) return;
    writeContract(simulateData.request);
  }

  return {
    mint,
    isSigning,
    isConfirming,
    isSuccess,
    isDisabled: !simulateData?.request || isSigning || isConfirming,
    simulateError,
  };
}

The write button

// components/MintButton.tsx
import { useChainId, useSwitchChain } from "wagmi";
import { base } from "viem/chains";
import { useMint } from "@/hooks/useMint";

export function MintButton({ amount }: { amount: bigint }) {
  const currentChainId = useChainId();
  const { switchChain, isPending: isSwitching } = useSwitchChain();
  const { mint, isSigning, isConfirming, isSuccess, isDisabled, simulateError } = useMint(amount);

  const onWrongChain = currentChainId !== base.id;

  function handleClick() {
    if (onWrongChain) {
      switchChain({ chainId: base.id });
      return;
    }
    mint();
  }

  function label() {
    if (isSwitching) return "Approve network switch…";
    if (onWrongChain) return "Switch to Base";
    if (isSigning) return "Approve in wallet…";
    if (isConfirming) return "Confirming…";
    if (isSuccess) return "Minted!";
    return "Mint";
  }

  return (
    <div>
      <button onClick={handleClick} disabled={(!onWrongChain && isDisabled) || isSwitching}>
        {label()}
      </button>
      {simulateError && (
        <p role="alert">Transaction would fail: {simulateError.message}</p>
      )}
    </div>
  );
}

Gas on L2 is different

Ethereum L2s that use the OP Stack (Base, Optimism) charge two separate fees: the L2 execution fee and an L1 data fee for posting the transaction calldata to mainnet. The L1 data fee can sometimes be larger than the execution fee, especially for transactions with large calldata. wagmi's useEstimateGas will return the total combined estimate, but if you're displaying a fee breakdown, you'll need to use viem's getL1Fee action from viem/op-stack directly.

For most apps, displaying the total estimate from useEstimateGas is sufficient. Just don't hardcode gas limits — always estimate.

Testing multichain logic without going insane

Unit testing with the mock connector

wagmi ships a mock connector purpose-built for testing. It accepts a list of accounts and responds to connect, disconnect, and sign requests without needing a real wallet:

// test/setup.ts
import { createConfig, http } from "wagmi";
import { base, optimism } from "viem/chains";
import { mock } from "wagmi/connectors";

export function createTestConfig(initialChainId = base.id) {
  return createConfig({
    chains: [base, optimism],
    connectors: [
      mock({
        accounts: ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"],
        features: { switchChain: true },
      }),
    ],
    transports: {
      [base.id]: http(),
      [optimism.id]: http(),
    },
  });
}

A typical test for a multi-chain read:

// test/MultiChainBalances.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http } from "viem";
import { createTestConfig } from "./setup";
import { MultiChainBalances } from "@/components/MultiChainBalances";

test("renders balances for each chain", async () => {
  const config = createTestConfig();
  const queryClient = new QueryClient();

  render(
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <MultiChainBalances address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" />
      </QueryClientProvider>
    </WagmiProvider>
  );

  await waitFor(() => {
    expect(screen.getByText(/Base/)).toBeInTheDocument();
    expect(screen.getByText(/Optimism/)).toBeInTheDocument();
  });
});

Forking with Anvil

For tests that need real on-chain state — testing against actual USDC balances, live contract logic — Anvil is the right tool. It lets you fork mainnet or any L2 at a specific block number and run against real state locally:

# Fork Base at the latest block
anvil --fork-url https://base-mainnet.g.alchemy.com/v2/$ALCHEMY_KEY

Point your test transport at http://127.0.0.1:8545 and you can impersonate any address with anvil_impersonateAccount to set up any balance scenario you need. It's slower to set up than mocked tests but much more trustworthy for anything involving contract interactions.

What to skip

We spent two sprints building a full integration test suite that spun up Anvil, deployed contracts, and drove the UI through Playwright for every feature. It caught approximately one real bug that unit tests wouldn't have caught, and it added six minutes to every CI run. We killed most of it.

The pragmatic split: unit tests with the mock connector for component behavior and hook logic; manual verification on a testnet (Base Sepolia, Optimism Sepolia) for the write flows before every release. Playwright E2E is worth adding only if you have a dedicated QA environment and someone whose job it is to keep the selectors working.

Lessons, trade-offs, and what we'd do differently

After shipping and iterating on two multichain dApps with this stack, here's what's held up and what we'd change.

wagmi v3 + viem is the right foundation. The TypeScript types are genuinely tight — the generic inference on useReadContract is good enough that you rarely need to cast anything. The TanStack Query integration means caching and background refetching just work. And the surface area is small: once you understand the handful of hooks in this post, you understand most of wagmi.

The hardest part is not the code. Multichain state is well-handled by the library. What's hard is the UX: deciding when to switch chains automatically vs. asking the user, what to show when a chain is slow to respond, how to communicate that the same token has different balances on different chains. These decisions require product thinking, not just engineering.

Start with two chains, not six. Adding a new chain to the config is genuinely cheap once the pattern is in place — it's mostly an entry in the address registry and a transport in createConfig. Trying to support six chains from day one means six times the surface area for bugs before you've validated that any of them matter.

Audit your connector dependencies early. v3 hands you the responsibility. Build the habit of running pnpm audit and reviewing connector changelogs before bumping versions, especially for WalletConnect which has had security disclosures in the past.

Use paid RPC endpoints from the start. Public endpoints are fine for a weekend hackathon. The moment you share a link with anyone outside your team, you'll start hitting rate limits. Drpc has a generous free tier and straightforward pricing — there's no good reason to start on public endpoints and migrate later.

Invest in chain-switch UX on day one. In both projects we treated the chain-switch UI as a follow-up ticket, and in both cases it cost more to retrofit than it would have to design it properly from the start. The ChainGuard pattern above takes about an hour to build correctly — do it before you ship anything.

Let’s talk

Bring us your problem, we’ll help design the system. No hype, just engineering.