| name | setup-evm-react-app |
| description | Scaffold a React app with MetaMask EVM integration using createEVMClient, useState/useEffect/useRef patterns, provider.request calls, chain switching, and error handling |
Setup EVM React App with MetaMask Connect
When to use
Use this skill when:
- Creating a new React app that connects to MetaMask via
@metamask/connect-evm
- Adding wallet connect, sign, or send functionality to an existing React app
- Setting up
createEVMClient with Infura RPC URLs and event handlers
- Building a React component that tracks accounts, chain, and balance state
Workflow
Step 1: Install dependencies
npm install @metamask/connect-evm @metamask/connect-multichain
@metamask/connect-multichain is a regular dependency of @metamask/connect-evm and is installed transitively. (Only the 2.0.0 release briefly made it a peer dependency; 2.1.0 reverted that.) Installing it explicitly is harmless but not required. The SDK warns at runtime if duplicate or mismatched copies are resolved.
Step 2: Create the EVM client
Create a module that initializes the client once and exports a ready promise:
import { createEVMClient, getInfuraRpcUrls } from '@metamask/connect-evm';
import type { MetamaskConnectEVM } from '@metamask/connect-evm';
let clientPromise: Promise<MetamaskConnectEVM> | null = null;
export function getClient(): Promise<MetamaskConnectEVM> {
if (!clientPromise) {
clientPromise = createEVMClient({
dapp: {
name: 'My React DApp',
url: window.location.href,
},
api: {
supportedNetworks: {
...getInfuraRpcUrls({ infuraApiKey: 'YOUR_INFURA_KEY', chainIds: ['0x1', '0x89', '0xaa36a7'] }),
'0xa4b1': 'https://arb1.arbitrum.io/rpc',
},
},
ui: {
headless: false,
preferExtension: true,
showInstallModal: true,
},
eventHandlers: {
displayUri: (uri: string) => {
console.log('QR URI:', uri);
},
},
debug: false,
});
}
return clientPromise;
}
getInfuraRpcUrls({ infuraApiKey, chainIds? }) returns a Record<Hex, string> mapping hex chain IDs to Infura RPC URLs for all Infura-supported EVM chains. Pass an optional chainIds array (hex strings, e.g. ['0x1', '0x89']) to limit the output to specific chains. Spread it into supportedNetworks and add custom RPCs for any additional chains.
Step 3: Build the wallet component
Use useRef to hold the client instance and useState for reactive UI state:
import { useEffect, useRef, useState, useCallback } from 'react';
import { getClient } from './metamask';
import type { MetamaskConnectEVM } from '@metamask/connect-evm';
import type { Hex, Address } from '@metamask/connect-evm';
export function WalletConnect() {
const clientRef = useRef<MetamaskConnectEVM | null>(null);
const [accounts, setAccounts] = useState<Address[]>([]);
const [chainId, setChainId] = useState<Hex | null>(null);
const [balance, setBalance] = useState<string>('');
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
async function init() {
client = ();
(!mounted) ;
clientRef. = client;
provider = client.();
provider.(, {
(mounted) (accs);
});
provider.(, {
(mounted) (id);
});
provider.(, {
(mounted) {
([]);
();
();
}
});
}
();
{ mounted = ; };
}, []);
handleConnect = ( () => {
client = clientRef.;
(!client) ;
();
();
{
result = client.({ : [] });
(result. []);
(result. );
} (: ) {
(err. === ) {
();
;
}
(err. === -) {
();
;
}
(err. ?? );
} {
();
}
}, []);
handleDisconnect = ( () => {
client = clientRef.;
(!client) ;
client.();
([]);
();
();
}, []);
fetchBalance = ( () => {
client = clientRef.;
(!client || accounts. === ) ;
provider = client.();
wei = provider.({
: ,
: [accounts[], ],
}) ;
ethBalance = (wei, ) / ;
(ethBalance.());
}, [accounts]);
handleSwitchToPolygon = ( () => {
client = clientRef.;
(!client) ;
{
client.({
: ,
: {
: ,
: ,
: { : , : , : },
: [],
: [],
},
});
} (: ) {
(err. === ) {
();
}
}
}, []);
isConnected = accounts. > ;
(!isConnected) {
(
);
}
(
);
}
Step 4: Use provider.request for RPC calls
Once connected, use the EIP-1193 provider for any Ethereum JSON-RPC method:
const provider = client.getProvider();
const blockNumber = await provider.request({ method: 'eth_blockNumber' });
const chainId = await provider.request({ method: 'eth_chainId' });
const accounts = await provider.request({ method: 'eth_accounts' });
const balance = await provider.request({
method: 'eth_getBalance',
params: [accounts[0], 'latest'],
});
const nonce = await provider.request({
method: 'eth_getTransactionCount',
params: [accounts[0], 'latest'],
});
Step 5: Switch chains
Use client.switchChain to request a network change. The chainConfiguration fallback triggers wallet_addEthereumChain if the chain is not already in the user's wallet:
await client.switchChain({
chainId: '0xaa36a7',
});
await client.switchChain({
chainId: '0xa4b1',
chainConfiguration: {
chainId: '0xa4b1',
chainName: 'Arbitrum One',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://arb1.arbitrum.io/rpc'],
blockExplorerUrls: ['https://arbiscan.io'],
},
});
Step 6: Handle errors
Always catch and handle known error codes:
try {
await client.connect({ chainIds: ['0x1'] });
} catch (err: any) {
switch (err.code) {
case 4001:
break;
case -32002:
break;
default:
console.error('Unexpected error:', err);
}
}
Important Notes
- Call
createEVMClient once at app startup — store the promise and reuse it; never call it per render. Each call returns a new EVM client wrapper, but they all share one underlying multichain core (the core is the singleton, and its options are merged across calls).
- Chain IDs are always hex strings — use
'0x1' (Ethereum), '0x89' (Polygon), '0xaa36a7' (Sepolia). Never use decimal numbers.
0x1 (Ethereum mainnet) is always auto-included in connect() regardless of the chainIds you pass.
- The provider exists before connection —
client.getProvider() never returns undefined. But node-routed reads (eth_blockNumber, eth_getBalance, eth_call, …) require a selected chain and throw No chain ID selected until one is set (after connect() or a restored session). Only the intercepted methods eth_chainId and eth_accounts (served from cached state) are safe before connecting.
- Register event listeners early — set up
accountsChanged, chainChanged, and disconnect listeners in useEffect before the user connects.
- Error code 4001 is not an application error — it means the user deliberately rejected. Handle it gracefully with a retry option.
- Error code -32002 means a request is pending — do not fire another
connect() call. Wait for the user to act in MetaMask.