In traditional Web2 development, the line between client state and server state is well-defined. You fetch data from a REST or GraphQL API, cache it locally using tools like TanStack Query or Redux Toolkit, and push mutations back to a centralized database.
In Web3, this paradigm breaks down completely. The blockchain is a global, asynchronous, distributed state machine with high latency, probabilistic finality, and variable gas costs. To make matters more complex, your app doesn't just talk to a server; it interacts with a local wallet extension (the client signer), individual RPC nodes, and decentralized indexers.
If you don't cleanly decouple your Client State from your Server (Chain) State, your frontend will suffer from race conditions, out-of-sync UIs, and terrible user experiences. Here is how to architect the split.
1. Client State: Local UI and Wallet Context
Client state is data that lives entirely within the browser memory or local storage. It is synchronous, ephemeral, and changes instantly based on user interaction.
In a Web3 application, client state includes:
- UI Toggles: Modals, themes, sidebar states, active tabs.
- Form Inputs: Unsubmitted transaction parameters, raw token amounts entered by the user.
- Wallet Connection Context: Is the user connected? What is their current address? What chain ID is their wallet currently set to?
The Pitfall: Treating Wallet State as Immutable
A common mistake is storing the user's wallet address in a global Redux or Zustand store and assuming it remains static. Wallets are external actors; a user can change accounts or switch networks directly within their MetaMask or Phantom extension without interacting with your dapp UI.
Architectural Rule: Always treat wallet connection state as an externally managed stream of data. Use reactive hooks (like those provided by wagmi or @solana/wallet-adapter-react) to listen to account changes directly rather than trying to sync them manually into your local state managers.
2. Server State: The Blockchain and Indexers
Server state in Web3 is the global state of the ledger. Unlike a Web2 database, you do not own it, it is not instantaneous, and reading data requires navigating distinct architectural layers.
Web3 server state is split into two categories:
A. Core On-Chain State (The RPC Layer)
This is raw data sitting inside smart contract storage slots (e.g., token balances, protocol parameters, AMM pool liquidity).
-
The Reality: You fetch this via JSON-RPC calls (
eth_call). -
The Problem: It is pull-based and discrete. You only get the state at the specific block number you queried. To keep it accurate, you must constantly poll or subscribe to new block events (
eth_blockNumber).
B. Indexer State (The Graph / Custom Indexers)
Raw blockchain storage is optimized for execution, not querying. If you need to show a user their historical transaction logs, portfolio performance over time, or filtered NFT metadata, querying an RPC node directly is functionally impossible.
- The Reality: You query indexed databases (GraphQL entities via The Graph, or proprietary APIs like Goldsky or Envio).
- The Problem: Indexers introduce propagation lag. A transaction might be confirmed on-chain in block $N$, but the indexer might not process that block until seconds later.
3. The Synchronization Gap: Handling Async Mutations
The true engineering challenge lies in the delta between sending a transaction (mutating server state) and the frontend reflecting that change.
When a user executes a smart contract write operation, the state transitions through four distinct phases:
[ 1. Client Signed ] ──► [ 2. Broadcasted (Mempool) ] ──► [ 3. Included in Block ] ──► [ 4. Indexed ]
If your frontend relies solely on reading on-chain state to update the UI, the user will experience a jarring delay after signing a transaction, leading them to believe the app is frozen or the action failed.
Architectural Strategies to Bridge the Gap
1. Decouple Transaction Tracking from State Fetching
Do not block your UI thread waiting for the transaction receipt to fetch new balances. Use explicit transaction notification systems (e.g., toast alerts tracking the transaction hash life cycle) while allowing your data-fetching hooks to handle cache invalidation independently.
2. Optimistic Updates
For low-stakes interactions or fast L2s/L3s, modify the client state immediately to assume success before the transaction is finalized on-chain. If the transaction reverts, roll back the client state to the previous server state snapshot.
3. Smart Cache Invalidation (TanStack Query + Wagmi)
Leverage declarative queries where the query key is tied directly to the current block number or the user’s address. When a transaction succeeds, programmatically invalidate the specific cache keys to force an immediate RPC refetch.
// Example pattern using wagmi & tanstack query
const { writeContractAsync } = useWriteContract();
const queryClient = useQueryClient();
const handleSwap = async () => {
const txHash = await writeContractAsync({ ...config });
// Wait for block inclusion
await waitForTransactionReceipt(config, { hash: txHash });
// Invalidate the exact server state cache key to trigger an explicit reload
queryClient.invalidateQueries({ queryKey: ['balance', userAddress] });
};
Conclusion
Building clean Web3 frontends requires recognizing that you do not own the backend database. Your client state must remain lean, highly reactive, and decoupled from the slow asynchronous reality of the blockchain. The server state must be treated as a delayed, eventually consistent cache that requires explicit invalidation strategies.
I will be expanding on these frontend architecture choices as I document the progress of my protocol-level builds. Up next, I'll be looking into performance optimizations when managing high-frequency RPC polling vs. WebSocket subscriptions. Stay tuned.
Top comments (0)