Web3 Integration
This page covers patterns for integrating blockchain functionality with Redux and RTK Query, including handling wallet connections, transactions, and on-chain events.
Core Principle: Keep Web3 Objects Outside Redux
Never store these in Redux:
window.ethereum- Provider instances (
ethers.Provider,Web3Provider) - Signer instances
- WebSocket connections
- Contract instances
AbortControllerinstances
These are non-serializable and violate Redux principles.
Recommended Architecture
Web3 Context (Outside Redux)
// src/contexts/Web3Context.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { ethers } from 'ethers';
interface Web3ContextValue {
provider: ethers.providers.Web3Provider | null;
signer: ethers.Signer | null;
account: string | null;
chainId: number | null;
connect: () => Promise<void>;
disconnect: () => void;
}
const Web3Context = createContext<Web3ContextValue | undefined>(undefined);
export function Web3Provider({ children }: { children: ReactNode }) {
const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [account, setAccount] = useState<string | null>(null);
const [chainId, setChainId] = useState<number | null>(null);
const connect = async () => {
if (!window.ethereum) {
throw new Error('MetaMask not installed');
}
const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
const accounts = await web3Provider.send('eth_requestAccounts', []);
const network = await web3Provider.getNetwork();
const signer = web3Provider.getSigner();
setProvider(web3Provider);
setSigner(signer);
setAccount(accounts[0]);
setChainId(network.chainId);
};
const disconnect = () => {
setProvider(null);
setSigner(null);
setAccount(null);
setChainId(null);
};
// Listen to account changes
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
disconnect();
} else {
setAccount(accounts[0]);
}
};
const handleChainChanged = (chainIdHex: string) => {
const newChainId = parseInt(chainIdHex, 16);
setChainId(newChainId);
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
return () => {
window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum?.removeListener('chainChanged', handleChainChanged);
};
}, []);
return (
<Web3Context.Provider
value={{ provider, signer, account, chainId, connect, disconnect }}
>
{children}
</Web3Context.Provider>
);
}
export function useWeb3() {
const context = useContext(Web3Context);
if (!context) {
throw new Error('useWeb3 must be used within Web3Provider');
}
return context;
}
Redux Slice for Web3 State
Store only serializable Web3 state in Redux:
// src/features/web3/web3.slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '@/app/store';
interface Web3State {
account: string | null;
chainId: number | null;
isConnected: boolean;
pendingTxs: Record<string, PendingTransaction>;
}
interface PendingTransaction {
hash: string;
type: 'transfer' | 'mint' | 'approve';
status: 'pending' | 'confirmed' | 'failed';
timestamp: number;
}
const initialState: Web3State = {
account: null,
chainId: null,
isConnected: false,
pendingTxs: {},
};
const web3Slice = createSlice({
name: 'web3',
initialState,
reducers: {
connected(state, action: PayloadAction<{ account: string; chainId: number }>) {
state.account = action.payload.account;
state.chainId = action.payload.chainId;
state.isConnected = true;
},
disconnected(state) {
state.account = null;
state.chainId = null;
state.isConnected = false;
state.pendingTxs = {};
},
chainChanged(state, action: PayloadAction<number>) {
state.chainId = action.payload;
},
accountChanged(state, action: PayloadAction<string>) {
state.account = action.payload;
},
txPending(state, action: PayloadAction<PendingTransaction>) {
state.pendingTxs[action.payload.hash] = action.payload;
},
txConfirmed(state, action: PayloadAction<string>) {
if (state.pendingTxs[action.payload]) {
state.pendingTxs[action.payload].status = 'confirmed';
}
},
txFailed(state, action: PayloadAction<string>) {
if (state.pendingTxs[action.payload]) {
state.pendingTxs[action.payload].status = 'failed';
}
},
txRemoved(state, action: PayloadAction<string>) {
delete state.pendingTxs[action.payload];
},
},
});
export const {
connected,
disconnected,
chainChanged,
accountChanged,
txPending,
txConfirmed,
txFailed,
txRemoved,
} = web3Slice.actions;
export default web3Slice.reducer;
// Selectors
export const selectAccount = (state: RootState) => state.web3.account;
export const selectChainId = (state: RootState) => state.web3.chainId;
export const selectIsConnected = (state: RootState) => state.web3.isConnected;
export const selectPendingTxs = (state: RootState) =>
Object.values(state.web3.pendingTxs);
Syncing Context with Redux
Connect the Web3 context to Redux:
// src/components/Web3Sync.tsx
import { useEffect } from 'react';
import { useWeb3 } from '@/contexts/Web3Context';
import { useAppDispatch } from '@/app/hooks';
import { connected, disconnected, chainChanged, accountChanged } from '@/features/web3/web3.slice';
import { client } from '@/services/client';
export function Web3Sync() {
const { account, chainId, connect } = useWeb3();
const dispatch = useAppDispatch();
// Sync connection state
useEffect(() => {
if (account && chainId) {
dispatch(connected({ account, chainId }));
} else {
dispatch(disconnected());
}
}, [account, chainId, dispatch]);
// Reset cache on account/chain change
useEffect(() => {
if (account || chainId) {
// Option 1: Reset entire client state
dispatch(client.util.resetApiState());
// Option 2: Invalidate specific tags
// dispatch(client.util.invalidateTags(['User', 'Parcels', 'Credits']));
}
}, [account, chainId, dispatch]);
// Auto-connect on mount if previously connected
useEffect(() => {
const autoConnect = async () => {
const wasConnected = localStorage.getItem('walletConnected');
if (wasConnected && window.ethereum) {
try {
await connect();
} catch (error) {
console.error('Auto-connect failed:', error);
}
}
};
autoConnect();
}, [connect]);
return null;
}
Transaction Lifecycle
Sending Transactions
// src/hooks/useTransferParcel.ts
import { useCallback } from 'react';
import { useWeb3 } from '@/contexts/Web3Context';
import { useAppDispatch } from '@/app/hooks';
import { txPending, txConfirmed, txFailed } from '@/features/web3/web3.slice';
import { client } from '@/services/client';
import { ParcelContract } from '@/contracts';
export function useTransferParcel() {
const { signer, account } = useWeb3();
const dispatch = useAppDispatch();
return useCallback(async (parcelId: string, to: string) => {
if (!signer || !account) {
throw new Error('Wallet not connected');
}
// Get contract instance
const contract = ParcelContract.connect(signer);
try {
// Send transaction
const tx = await contract.transfer(parcelId, to);
// Add to pending
dispatch(txPending({
hash: tx.hash,
type: 'transfer',
status: 'pending',
timestamp: Date.now(),
}));
// Optimistically update cache
dispatch(
client.util.updateQueryData('getParcel', { id: parcelId }, (draft) => {
draft.owner = to;
})
);
// Wait for confirmation
const receipt = await tx.wait();
if (receipt.status === 1) {
// Transaction confirmed
dispatch(txConfirmed(tx.hash));
// Invalidate affected queries
dispatch(client.util.invalidateTags([
{ type: 'Parcels', id: parcelId },
'Parcels',
]));
} else {
// Transaction failed
dispatch(txFailed(tx.hash));
// Rollback optimistic update
dispatch(client.util.invalidateTags([
{ type: 'Parcels', id: parcelId },
]));
}
return receipt;
} catch (error) {
// Transaction rejected or failed
if (error.hash) {
dispatch(txFailed(error.hash));
}
// Rollback optimistic update
dispatch(client.util.invalidateTags([
{ type: 'Parcels', id: parcelId },
]));
throw error;
}
}, [signer, account, dispatch]);
}
Using in Components
function TransferParcelButton({ parcelId }: { parcelId: string }) {
const transferParcel = useTransferParcel();
const [recipient, setRecipient] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleTransfer = async () => {
setIsLoading(true);
try {
await transferParcel(parcelId, recipient);
toast.success('Transfer successful!');
setRecipient('');
} catch (error) {
if (error.code === 4001) {
toast.error('Transaction rejected');
} else {
toast.error('Transfer failed');
}
} finally {
setIsLoading(false);
}
};
return (
<div>
<input
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="Recipient address"
/>
<button onClick={handleTransfer} disabled={isLoading || !recipient}>
{isLoading ? 'Transferring...' : 'Transfer'}
</button>
</div>
);
}
Listening to On-Chain Events
// src/hooks/useParcelEvents.ts
import { useEffect } from 'react';
import { useWeb3 } from '@/contexts/Web3Context';
import { useAppDispatch } from '@/app/hooks';
import { client } from '@/services/client';
import { ParcelContract } from '@/contracts';
export function useParcelEvents() {
const { provider } = useWeb3();
const dispatch = useAppDispatch();
useEffect(() => {
if (!provider) return;
const contract = ParcelContract.connect(provider);
// Listen for Transfer events
const handleTransfer = (from: string, to: string, tokenId: string) => {
console.log(`Parcel ${tokenId} transferred from ${from} to ${to}`);
// Invalidate affected queries
dispatch(client.util.invalidateTags([
{ type: 'Parcels', id: tokenId },
'Parcels',
]));
};
// Subscribe to events
contract.on('Transfer', handleTransfer);
// Cleanup
return () => {
contract.off('Transfer', handleTransfer);
};
}, [provider, dispatch]);
}
RTK Query with Web3 Data
Create endpoints that use blockchain data:
// src/features/nft/nft.client.ts
import { client } from '@/services/client';
import { ethers } from 'ethers';
export const nftClient = client.injectEndpoints({
endpoints: (build) => ({
// Hybrid: fetch from API and verify on-chain
getNFTWithOwnership: build.query<NFT, { id: string; account?: string }>({
async queryFn({ id, account }, { getState }) {
try {
// Fetch metadata from API
const response = await fetch(`/api/nfts/${id}`);
const nft = await response.json();
// Verify ownership on-chain if account provided
if (account && window.ethereum) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = NFTContract.connect(provider);
const owner = await contract.ownerOf(id);
nft.isOwner = owner.toLowerCase() === account.toLowerCase();
}
return { data: nft };
} catch (error) {
return { error: error.message };
}
},
providesTags: (result, error, arg) => [
{ type: 'NFTs', id: arg.id },
],
}),
}),
});
Cache Invalidation Strategies
On Network Change
// Invalidate all data when network changes
useEffect(() => {
if (chainId) {
dispatch(client.util.resetApiState());
}
}, [chainId, dispatch]);
On Account Change
// Invalidate user-specific data
useEffect(() => {
if (account) {
dispatch(client.util.invalidateTags(['User', 'Credits', 'NFTs']));
}
}, [account, dispatch]);
After Transaction Confirmation
// Invalidate related data after transaction
if (receipt.status === 1) {
dispatch(client.util.invalidateTags([
{ type: 'Parcels', id: parcelId },
'Parcels',
'User', // May affect user's balance
]));
}
Best Practices
1. Separate Concerns
// ✅ Good: Web3 in context, state in Redux
const { signer } = useWeb3(); // From context
const account = useAppSelector(selectAccount); // From Redux
// ❌ Bad: Everything in Redux
const { signer, account } = useAppSelector(selectWeb3); // Don't do this
2. Handle Transaction States
// ✅ Good: Track all states
const tx = await contract.transfer(...);
dispatch(txPending(tx.hash));
const receipt = await tx.wait();
if (receipt.status === 1) {
dispatch(txConfirmed(tx.hash));
} else {
dispatch(txFailed(tx.hash));
}
// ❌ Bad: Fire and forget
await contract.transfer(...);
3. Optimistic Updates with Rollback
// ✅ Good: Optimistic with rollback
dispatch(client.util.updateQueryData(...));
try {
await tx.wait();
dispatch(client.util.invalidateTags(...));
} catch {
dispatch(client.util.invalidateTags(...)); // Rollback
}
// ❌ Bad: No rollback
dispatch(client.util.updateQueryData(...));
await tx.wait(); // What if this fails?
4. Event Listener Cleanup
// ✅ Good: Clean up listeners
useEffect(() => {
contract.on('Transfer', handler);
return () => contract.off('Transfer', handler);
}, [contract]);
// ❌ Bad: Memory leak
useEffect(() => {
contract.on('Transfer', handler);
}, [contract]);
Next Steps
- Review Testing & Performance for optimization
- See Component Patterns for usage examples
- Understand RTK Query for cache management