Building a Real-Time Blockchain Chat dApp: Smart Contract Event Listening with React and Ethers.js


Want to listen to smart contract events directly on your frontend? In this guide, I’ll walk through setting up event listeners using
ethers.js
helping you display real-time on-chain activity in your React app.We will cover everything from setting up the wallet connection and the provider to the contract instance, fetching previous contract events, and listening to emitted events from a deployed smart contract.
All while building a simple real-time chat app called BlockChat. Let’s dive in.
Some terms used
1. Ethers.js
A JavaScript library that allows interaction with the Ethereum blockchain and its ecosystem, including connecting to wallets, reading from, and writing to smart contracts.
2. ABI (Application Binary Interface)
A JSON representation of the smart contract’s interface that defines how to interact with it (e.g., functions and events).
3. Provider
An abstraction that allows you to connect to and read data from the blockchain, typically using JSON-RPC.
→ JsonRpcProvider is a type of provider used for read-only operations.
4. Signer
A component of ethers.js that allows you to sign transactions and messages, required for write operations on the blockchain.
5. BrowserProvider
Ethers.js class that wraps the browser’s injected provider (like MetaMask), allowing interaction with the user’s wallet.
6. JSON-RPC
A remote procedure call protocol encoded in JSON, used by Ethereum clients to communicate.
7. Event Subscription
A way to listen to events emitted by the smart contract continuously, allowing real-time updates in the UI.
🔧 Prerequisites: Tools You’ll Need
Ensure you have the following tools:
Node.js installed
A frontend project (React preferred)
Ethers.js installed:
npm install ethers
Optional for this tutorial, TailwindCSS and Shadcn are set up with your React project for aesthetics(most of the UI components used in here)
For this tutorial, I’ve created a simple contract BlockChat.sol
and deployed it here to the rootstock testnet
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BlockChat {
// Event declaration with channelId
event MessageSent(
address indexed sender,
string indexed channelId,
string content,
uint256 timestamp
);
// Function to send a message to a specific channel
function sendMessage(string calldata _channelId, string calldata _content) external {
emit MessageSent(msg.sender, _channelId, _content, block.timestamp);
}
}
🧱 Step 1: Add Your Contract ABI & Address
Create a new file utils/contract.ts
You’ll add your contract ABI and your contract address
export const CHAT_CONTRACT_ABI = [
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: "address",
name: "sender",
type: "address",
},
{
indexed: true,
internalType: "string",
name: "channelId",
type: "string",
},
{
indexed: false,
internalType: "string",
name: "content",
type: "string",
},
{
indexed: false,
internalType: "uint256",
name: "timestamp",
type: "uint256",
},
],
name: "MessageSent",
type: "event",
},
{
inputs: [
{ internalType: "string", name: "_channelId", type: "string" },
{ internalType: "string", name: "_content", type: "string" },
],
name: "sendMessage",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
];
export const CHAT_CONTRACT_ADDRESS = "0x002574D6d7f9A69e22c56EfC6d76DA6c36744395";
🌐 Step 2: Configure the RPC Provider
Create a new file utils/provider.ts
We’ll be creating our provider using the JsonRpcProvider using the Rootstock testnet RPC. You can follow this guide to set up yours. Setting up Rootstock RPC API
//utils/provider.ts
import { JsonRpcProvider } from "ethers";
export const provider = new JsonRpcProvider("https://rpc.testnet.rootstock.io/<YOUR-API-KEY>");
🔐 Step 3: Connect and Manage Wallets
Now that our provider is ready, let’s handle wallet connections. We’ll write a custom hook useWallet
to simplify Wallet integration, switch networks, and manage user accounts.
import { useState, useEffect, useCallback } from "react"
import { BrowserProvider, type JsonRpcSigner } from "ethers"
// Rootstock Testnet configuration
const ROOTSTOCK_CHAIN_ID = "0x1f" // 31 in hex
const ROOTSTOCK_PARAMS = {
chainId: ROOTSTOCK_CHAIN_ID,
chainName: "Rootstock Testnet",
nativeCurrency: {
name: "tRBTC",
symbol: "tRBTC",
decimals: 18,
},
rpcUrls: ["https://public-node.testnet.rsk.co"],
blockExplorerUrls: ["https://explorer.testnet.rootstock.io/"],
}
export function useWallet() {
const [address, setAddress] = useState<string>("")
const [signer, setSigner] = useState<JsonRpcSigner | null>(null)
const [provider, setProvider] = useState<BrowserProvider | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isInitializing, setIsInitializing] = useState<boolean>(true)
const connectWallet = useCallback(async () => {
const { ethereum } = window as any
if (!ethereum) {
alert("Please install MetaMask to use this dApp")
return
}
setIsLoading(true)
try {
// Switch to Rootstock if not already there
const currentChainId = await ethereum.request({ method: "eth_chainId" })
if (currentChainId !== ROOTSTOCK_CHAIN_ID) {
try {
await ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: ROOTSTOCK_CHAIN_ID }],
})
} catch (switchError: any) {
if (switchError.code === 4902) {
await ethereum.request({
method: "wallet_addEthereumChain",
params: [ROOTSTOCK_PARAMS],
})
} else {
throw switchError
}
}
}
// Create provider and get accounts
const browserProvider = new BrowserProvider(ethereum)
const accounts = await browserProvider.send("eth_requestAccounts", [])
if (accounts.length > 0) {
const newSigner = await browserProvider.getSigner()
// Update all states together
setProvider(browserProvider)
setAddress(accounts[0])
setSigner(newSigner)
console.log("🔌 Wallet connected:", accounts[0])
}
} catch (err) {
console.error("Failed to connect:", err)
// Reset states on error
setAddress("")
setSigner(null)
setProvider(null)
} finally {
setIsLoading(false)
}
}, [])
const disconnectWallet = useCallback(() => {
setAddress("")
setSigner(null)
setProvider(null)
console.log("🔌 Wallet disconnected", address);
}, [])
// Set up event listeners for wallet events
useEffect(() => {
const { ethereum } = window as any
if (!ethereum) {
setIsInitializing(false)
return
}
const handleAccountsChanged = async (accounts: string[]) => {
console.log("👤 Accounts changed:", accounts)
if (accounts.length === 0) {
// User disconnected
disconnectWallet()
} else if (accounts[0] !== address) {
// User switched accounts
setIsLoading(true)
try {
setAddress(accounts[0])
if (provider) {
const newSigner = await provider.getSigner()
setSigner(newSigner)
}
} catch (error) {
console.error("Error updating signer:", error)
disconnectWallet()
} finally {
setIsLoading(false)
}
}
}
const handleChainChanged = async (hexChainId: string) => {
console.log("🔗 Chain changed:", hexChainId)
// If not on Rootstock, disconnect
if (hexChainId !== ROOTSTOCK_CHAIN_ID) {
disconnectWallet()
alert("Please switch to Rootstock Testnet to continue using the app")
} else {
// Reconnect on correct chain
try {
setIsLoading(true)
const newProvider = new BrowserProvider(ethereum)
const accounts = await newProvider.send("eth_accounts", [])
if (accounts.length > 0) {
const newSigner = await newProvider.getSigner()
setProvider(newProvider)
setAddress(accounts[0])
setSigner(newSigner)
}
} catch (error) {
console.error("Error handling chain change:", error)
disconnectWallet()
} finally {
setIsLoading(false)
}
}
}
const handleDisconnect = () => {
console.log("🔌 Wallet disconnected by user")
disconnectWallet()
}
// Add event listeners
ethereum.on("accountsChanged", handleAccountsChanged)
ethereum.on("chainChanged", handleChainChanged)
ethereum.on("disconnect", handleDisconnect)
// Cleanup function
return () => {
if (ethereum.removeListener) {
ethereum.removeListener("accountsChanged", handleAccountsChanged)
ethereum.removeListener("chainChanged", handleChainChanged)
ethereum.removeListener("disconnect", handleDisconnect)
}
}
}, [address, provider, disconnectWallet])
// Try to reconnect on page load if wallet was previously connected
useEffect(() => {
const tryReconnect = async () => {
const { ethereum } = window as any
if (!ethereum) {
setIsInitializing(false)
return
}
try {
// Check if already connected
const accounts = await ethereum.request({ method: "eth_accounts" })
const currentChainId = await ethereum.request({ method: "eth_chainId" })
if (accounts.length > 0 && currentChainId === ROOTSTOCK_CHAIN_ID) {
// Auto-reconnect
const browserProvider = new BrowserProvider(ethereum)
const newSigner = await browserProvider.getSigner()
setProvider(browserProvider)
setAddress(accounts[0])
setSigner(newSigner)
console.log("🔄 Auto-reconnected:", accounts[0])
}
} catch (error) {
console.error("Auto-reconnect failed:", error)
} finally {
setIsInitializing(false)
}
}
tryReconnect()
}, [])
return {
address,
signer,
provider,
isConnected: !!address && !!signer,
isLoading,
isInitializing,
connectWallet,
disconnectWallet,
}
}
This hook typically has two major functions, connectWallet
and disconnectWallet
, and other parameters; the loading state, the signer, the provider, the initializing state, and the currently connected address. In this hook, the connectWallet
function checks to ensure the user is connected to the rootstock testnet network. This hook will automatically try to reestablish the connection upon page refresh. Now, in our main App component, let’s see how this useWallet
hook is used.
import { WalletConnect } from "@/components/wallet-connect"
import { ChatInterface } from "@/components/chat-interface"
import { useWallet } from "@/hooks/use-wallet"
import { useEffect } from "react"
export default function Home() {
const {
isConnected,
isLoading,
isInitializing,
address,
connectWallet,
disconnectWallet
} = useWallet();
if (isInitializing) {
return (
<main className="min-h-screen bg-zinc-900 text-white flex items-center justify-center">
<div className="text-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent mx-auto mb-4" />
<p className="text-zinc-400">Initializing wallet connection...</p>
</div>
</main>
)
}
return (
<main className="min-h-screen bg-zinc-900 text-white">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold mb-2">BlockChat</h1>
<p className="text-zinc-400">Decentralized messaging powered by smart contracts</p>
</div>
<div className="flex justify-center mb-8">
<WalletConnect
isConnected={isConnected}
isLoading={isLoading}
address={address}
connectWallet={connectWallet}
disconnectWallet={disconnectWallet}
/>
</div>
{isConnected && address ? (
<ChatInterface />
) : (
<div className="text-center p-12 border border-zinc-700 rounded-lg bg-zinc-800/50">
{isLoading ? (
<div>
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent mx-auto mb-4" />
<h2 className="text-2xl font-medium mb-4">Connecting wallet...</h2>
<p className="text-zinc-400">Please check your wallet for connection requests</p>
</div>
) : (
<div>
<h2 className="text-2xl font-medium mb-4">Connect your wallet to start chatting</h2>
<p className="text-zinc-400">All messages are stored on the blockchain and visible to everyone</p>
</div>
)}
</div>
)}
</div>
</div>
</main>
)
}
The above conditionally displays a loader while the useWallet hook is being initialized. In this case, this means that when the page is refreshed or on load of the page, the hook tries to reestablish the wallet connection. Also, this conditionally shows a connect wallet or disconnect wallet button displayed by the WalletConnect component, and then conditionally renders the ChatInterface if an address is connected.
The wallet-connect.tsx
component
import { Button } from "@/components/ui/button"
import { Wallet, LogOut } from "lucide-react"
interface WalletConnectProps {
isConnected: boolean;
isLoading: boolean;
address: string;
connectWallet: () => Promise<void>;
disconnectWallet: () => void;
}
export function WalletConnect ({
isConnected,
isLoading,
address,
connectWallet,
disconnectWallet
}: WalletConnectProps) {
return (
<div className="flex flex-col items-center gap-2">
<Button
onClick={isConnected ? disconnectWallet : connectWallet}
variant={isConnected ? "destructive" : "default"}
disabled={isLoading}
className="flex items-center gap-2 min-w-[160px]"
>
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{isConnected ? "Disconnecting..." : "Connecting..."}
</>
) : isConnected ? (
<>
<LogOut className="h-4 w-4" />
Disconnect Wallet
</>
) : (
<>
<Wallet className="h-4 w-4" />
Connect Wallet
</>
)}
</Button>
{isConnected && address && !isLoading && (
<p className="text-sm text-zinc-400">
Connected: {address.substring(0, 6)}...{address.substring(address.length - 4)}
</p>
)}
</div>
)
}
This WalletConnect component handles wallet connection and disconnection, displaying the appropriate button depending on connection status. The connectWallet
function ensures the user is connected to the Rootstock Testnet.
🧠 Step 4: Fetch and Listen to Contract Events, Send and View Message Updates in Real Time
To interact with or listen to events from a contract, ethers.js requires:
The ABI
The contract address
The provider (read-only) or the signer(read and write)
First, we start with the hook use-chat-messages.tsx
,
import { useState, useEffect } from "react";
import { Contract } from "ethers";
import type { Message } from "@/types/message";
import { useWallet } from "@/hooks/use-wallet";
import { CHAT_CONTRACT_ABI, CHAT_CONTRACT_ADDRESS } from "@/lib/contract";
import { provider } from "@/lib/provider";
import { hashToChannelId } from "@/lib/utils";
export function useChatMessages(channelId: string) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { signer, address, isConnected } = useWallet();
// Event handler for new messages
const onMessageSent = (
sender: string,
channel: string,
content: string,
timestamp: number
) => {
console.log("📥 New message event received:", {
sender,
channel,
content,
timestamp,
});
console.log(channel, channelId);
// Only process messages for the current channel
if (channel !== channelId) {
console.log(
`⏭️ Skipping message for different channel: ${channel} (current: ${channelId})`
);
return;
}
console.log(`✅ Adding new message to channel ${channelId}:`, {
sender,
content,
});
// Add new message to state
setMessages((prev) => [
...prev,
{
id: `${Date.now()}-${prev.length}`,
sender,
channelId: channel,
content,
timestamp: Number(timestamp),
},
]);
};
// Load messages and set up event listeners
useEffect(() => {
if (!isConnected || !channelId) return;
console.log(`📡 Setting up event listener for channel: ${channelId}`);
// Create a read-only contract instance for listening to events
const contract = new Contract(
CHAT_CONTRACT_ADDRESS,
CHAT_CONTRACT_ABI,
provider
);
let isSubscribed = true;
// Fetch past messages for the current channel
const fetchMessages = async () => {
try {
setIsLoading(true);
setMessages([]); // Clear messages before fetching
console.log(`🔍 Fetching past messages for channel: ${channelId}`);
const filter = contract.filters.MessageSent(null, channelId);
console.log("🔍 Using filter:", filter);
const events = await contract.queryFilter(filter, 6417246, "latest");
console.log(
`📜 Found ${events.length} past events for channel ${channelId}:`,
events
);
if (isSubscribed) {
const parsedMessages = events.map((event: any) => {
return {
id: `${event.transactionHash}-${event.logIndex}`,
sender: event.args[0],
channelId: event.args[1],
content: event.args[2],
timestamp: Number(event.args[3]),
};
});
// Sort by timestamp (oldest first)
setMessages(parsedMessages.sort((a, b) => a.timestamp - b.timestamp));
console.log(
`✅ Parsed ${parsedMessages.length} messages for channel ${channelId}`
);
}
} catch (error) {
console.error(
`❌ Error fetching messages for channel ${channelId}:`,
error
);
} finally {
setIsLoading(false);
}
};
// Set up event listener
contract.on("MessageSent", onMessageSent);
console.log(`✅ Event listener set up for channel ${channelId}`);
// Fetch initial messages
fetchMessages();
// Clean up function
return () => {
console.log(`🧹 Cleaning up event listener for channel ${channelId}`);
isSubscribed = false;
contract.off("MessageSent", onMessageSent);
};
}, [channelId, isConnected]);
const sendMessage = async (content: string) => {
if (!signer || !address) {
throw new Error("Wallet not connected");
}
try {
setIsLoading(true);
console.log(`📤 Sending message to channel ${channelId}:`, content);
// Create a contract instance with the signer for write operations
const contract = new Contract(
CHAT_CONTRACT_ADDRESS,
CHAT_CONTRACT_ABI,
signer
);
// Send the transaction
const tx = await contract.sendMessage(channelId, content);
console.log("⏳ Transaction sent:", tx.hash);
// Wait for transaction to be mined
const receipt = await tx.wait();
console.log("✅ Transaction confirmed:", receipt);
onMessageSent(
receipt.logs[0].args[0],
hashToChannelId[receipt.logs[0].args[1].hash],
receipt.logs[0].args[2],
receipt.logs[0].args[3]
);
return receipt;
} catch (error) {
console.error("❌ Error sending message:", error);
throw error;
} finally {
setIsLoading(false);
}
};
return { messages, sendMessage, isLoading };
}
In your types/message.ts
// types/message.ts
export interface Message {
id: string
sender: string
channelId: string
content: string
timestamp: number
}
And in your lib/utils.ts
// lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Format an Ethereum address to a shorter version
export function formatAddress(address: string): string {
if (!address) return ""
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`
}
// Format a Unix timestamp to a readable time
export function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
// A record to get the string equivalent value of a channel's keccak256 hash
export const hashToChannelId: Record<string, string> = {
"0xa4896a3f93bf4bf58378e579f3cf193bb4af1022af7d2089f37d8bae7157b85f": "random",
"0x99b6c68d677d3cdb82a74f6b2505208e25a692638accf1d7a62669954fe08d9a": "general",
"0x72c3936f97a420f89c999b2f7642e9203ca44f2e72e4506f69a989a5250c8266": "tech",
}
The useChatMessages
hook manages chat messages for a given channelId
:
Its State:
messages
: Array of messages (id
,sender
,channelId
,content
,timestamp
).isLoading
: Tracks fetching/sending status.
Wallet: Uses useWallet
for signer
(to send messages), address
, and isConnected
.
Event Handler: onMessageSent
Adds messages to messages if their channel matches channelId.
useEffect:
Runs on
channelId
/isConnected
change:Creates a read-only contract with the provider to listen for
MessageSent
events.Fetches past messages using
contract.queryFilter
from block 6417246 (a previous block from the block where the contract was deployed).Sets up a real-time listener with
contract.on
.Cleans up with
contract.off
to avoid memory leaks.
sendMessage: Sends a message via the contract using the signer, waits for confirmation, and updates messages via onMessageSent.
Returns: messages
, sendMessage
, isLoading
.
In all, it fetches and displays chat history, listens for new messages, and sends messages, ensuring channel-specific updates and proper cleanup.
📤 Step 4: Putting the pieces together, integrating the useChatMessages hook to fully build the chat dApp
First, we start with the ChatInterface component, create a new file components/ChatInterface.tsx
import { useState, useRef, useEffect } from "react";
import { MessageList } from "@/components/message-list";
import { MessageInput } from "@/components/message-input";
import { ChannelSelector } from "@/components/channel-selector";
import { useChatMessages } from "@/hooks/use-chat-messages";
export function ChatInterface() {
const [currentChannel, setCurrentChannel] = useState("general");
const { messages, sendMessage, isLoading } = useChatMessages(currentChannel);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSendMessage = async (content: string) => {
if (!content.trim()) return;
await sendMessage(content);
};
const handleChannelChange = (channel: string) => {
setCurrentChannel(channel);
};
return (
<div className="border border-zinc-700 rounded-lg overflow-hidden bg-zinc-800/50">
<div className="p-4 border-b border-zinc-700 bg-zinc-800">
<ChannelSelector
currentChannel={currentChannel}
onChannelChange={handleChannelChange}
/>
</div>
<div className="h-[500px] flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
<MessageList messages={messages} />
<div ref={messagesEndRef} />
</div>
<div className="border-t border-zinc-700 p-4 bg-zinc-800">
<MessageInput
onSendMessage={handleSendMessage}
isLoading={isLoading}
/>
</div>
</div>
</div>
);
}
What does the ChatInterface component do?
It’s State:
currentChannel
: Tracks the active channel (starts as "general").Uses
useChatMessages
hook to getmessages
,isLoading
for the current channel andsendMessage
,.
Auto-Scroll:
- Uses
useRef
anduseEffect
to scroll to the latest message (messagesEndRef) smoothly when messages update.
- Uses
Handlers:
handleSendMessage
: Sends a non-empty message viasendMessage
from the hook.handleChannelChange
: UpdatescurrentChannel
when a new channel is selected.
UI:
Renders a bordered container with:
ChannelSelector
: Dropdown to switch channels, updatescurrentChannel
.MessageList
: Displays messages in a scrollable area.MessageInput
: Input field to send messages, shows loading state when a message is being sent or messages for a current channel are being fetched.
Includes a ref (messagesEndRef) for auto-scrolling to the latest message.
This provides a clean interface to view, send, and switch between chat channels, with smooth scrolling and loading feedback.
Now we add the other sub-components, create components/MessageList.tsx
// components/MessageList.tsx
import type { Message } from "@/types/message";
import { formatTimestamp, formatAddress } from "@/lib/utils";
import { useWallet } from "@/hooks/use-wallet";
interface MessageListProps {
messages: Message[];
}
export function MessageList({ messages }: MessageListProps) {
const { address } = useWallet();
if (messages.length === 0) {
return (
<div className="flex items-center justify-center h-full text-zinc-500">
<p>No messages yet. Be the first to send one!</p>
</div>
);
}
return (
<div className="space-y-4">
{messages.map((message, index) => (
<MessageItem
key={`${message.id}-${index}`}
message={message}
isOwn={message.sender.toLowerCase() === address?.toLowerCase()}
/>
))}
</div>
);
}
interface MessageItemProps {
message: Message;
isOwn: boolean;
}
function MessageItem({ message, isOwn }: MessageItemProps) {
return (
<div className={`flex ${isOwn ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-lg p-3 ${
isOwn ? "bg-emerald-600 text-white" : "bg-zinc-700 text-white"
}`}
>
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-medium">
{isOwn ? "You" : formatAddress(message.sender)}
</span>
<span className="text-xs opacity-70 ml-2">
{formatTimestamp(message.timestamp)}
</span>
</div>
<p className="break-words">{message.content}</p>
</div>
</div>
);
}
then the components/ChannelSelector.tsx
// components/ChannelSelector.tsx
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Hash, ChevronDown } from "lucide-react";
interface ChannelSelectorProps {
currentChannel: string;
onChannelChange: (channel: string) => void;
}
const CHANNELS = [
{ id: "general", name: "General" },
{ id: "random", name: "Random" },
{ id: "tech", name: "Tech" },
];
export function ChannelSelector({
currentChannel,
onChannelChange,
}: ChannelSelectorProps) {
const currentChannelName =
CHANNELS.find((c) => c.id === currentChannel)?.name || "Select Channel";
return (
<div className="flex items-center">
<Hash className="h-5 w-5 mr-2 text-zinc-400" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 px-2 py-1">
<span className="font-medium">{currentChannelName}</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="bg-zinc-900 border-zinc-700"
>
{CHANNELS.map((channel) => (
<DropdownMenuItem
key={channel.id}
onClick={() => onChannelChange(channel.id)}
className={currentChannel === channel.id ? "bg-zinc-800" : ""}
>
<Hash className="h-4 w-4 mr-2 text-zinc-400" />
{channel.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
And finally, the components/MessageInput.tsx
// components/MessageInput.tsx
import type React from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Send } from "lucide-react";
interface MessageInputProps {
onSendMessage: (message: string) => Promise<void>;
isLoading: boolean;
}
export function MessageInput({ onSendMessage, isLoading }: MessageInputProps) {
const [message, setMessage] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || isLoading) return;
try {
await onSendMessage(message);
setMessage("");
} catch (error) {
console.error("Failed to send message:", error);
}
};
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={isLoading}
className="bg-zinc-900 border-zinc-700"
/>
<Button
type="submit"
disabled={isLoading || !message.trim()}
className="flex-shrink-0"
>
{isLoading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</form>
);
}
Now, we’ve set up a provider, connected a wallet, and listened for events from a deployed contract using ethers.js
all while building a fully functional real-time chat dApp.
Here’s a quick demo of how BlockChat works
✔️ Additional Notes & Best Practices
Always clean up listeners using
contract.off()
to prevent memory leaksFor mainnet apps, consider using WebSockets
WebSocketProvider
for real-time updatesAlways wrap your contract calls in try-catch blocks to handle user rejections or failures gracefully.
Wrap Up
By following the steps in this guide, you now have a solid foundation for:
Connecting to a wallet
Listening to past and real-time events from a deployed smart contract
Managing connection state in React
Interacting with smart contracts using Ethers.js
All while building a real-time Chat dApp
The full working code for this tutorial can be found here https://github.com/michojekunle/BlockChat. I’ve also deployed it live https://block-chat-topaz.vercel.app/
That’s all for now, friend. Be sure to consult the official Rootstock docs to dive deeper into Rootstock and join the Rootstock community on Discord, to learn more about the community, get guidance, and interact with fellow members of the community
Subscribe to my newsletter
Read articles from Michael Ojekunle directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Michael Ojekunle
Michael Ojekunle
I am an Enthusiastic, curious and imaginative Web Developer, eager to contribute to team success through hard work, Attention to detail and Excellent Organizational Skills, always open to new and unconventional ideas. I take my work as a Web Developer seriously and this means I always ensure my skills are kept up to date within this rapidly changing industry.