
Skill
setup-evm-react-app
scaffold React apps with MetaMask integration
Description
Scaffold a React app with MetaMask EVM integration using createEVMClient, useState/useEffect/useRef patterns, provider.request calls, chain switching, and error handling
SKILL.md
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
createEVMClientwith 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:
// src/metamask.ts
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:
// src/WalletConnect.tsx
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() {
const client = await getClient();
if (!mounted) return;
clientRef.current = client;
const provider = client.getProvider();
provider.on('accountsChanged', (accs: Address[]) => {
if (mounted) setAccounts(accs);
});
provider.on('chainChanged', (id: Hex) => {
if (mounted) setChainId(id);
});
provider.on('disconnect', () => {
if (mounted) {
setAccounts([]);
setChainId(null);
setBalance('');
}
});
}
init();
return () => { mounted = false; };
}, []);
const handleConnect = useCallback(async () => {
const client = clientRef.current;
if (!client) return;
setConnecting(true);
setError(null);
try {
const result = await client.connect({ chainIds: ['0x1'] });
setAccounts(result.accounts as Address[]);
setChainId(result.chainId as Hex);
} catch (err: any) {
if (err.code === 4001) {
setError('Connection rejected. Please try again.');
return;
}
if (err.code === -32002) {
setError('A connection request is already pending. Check MetaMask.');
return;
}
setError(err.message ?? 'Connection failed');
} finally {
setConnecting(false);
}
}, []);
const handleDisconnect = useCallback(async () => {
const client = clientRef.current;
if (!client) return;
await client.disconnect();
setAccounts([]);
setChainId(null);
setBalance('');
}, []);
const fetchBalance = useCallback(async () => {
const client = clientRef.current;
if (!client || accounts.length === 0) return;
const provider = client.getProvider();
const wei = await provider.request({
method: 'eth_getBalance',
params: [accounts[0], 'latest'],
}) as Hex;
const ethBalance = parseInt(wei, 16) / 1e18;
setBalance(ethBalance.toFixed(6));
}, [accounts]);
// chainConfiguration must match the target chain (and include its chainId) —
// the wallet receives it verbatim as wallet_addEthereumChain params
const handleSwitchToPolygon = useCallback(async () => {
const client = clientRef.current;
if (!client) return;
try {
await client.switchChain({
chainId: '0x89',
chainConfiguration: {
chainId: '0x89',
chainName: 'Polygon',
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 },
rpcUrls: ['https://polygon-rpc.com'],
blockExplorerUrls: ['https://polygonscan.com'],
},
});
} catch (err: any) {
if (err.code === 4001) {
setError('Chain switch rejected by user.');
}
}
}, []);
const isConnected = accounts.length > 0;
if (!isConnected) {
return (
<div>
<button onClick={handleConnect} disabled={connecting}>
{connecting ? 'Connecting...' : 'Connect MetaMask'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
return (
<div>
<p>Account: {accounts[0]}</p>
<p>Chain ID: {chainId}</p>
<p>Balance: {balance || '—'} ETH</p>
<button onClick={fetchBalance}>Refresh Balance</button>
<button onClick={handleSwitchToPolygon}>Switch to Polygon</button>
<button onClick={handleDisconnect}>Disconnect</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
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();
// Get current block number
const blockNumber = await provider.request({ method: 'eth_blockNumber' });
// Get chain ID
const chainId = await provider.request({ method: 'eth_chainId' });
// Get accounts
const accounts = await provider.request({ method: 'eth_accounts' });
// Get balance
const balance = await provider.request({
method: 'eth_getBalance',
params: [accounts[0], 'latest'],
});
// Get transaction count (nonce)
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', // Sepolia
});
// With fallback configuration for unknown chains
await client.switchChain({
chainId: '0xa4b1', // Arbitrum One
chainConfiguration: {
chainId: '0xa4b1', // optional in the type, but set it to the target chain — if omitted it falls back to the currently selected chain (likely the wrong chain to add)
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:
// User rejected the request — show retry UI
break;
case -32002:
// Request already pending — tell user to check MetaMask
break;
default:
console.error('Unexpected error:', err);
}
}
Important Notes
- Call
createEVMClientonce 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 inconnect()regardless of thechainIdsyou pass.- The provider exists before connection —
client.getProvider()never returnsundefined. But node-routed reads (eth_blockNumber,eth_getBalance,eth_call, …) require a selected chain and throwNo chain ID selecteduntil one is set (afterconnect()or a restored session). Only the intercepted methodseth_chainIdandeth_accounts(served from cached state) are safe before connecting. - Register event listeners early — set up
accountsChanged,chainChanged, anddisconnectlisteners inuseEffectbefore 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.
More skills from the metamask-connect-cursor-plugin repository
View all 18 skillsmigrate-from-sdk
migrate MetaMask SDK to connect-evm and connect-solana
Jul 13EthereumMigrationSDKWeb3migrate-wagmi-metamask-connector
migrate wagmi apps to MetaMask connector
Jul 13EthereumMigrationwagmiWeb3send-evm-transaction
send EVM transactions with MetaMask
Jul 13API DevelopmentEthereumWeb3send-solana-transaction
send Solana transactions via MetaMask
Jul 13PaymentsSolanaWeb3setup-evm-browser-app
scaffold EVM browser applications with MetaMask
Jul 13EthereumFrontendJavaScriptTypeScript +1setup-evm-react-native-app
scaffold React Native apps with MetaMask
Jul 13EthereumMobileReact NativeWeb3
More from MetaMask
View publishersmart-accounts-kit
build dApps with MetaMask Smart Accounts
smart-accounts-kit
Jul 13API DevelopmentAuthEthereumPermissions +1x402-payments
build x402 payment flows
smart-accounts-kit
Jul 13API DevelopmentPaymentsWeb3x402discovery
discover and use services
ocap-kernel
Jul 13API DevelopmentSearchmetamask
interact with MetaMask wallet capabilities
ocap-kernel
Jul 13API DevelopmentEthereumWeb3wallet
manage wallet balances and transactions
ocap-kernel
Jul 13EthereumPaymentsWeb3metamask-connect
integrate MetaMask into dApps
connect-monorepo
Jul 13API DevelopmentEthereumSolanawagmi +1