Reading and writing to contracts from React
The complete flow for reads (view calls), writes (signed transactions), pending states, receipts, and error handling.
There are exactly two things your frontend does with a smart contract:
- Read — ask the contract a question (free, instant, no MetaMask popup)
- Write — send a transaction that changes state (costs gas, requires MetaMask approval, takes time)
Every Web3 frontend confusion traces back to not understanding this boundary clearly.
1. The Story: The Phantom Vote
In ChainElect's early days, I had a bug that took me three hours to find.
A voter would click the vote button. MetaMask would pop up. They'd approve the transaction. The UI would reload. The vote count wouldn't change.
"Did the vote work? Did it fail? Why isn't the count updating?"
The bug was embarrassingly simple: my "reload" function called getCandidate() immediately after sendTransaction() returned. But sendTransaction() returns as soon as the transaction is submitted to the mempool — not when it's confirmed by a validator. The vote existed in pending state. The read came back before the block was mined. Old data.
I was reading state before the write had landed.
2. The Visual: Read vs Write Flow
[!TIP] VISUAL TRIGGER FOR FRONTEND: Animate both flows as simultaneous signal paths. The read path is a short, fast loop. The write path is a longer sequential pipeline with a clear "PENDING" state bubble that animates until the block confirmation arrives. This sets user expectations correctly.

3. Technical Explanation: The Read Pattern
Reading from a contract is free — it executes locally on the RPC node, never touches the blockchain consensus layer. In Web3.js (ChainElect's library):
Key properties of .call():
- Free — no gas consumed, no MetaMask popup
- Instant — executes on the RPC node, not the chain
- Reads current confirmed state — does NOT read pending transactions
4. Technical Explanation: The Write Pattern with Proper Pending State
The write pattern must explicitly handle three states: pending, confirmed, failed:
The three UI states to handle:
idle— vote button enabled, normal statepending— spinner shown, button disabled, TX hash displayed for Etherscan linkconfirmed/failed— success message or error message, data reloaded
Beginners often use useEffect with the contract as a dependency to "auto-refresh" data after writes. This almost always causes infinite render loops or reads stale data. The correct pattern is: trigger reads explicitly after confirmed writes. Call your read function inside the then() or await block that fires after confirmation, not on component re-render.
The Gas Estimation Trap:
Hardcoding gas: 300000 works during development but can fail in production if the contract's gas requirements change (e.g. after an upgrade, or as state grows). Always call contract.methods.vote(id).estimateGas({ from: account }) before sending and use that value (with a small buffer like * 1.2) to set gas dynamically. ChainElect's contractConfig.js stores the hardcoded gas values — this is a known technical debt.
In ChainElect's Voters.jsx, find the function that casts a vote. Trace the full execution path: what happens if the user rejects the MetaMask popup (error code 4001)? What happens if the transaction gets included in a block but reverts (e.g. because the voter already voted)? Does the current UI handle both cases distinctly?
Was this lesson helpful?
Let us know what you think of this specification. (submitting anonymously)
