Real-Time Smart Contract Events in The Frontend

With React, and ethers

Introduction

Solidity Events is a useful feature of Solidity that allows Smart Contracts to log information to the blockchain.

This log has pretty valuable features that enable the data to be indexed and searched or filtered. Also, external applications can listen to these event logs for new data.

It must be noted that although smart contracts generate these event logs, they cannot read from the logs. As such event logs on the blockchain serve as a cheap form of storage on the blockchain.

However, Dapp developers can take advantage of these logs to provide real-time updates on the frontend. If you are developing a Dapp chances are that you will use either web3js or ethers to interact with Ethereum nodes.

What we will be doing

We will write a basic product smart contract that provides functionalities to create products and sell a product.

However, our main objective will be to build a simple react app that will

  1. read products data from the smart contract

  2. subscribe to smart contract events and update the UI in real-time.

Writing a basic product smart contract Product.sol

Our smart contract will be simple and concise.

  1. It will allow the owner of the contract to create a product with the following properties:

    • sku
    • name
    • image
    • description
    • price
    • quantityAvailable
    • quantitySold

    When a product is created, the smart contract will emit an event that gets logged on the blockchain.

  2. It will allow a user to buy a listed product with Ether and then emits an event that gets logged on the blockchain.

You can find the code for the smart contract here on github. It is pretty straight forward and explained with comments

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

contract ConsumerShop {
    //stores the owner of the contract
    address owner;

    //define the properties of a Product
    struct Product {
        uint sku;
        string name;
        string imageU;
        string description;
        uint price;
        uint quantityAvailable;
        uint quantitySold;
    }

    //stores all products that have been created
    Product[] public products;

    //restrict access to only the owner of the contract
    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner allowed");
        _;
    }

    //@dev event to be emitted when a new product is created
    event ProductCreated(
        uint indexed index,
        uint indexed sku,
        string name,
        string image,
        string description,
        uint quantityAvailable,
        uint price
    );

    // event to be emitted when a product is sold
    event ProductSold(
        uint indexed index,
        uint indexed sku,
        uint oldQuantitySold,
        uint newQuantitySold
    );

    constructor() {
        //set the owner of the contract to the address that deployed the contract
        owner = msg.sender;
    }

    /**
     *@dev creates a new product and adds it to the `products` array
     * restrict access to the owner of the contract using `onlyOwner` modifier
     *@param _sku - a unique ID for product
     *@param _image - a url of product image
     *@param _description - a label or short description of the product
     *@param _price - price at which the product will be sold
     *@param _quantityAvailable - quantity of products available for sale
     */
    function createProduct(
        uint _sku,
        string memory _name,
        string memory _image,
        string memory _description,
        uint _price,
        uint _quantityAvailable
    ) public onlyOwner {
        // we make some assumption that information on sku, name, and price
        // are the least requirements of a product
        require(_sku > 0, "SKU is required");
        require(bytes(_name).length > 0, "Name cannot be empty");
        require(_price > 0, "Price cannot be zero");

        //make a new product using the key-value pair approach
        //and push into the products array
        products.push(
            Product({
                sku: _sku,
                name: _name,
                image: _image,
                description: _description,
                price: _price,
                quantityAvailable: _quantityAvailable,
                quantitySold: 0
            })
        );

        //emit a `ProductCreated` event to log to the blockchain
        emit ProductCreated(
            products.length,
            _sku,
            _name,
            _image,
            _description,
            _quantityAvailable,
            _price
        );
    }

    /**
     *@dev `buyProduct` allows a user to buy a product per transaction
     *@param index - index of the product in the `products` array
     */
    function buyProduct(uint index) external payable {
        //make sure the index is within range of the array
        require(index <= products.length - 1, "Index is out of range");
        //get the product
        Product storage product = products[index];
        //make sure that the amount sent by the user is enough
        require(msg.value >= product.price, "Amount sent is not enough");

        uint oldQuantitySold = product.quantitySold;
        product.quantityAvailable -= 1;
        product.quantitySold += 1;

        emit ProductSold(
            index,
            product.sku,
            oldQuantitySold,
            product.quantitySold
        );
    }

    //@dev additional functionality to withdraw funds, and update product information
    //Yet the object here is to demonstract events in solidity so we stick to it.

    //enable our contract to receive ether
    receive() external payable {}

    fallback() external payable {}
}

Writing our frontend

Our frontend will be built with ReactJS and the bootstraps library, and ethers. As the subject of this article is primarily on listening for contract events on the frontend, I will focus on the parts of the code that deals with subscribing and listening to smart contract events. If you wish to look at the entire codes you can check it out here. The codes are explained with comments.

There will be three basic parts of our frontend.

  1. Event constants that store string literals of events emitted by the smart contracts.

    //smart contract events
    export const EVENTS = {
       PRODUCT_CREATED: "ProductCreated",
       PRODUCT_SOLD: "ProductSold"
    }
    
  2. React state variables that track loading status and a list of fetched products.

    //tracks products
    const [products, setProducts] = useState([]);
    
    //tracks loading activity
    const [loading, setLoading] = useState(false);
    
  3. Card components that are rendered when products get loaded.

  4. A modal component for creating new product.

Subscribing to events with ethers

ethers contract connection instances have a method on , that allows you to listen for events from your smart contract. contract.on( event , listener ) It accepts two arguments;

  • event: this could be a string of the event name as defined in the smart contract (in our case ProductCreated or ProductSold) or a filter object (which provides advanced filtering)
  • listener: this is a function that will be called when the associated event is emitted by the smart contract.

Our implementation will look like this:

  1. Subscribing to ProductCreated event

    //subscribing to product created event
    //note that JS spread (...) operator is used to collect all arguments
    //that are passed to the callback function as eventData
    contract.on(EVENTS.PRODUCT_CREATED, (...eventData) => {
       const [
           index,
           sku,
           name,
           image,
           description,
           quantityAvailable,
           price,
       ] = eventData;
    
       setProducts((prevProducts) => {
           const newProduct = {
               index: index.toNumber(),
               sku,
               name,
               image,
               description,
               price,
               quantityAvailable,
           };
    
           //append newProduct
           return [...prevProducts, newProduct];
       });
    });
    
  2. Subscribing to ProductSold event

    //subscribing to product sold event
    //note again that JS spread (...) operator is used to collect all arguments
    //that are passed to the callback function as eventData
    contract.on(EVENTS.PRODUCT_SOLD, (...eventData) => {
       //get product info from event data
       const [index,,, totalQuantitySold, newQuantityAvailable] = eventData;
    
       //used as key to identify product in `products`
       const idx = index?.toNumber();
    
       setProducts((prevProducts) => {
           //find product by index
           const oldProductDetails = prevProducts[idx];
    
           if (!oldProductDetails) {
               return {
                   ...prevProducts,
               };
           }
    
           //generate updated product info to replace old product info
           const updatedProductDetails = {
               ...oldProductDetails,
               quantitySold: totalQuantitySold,
               quantityAvailable: newQuantityAvailable,
           };
    
           //insert to replace old product details
           const products = [
               ...prevProducts.slice(0, idx),
               updatedProductDetails,
               ...prevProducts.slice(idx + 1),
           ];
    
           return products;
       });
    });
    

In both cases, our implementation is very simple. We pass the event name and our listener function to the contract.on method. The listener function gets executed whenever a new product is created or when a product is sold. Each of the listener functions receive the event data, manipulates the data to extract the relevant information and updates the product state variable of the component.

This code can be implemented using the useEffect hook. When the component unmounts the listener functions on the contract connection instance must be removed.

useEffect(() => {
    //get smartcontract connection instance
    const contract = getcontract();

    //subscribing to product created event
    //note that JS spread (...) operator is used to collect all arguments
    //that are passed to the callback function as eventData
    contract.on(EVENTS.PRODUCT_CREATED, (...eventData) => {
        // ...
    });

    //subscribing to product sold event
    //note again that JS spread (...) operator is used to collect all arguments
    //that are passed to the callback function as eventData
    contract.on(EVENTS.PRODUCT_SOLD, (...eventData) => {
        // ...
    });

    return () => {
        //remove all contract listeners when component is unmounted
        //it is important to remove dangling listeners to prevent multiple
        //listener function calls on event which may lead to multiiple rendering of a product.
        contract.removeAllListeners();
    };
}, []);