Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our dApp development journey.
In the last lesson, you built an impressive staking dashboard, enabling users to stake tokens, see their staked amount, and claim rewards. We implemented basic isProcessing and error states to manage the UI during these actions. While functional, a truly professional dApp provides more granular and informative feedback to the user, guiding them through every step of a blockchain interaction.
Today, we'll elevate your dApp's user experience by focusing on precisely that. This lesson is dedicated to handling the complete lifecycle of a transaction—from the moment a user clicks a button to the final confirmation on the blockchain. You will learn to provide clear feedback for pending, successful, and failed transactions, which is crucial for building user trust and preventing confusion with operations that aren't instantaneous.
The Anatomy of a dApp Transaction
Before we write any code, let's visualize the process. When a user initiates a transaction (like staking tokens), several things happen between their browser and the blockchain.

A transaction doesn't happen instantly. It has a distinct lifecycle with three key states from the user's perspective:
- Pending: The user has approved the transaction in their wallet (e.g., MetaMask), and it has been broadcast to the network. It's now in a queue (the "mempool"), waiting for a miner or validator to include it in a new block.
- Success (Confirmed): The transaction has been included in a block. The state change (e.g., tokens staked) is now permanently recorded on the blockchain.
- Error (Failed or Rejected): The transaction did not succeed. This can happen in two main ways:
- Rejected: The user manually rejected the transaction in their wallet.
- Failed: The transaction was included in a block, but the smart contract code reverted the transaction (e.g., due to a
requirecondition failing). The user still pays gas fees for the computation used.
Our goal is to communicate each of these states clearly to the user.
1. From a Simple Spinner to Granular Feedback
In our previous lesson, we used a single boolean state, isProcessing. This is a common starting point. A simple loading spinner tells the user something is happening.
The video below, though using slightly older React patterns, clearly demonstrates this basic approach. A loading state is set to true before the transaction is sent and false once it's complete.
Etherem Smart Contracts with React JS - Ethereum/React Todo List #3
Let's watch a brief clip from Dapp University that illustrates the fundamental 'loading' state pattern.
Watch from 10:54 to 12:20. Notice how the application state is set to 'loading' before calling the smart contract method (createTask). After the transaction receipt is received, the loading state is set back to false and the app is reloaded. This is the core loop we are about to enhance.
This is good, but we can do much better. A single loading spinner for the entire duration can leave the user wondering: "Did I click it? Is it waiting for me? Is it stuck?"
To solve this, we'll move from a boolean isProcessing state to a more descriptive string-based status. This allows us to provide specific messages at each step.
2. Implementing Multi-State Transaction Feedback
Let's refactor the handleStake function from our Staking.tsx component. We'll replace the isProcessing and error states with a single status state that can hold messages for each stage of the transaction.
We will also introduce toast notifications, a standard UX pattern for displaying short, non-disruptive messages. We'll use the popular react-toastify library. First, install it and set it up in your App.tsx.
npm install react-toastify
Then, in your main App.tsx (or equivalent layout file):
// src/App.tsx
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
function App() {
return (
<div>
{/* Your other components and routing */}
<ToastContainer
position="bottom-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
/>
</div>
);
}
Now, let's apply this new pattern. The following resource provides an excellent try...catch structure for handling the transaction lifecycle, which we will adapt.
Interacting with Smart Contracts using ethers.js
The Mode Docs article 'Interacting with Smart Contracts using ethers.js' demonstrates a robust pattern for updating smart contract state with user feedback. We will use its submitBid function as a model for our handleStake function.
Read the section 'Update Our Smart Contract State'. Pay close attention to the submitBid function. Notice how it uses a try-catch block, sends the transaction, waits for confirmation with tx.wait(), and updates a status message at each step. This is the exact pattern we will implement.
Here is the refactored handleStake function in Staking.tsx using react-toastify:
// src/components/Staking.tsx
// ... other imports
import { toast } from 'react-toastify';
// Inside the Staking component, replace setIsProcessing and setError states
// const [isProcessing, setIsProcessing] = useState(false); -> remove
// const [error, setError] = useState<string | null>(null); -> remove
const [isStaking, setIsStaking] = useState(false); // We'll keep a simple boolean for disabling the button
const handleStake = async () => {
if (!provider || !account || !amountToStake || parseFloat(amountToStake) <= 0) {
toast.error("Please enter a valid amount to stake.");
return;
}
setIsStaking(true);
const stakeAmountWei = ethers.parseEther(amountToStake);
// Create a unique ID for the toast
const toastId = toast.loading("Waiting for your approval...");
try {
const signer = await provider.getSigner();
const stakeTokenContract = new ethers.Contract(stakeTokenAddress, TokenABI.abi, signer);
const stakingContract = new ethers.Contract(stakingContractAddress, StakingABI.abi, signer);
// 1. Approve
const approveTx = await stakeTokenContract.approve(stakingContractAddress, stakeAmountWei);
toast.update(toastId, { render: "Approving... waiting for confirmation." });
await approveTx.wait();
toast.update(toastId, { render: "Approval successful! Now staking..." });
// 2. Stake
const stakeTx = await stakingContract.stake(stakeAmountWei);
toast.update(toastId, { render: "Staking... waiting for confirmation." });
await stakeTx.wait();
// 3. Success
toast.update(toastId, {
render: "Staked successfully!",
type: "success",
isLoading: false,
autoClose: 5000
});
fetchData(); // Refresh data after success
setAmountToStake('');
} catch (err: any) {
// Handle errors
let errorMessage = "An error occurred.";
if (err.code === 4001) { // User rejected the transaction
errorMessage = "Transaction rejected by user.";
} else if (err.reason) { // Transaction failed on-chain
errorMessage = err.reason;
}
toast.update(toastId, {
render: errorMessage,
type: "error",
isLoading: false,
autoClose: 5000
});
console.error("Staking failed:", err);
} finally {
setIsStaking(false);
}
};
// Update your stake button to use the new state
// <button onClick={handleStake} disabled={isProcessing} ... >
// {isProcessing ? 'Processing...' : 'Stake'}
// </button>
// Becomes:
// <button onClick={handleStake} disabled={isStaking} ... >
// {isStaking ? 'Processing...' : 'Stake'}
// </button>
Test your understanding!
In the code above, why do we call await approveTx.wait() and await stakeTx.wait()? What would happen if we omitted the await keyword before approveTx.wait()?
Show answer
tx.wait() is an asynchronous function that resolves only when the transaction has been included in a block (i.e., confirmed). We use await to pause the execution of our handleStake function until this confirmation happens.
If we omitted await before approveTx.wait(), our JavaScript code would immediately proceed to the next line and try to execute the stake transaction. The stake transaction would almost certainly fail because the approve transaction wouldn't have been mined yet, meaning the staking contract would not have the permission to pull the tokens. Waiting for each transaction to be confirmed ensures our steps execute in the correct order on the blockchain.
This approach provides a much better user experience. The user sees a persistent notification that updates its message as the transaction progresses through its lifecycle.
3. Proactive UI Updates with Event Listeners
Our current implementation calls fetchData() after a successful transaction. This is called "polling" and it works, but there's a more elegant and efficient solution: listening for smart contract events.
When your stake function successfully executes, your Staking contract should emit an event (e.g., event Staked(address user, uint256 amount)). We can set up a listener in our frontend that "hears" this event and triggers a data refresh automatically. This is faster than polling and ensures the UI updates the moment the on-chain action is confirmed.
Interacting with Smart Contracts using ethers.js
The Mode Docs article also explains how to listen for smart contract events. This is a powerful pattern for building reactive dApps.
Please read the section 'Listening to events on our Smart Contract'. Focus on how the useEffect hook is used to set up the contract.on() listener and, crucially, how it returns a cleanup function to call contract.off().
Let's add an event listener to our Staking.tsx component. This will listen for stake, unstake, and claim events, and refresh our data whenever they occur.
// src/components/Staking.tsx
// Inside the Staking component, add a new useEffect hook for listeners
useEffect(() => {
if (!provider || !account) return;
const stakingContract = new ethers.Contract(stakingContractAddress, StakingABI.abi, provider);
// Define the listener function
const handleContractEvent = (user: string, amount: ethers.BigNumberish) => {
// We can add a filter to only refresh if the event is for the current user
if (user.toLowerCase() === account.toLowerCase()) {
console.log("Relevant contract event detected. Refreshing data...");
fetchData();
toast.info("Your balance has been updated!");
}
};
// Assuming your contract has these events:
// event Staked(address indexed user, uint256 amount);
// event Withdrawn(address indexed user, uint256 amount);
// event RewardPaid(address indexed user, uint256 reward);
stakingContract.on("Staked", handleContractEvent);
stakingContract.on("Withdrawn", handleContractEvent);
stakingContract.on("RewardPaid", handleContractEvent);
// IMPORTANT: Cleanup function to remove listeners
return () => {
stakingContract.off("Staked", handleContractEvent);
stakingContract.off("Withdrawn", handleContractEvent);
stakingContract.off("RewardPaid", handleContractEvent);
};
}, [provider, account]); // Re-run if provider or account changes
With this useEffect in place, you can now remove the manual fetchData() call from the try block of your handleStake, handleUnstake, and handleClaim functions. The UI will update automatically whenever a relevant event is emitted by the contract!
For the Future: Professional-Grade State Management
As your dApp grows, you might find yourself managing transaction states across many different components. For large-scale applications, developers often centralize this logic.
The Decentraland documentation provides a fascinating look at how they handle this using Redux and custom hooks.
Web3 Integration | Contributor
For a glimpse into how large, professional teams manage complex dApp state, explore this resource from Decentraland. You don't need to implement this now, but it's a valuable preview of advanced patterns.
Skim through the code examples in 'Redux Slice for Web3 State', 'Sending Transactions', and '2. Handle Transaction States'. Notice how they define a global state for pending transactions (pendingTxs) and use a custom hook (useTransferParcel) to dispatch actions like txPending, txConfirmed, and txFailed. This is a powerful pattern for separating concerns, which aligns well with your backend development experience.
Conclusion
Congratulations on completing this crucial lesson! You have significantly improved the user experience of your dApp by providing clear, real-time feedback throughout the transaction lifecycle.
Key Takeaways:
- The full lifecycle of a transaction includes pending, success, and error states.
- Communicating these states clearly with tools like toast notifications is essential for a professional user experience.
- The
await tx.wait()pattern is fundamental for ensuring transactions are confirmed before your app proceeds. - Using smart contract event listeners (
contract.on()) provides a more efficient and reactive way to update your UI compared to manual polling.
Preview of the Next Module:
You have now built a complete dApp around your ERC-20 token, including a user interface for sales and staking. The next major step is to connect your token to the broader world of Decentralized Finance (DeFi). In the first lesson of our next module, we will explore Decentralized Exchanges (DEXs) like Uniswap. You will learn what an Automated Market Maker (AMM) is and how to create a liquidity pool, enabling anyone in the world to trade your token.