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

Michael OjekunleMichael Ojekunle
16 min read
  • 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.tsYou’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 get messages, isLoading for the current channel and sendMessage,.

  • Auto-Scroll:

    • Uses useRef and useEffect to scroll to the latest message (messagesEndRef) smoothly when messages update.
  • Handlers:

    • handleSendMessage: Sends a non-empty message via sendMessage from the hook.

    • handleChannelChange: Updates currentChannel when a new channel is selected.

  • UI:

    • Renders a bordered container with:

      • ChannelSelector: Dropdown to switch channels, updates currentChannel.

      • 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 leaks

  • For mainnet apps, consider using WebSockets WebSocketProviderfor real-time updates

  • Always 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

0
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.