logo

Event-Driven Architecture for DApp/Contract Event Listening

Home Blog Event-Driven Architecture for DApp/Contr...
Development October 26, 2026 Admin 0 comments

Introduction

Before diving into the details, I give the flowers to Adekunle Michael Ajayi, whose teachings and articles on event-driven systems inspired this piece. If you’re new to listening to smart contract events, tools like Wagmi provide a straightforward way to handle them using React Query. However, this article is for developers who want more flexibility and control over event handling in their Web2/Web3 applications.


What is Event-Driven Architecture?

Event-Driven Architecture (EDA) is a software design pattern that emphasizes the flow of events in a system. Instead of components communicating through direct function calls, they respond to events as they occur. This approach improves scalability, decouples components, and makes systems more reactive.


Event-Driven Architecture in Traditional Web Development

A common example of event-driven programming in Web2 applications is handling global state changes across components that aren’t directly connected through props or context.

Understanding the Custom Event Hook in React Let’s examine a custom React hook for managing application-wide events:


import { useCallback, useEffect, type Dispatch } from "react";

interface AppEvent<PayloadType = unknown> extends Event { detail: PayloadType; }

export interface CustomWindowEventMap extends WindowEventMap { /* Custom Events */ onThemeChange: AppEvent; onNotification: AppEvent<{message: string, type: 'success' | 'error' | 'info'}>; }

export const useAppEvent = <PayloadType = unknown>( eventName: keyof CustomWindowEventMap, callback?: Dispatch | VoidFunction ) => { useEffect(() => { if (!callback) { return; }

const listener = ((event: AppEvent<PayloadType>) => {
  callback(event.detail); // Use `event.detail` for custom payloads
}) as EventListener;

window.addEventListener(eventName, listener);
return () => {
  window.removeEventListener(eventName, listener);
};

}, [callback, eventName]);

const dispatch = useCallback( (detail: PayloadType) => { const event = new CustomEvent(eventName, { detail }); window.dispatchEvent(event); }, [eventName] );

// Return a function to dispatch the event return { dispatch }; };


This hook provides a powerful way to implement pub-sub (publish-subscribe) patterns in React applications. Let’s break down how it works:

We define an AppEvent interface that extends the standard DOM Event with a detail property to carry our custom data We extend the WindowEventMap with our custom events, typed with the payloads they'll carry The hook sets up an event listener when a callback is provided and cleans it up when the component unmounts It returns a dispatch function that creates and dispatches a CustomEvent with our payload Example 1: Theme Switcher Across Components Imagine you have a theme toggle button in your header and need to update multiple components across your application:

// In your theme toggle component import { useAppEvent } from "@/hooks/useAppEvent";

const ThemeToggler = () => { const { dispatch } = useAppEvent("onThemeChange");

const toggleTheme = () => { const newTheme = document.documentElement.classList.contains("dark") ? "light" : "dark"; document.documentElement.classList.toggle("dark"); localStorage.setItem("theme", newTheme); dispatch(newTheme); // Broadcast the theme change };

return Toggle Theme; };

// In a separate component that needs to react to theme changes const ThemeAwareComponent = () => { const [theme, setTheme] = useState("light");

useAppEvent("onThemeChange", (newTheme) => { setTheme(newTheme); // Update component-specific styling or logic });

return <div className={bg-${theme === "dark" ? "gray-800" : "white"}}> Content adapts to theme without prop drilling!

; };


Example 2: Global Notification System Create a notification system that can be triggered from anywhere in your application:

// In a component that needs to show notifications import { useAppEvent } from "@/hooks/useAppEvent";

const NotificationTrigger = () => { const { dispatch } = useAppEvent("onNotification");

const handleSuccess = () => { dispatch({ message: "Operation completed successfully!", type: "success" }); };

const handleError = () => { dispatch({ message: "Something went wrong", type: "error" }); };

return (

Trigger Success Trigger Error

); };


// In your notification component (can be anywhere in the component tree) const NotificationCenter = () => { const [notifications, setNotifications] = useState<Array<{ id: number; message: string; type: 'success' | 'error' | 'info'; }>>([]);

useAppEvent("onNotification", (notificationData) => { const newNotification = { id: Date.now(), ...notificationData };

setNotifications(prev => [...prev, newNotification]);

// Auto-dismiss after 5 seconds
setTimeout(() => {
  setNotifications(prev => 
    prev.filter(item => item.id !== newNotification.id)
  );
}, 5000);

});

return (

{notifications.map(notification => ( <div key={notification.id} className={p-4 mb-2 rounded shadow-md ${ notification.type === 'success' ? 'bg-green-100 text-green-800' : notification.type === 'error' ? 'bg-red-100 text-red-800' : 'bg-blue-100 text-blue-800' }} > {notification.message}

))} ); };


The application of event-driven architecture extends far beyond these examples, powering many sophisticated real-world web applications. For instance, collaborative editing tools like Google Docs use event-driven systems to synchronise changes across multiple users in real-time. E-commerce platforms leverage this pattern to coordinate inventory updates, cart modifications, and checkout processes across different parts of the application. Real-time analytics dashboards use events to update visualisations as new data flows in without refreshing the page. Chat and messaging applications rely heavily on this architecture to deliver messages instantly while maintaining separation between UI components. Enterprise applications employ event-driven systems to orchestrate complex workflows where multiple teams and services need to react to the same business events independently. This pattern also excels in IoT applications where web interfaces must respond to device status changes and sensor readings from multiple sources simultaneously.Event-Driven Architecture in DApps In blockchain applications, events are essential for tracking on-chain activities. Unlike traditional function calls, smart contracts emit events that external applications listen to in real-time. This is particularly useful for handling updates across multiple contracts without excessive polling.

The Smart Contract Backbone Here’s a simplified Todo smart contract that emits events when a todo is created, updated, completed, or deleted:

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

contract TodoList{

enum Status {None, Created, Edited, Done}

struct Todo{
    string title;
    string description;
    Status status;
}

Todo[] public todos;

modifier validAddress(){
    require(msg.sender != address(0), "Zero address not allowed");
    _;
}

event TodoCreated(string title, string description, Status status);
event TodoUpdated(uint8 index, string title, string description, Status status);
event TodoCompleted(uint8 index, Status status);
event TodoDeleted(uint8 index);

function createTodo(string memory _title, string memory _desc) external validAddress returns (bool){
    Todo memory mytodo;
    mytodo.title = _title;
    mytodo.description = _desc;
    mytodo.status = Status.Created;

    todos.push(mytodo);

    emit TodoCreated(_title, _desc, Status.Created);

    return true;
}

function updateTodo(uint8 _index, string memory _title, string memory _desc) external validAddress {
    require(_index < todos.length, "Index is out-of-bound");

    Todo storage mytodo = todos[_index];
    mytodo.title = _title;
    mytodo.description = _desc;
    mytodo.status = Status.Edited;

    emit TodoUpdated(_index, _title, _desc, Status.Edited);
}

function getTodo(uint8 _index) external view returns (string memory, string memory, Status){
    require(_index < todos.length, "Index is out-of-bound");

    Todo memory mytodo = todos[_index];
    return (mytodo.title, mytodo.description, mytodo.status);
}

function getAllTodo() external view returns(Todo[] memory){
    return todos;
}

function todoCompleted(uint8 _index) external validAddress returns (bool){
    require(_index < todos.length, "Index is out-of-bound");

    Todo storage mytodo = todos[_index];
    mytodo.status = Status.Done;

    emit TodoCompleted(_index, Status.Done);

    return true;
}

function deleteTodo(uint8 _index) external validAddress{
    require(_index < todos.length, "Index is out-of-bound");

    todos[_index] = todos[todos.length - 1];
    todos.pop();

    emit TodoDeleted(_index);
}

}


Our Todo smart contract acts as a blockchain notary — every action (create/update/complete/delete) gets permanently recorded and emits an event. Think of these events as blockchain text messages that say:

“Hey, someone created a todo!” “Todo #3 was just completed!” The Event Translator (useContractEvents Hook) import { AppEvent, CustomWindowEventMap, useAppEvent } from "./useAppEvent"; import { useMemo, type Dispatch } from "react"; import { useEffect } from "react"; import { Contract } from "ethers"; import ABI from "../../abi/todo.json"; import { readOnlyProvider } from "../../config/readOnlyProvider";


// Extend our CustomWindowEventMap to include contract events interface ContractEventMap extends CustomWindowEventMap { TodoCreated: AppEvent<{ title: string; description: string; status: number }>; TodoUpdated: AppEvent<{ index: number; title: string; description: string; status: number; }>; TodoCompleted: AppEvent<{ index: number; status: number }>; TodoDeleted: AppEvent<{ index: number }>; }

// Create a specialized version of our hook for contract events export const useContractEvent = <PayloadType = unknown>( eventName: keyof ContractEventMap, callback?: Dispatch | VoidFunction ) => { return useAppEvent(eventName as never, callback); };

/**

  • Sets up listeners for contract events and dispatches app events when a contract
  • event is emitted. This hook is used to keep the state of the app up to date
  • with the state of the contract.
  • Currently, the events that are listened to are:
  • TodoCreated(title, description, status)
  • TodoUpdated(index, title, description, status)
  • TodoCompleted(index, status)
  • TodoDeleted(index)
  • When a contract event is emitted, the corresponding app event is dispatched
  • with the same payload. */ const useContractEvents = () => { const contractAddress = import.meta.env.VITE_CONTRACT_ADDRESS;

// Memoize contract instance to reuse across re-renders const contract = useMemo(() => { return new Contract(contractAddress, ABI, readOnlyProvider); }, [contractAddress]);

// Get dispatch functions for each contract event const { dispatch: dispatchTodoCreated } = useContractEvent("TodoCreated"); const { dispatch: dispatchTodoUpdated } = useContractEvent("TodoUpdated"); const { dispatch: dispatchTodoCompleted } = useContractEvent("TodoCompleted"); const { dispatch: dispatchTodoDeleted } = useContractEvent("TodoDeleted");

useEffect(() => { // Set up listeners for each contract event contract.on( "TodoCreated", (title: string, description: string, status: number) => { dispatchTodoCreated({ title, description, status }); } );

contract.on(
  "TodoUpdated",
  (index: number, title: string, description: string, status: number) => {
    dispatchTodoUpdated({ index, title, description, status });
  }
);

contract.on("TodoCompleted", (index: number, status: number) => {
  dispatchTodoCompleted({ index, status });
});

contract.on("TodoDeleted", (index: number) => {
  dispatchTodoDeleted({ index });
});

// Clean up listeners when component unmounts
return () => {
  contract.removeAllListeners();
};

}, [ contract, dispatchTodoCreated, dispatchTodoUpdated, dispatchTodoCompleted, dispatchTodoDeleted, ]); };

export default useContractEvents; This code acts like a bilingual interpreter:

Listens to raw blockchain events. Translates them into app-friendly JavaScript events. Automatically cleans up when you leave the page. The State Synchronizer (TodoContextProvider) import { createContext, useCallback, useContext, useEffect, useState } from "react"; import useContractEvents, { useContractEvent } from "../hooks/events/useContractEvents"; import useContract from "../hooks/useContract";

// Define Todo interface interface Todo { title: string; description: string; status: string; }

// Define context type interface TodoContextType { todos: Todo[]; }

// Create properly typed context const TodoContext = createContext({ todos: [] });

/**

  • Provides a context and manages the state for todos in the application.
  • This component sets up event listeners for todo-related actions,
  • such as creation, updating, completion, and deletion, and updates
  • the todos state accordingly based on contract events.
  • It fetches initial todo data from the read-only contract and formats
  • the status of each todo item using the formatEnum function.
  • The context value contains the todos state, which is an array of todo
  • objects, each with a title, description, and status.
  • @param {object} props - The props for the provider.
  • @param {React.ReactNode} props.children - The child components to be wrapped by the provider. */

const TodoContextProvider = ({ children }: { children: React.ReactNode }) => { const [todos, setTodos] = useState<Todo[]>([]);

const readOnlyTodoContract = useContract();

// Start listening to contract events
useContractEvents();


const formatEnum = (value: number): string => {
    const status = Number(value);
    switch (status) {
        case 1:
            return "Created";
        case 2:
            return "Edited";
        case 3:
            return "Completed";
        default:
            return "Pending";
    }
}

const getTodos = useCallback(async () => {
    if (!readOnlyTodoContract) return;

    try {
        const data = await readOnlyTodoContract.getAllTodo();
        const formattedTodos = data.map((todo: { title: string; description: string; status: number }) => ({
            title: todo.title,
            description: todo.description,
            status: formatEnum(todo.status)
        }))
        console.log("Todo fetched from getTodos");
        setTodos(formattedTodos);
    } catch (error) {
        console.log("Error fetching todos", error);
    }
}, [readOnlyTodoContract])

useEffect(() => {
    getTodos();
}, [getTodos])


// Define types for each event payload
type TodoCreatedPayload = {
    title: string;
    description: string;
    status: number;
};

type TodoUpdatedPayload = {
    index: number;
    title: string;
    description: string;
    status: number;
};

type TodoCompletedPayload = {
    index: number;
    status: number;
};

type TodoDeletedPayload = {
    index: number;
};


// Listen for TodoCreated events
useContractEvent<TodoCreatedPayload>("TodoCreated", (payload) => {
    setTodos(prevState => {
        const exists = prevState.some(todo =>
            todo.title === payload.title &&
            todo.description === payload.description &&
            todo.status === formatEnum(payload.status)
        );
        return exists ? prevState : [...prevState, {
            title: payload.title,
            description: payload.description,
            status: formatEnum(payload.status),
        }];
    });
});



// Listen for TodoUpdated events
useContractEvent<TodoUpdatedPayload>("TodoUpdated", (payload) => {
    setTodos((prevState) => {
        const updatedTodos = [...prevState];
        updatedTodos[Number(payload.index)] = {
            title: payload.title,
            description: payload.description,
            status: formatEnum(payload.status),
        };
        return updatedTodos;
    });
});


// Listen for TodoCompleted events
useContractEvent<TodoCompletedPayload>("TodoCompleted", (payload) => {
    setTodos((prevState) => {
        const updatedTodos = [...prevState];
        updatedTodos[Number(payload.index)] = {
            ...updatedTodos[Number(payload.index)],
            status: formatEnum(payload.status),
        };
        return updatedTodos;
    });
});


// Listen for TodoDeleted events
useContractEvent<TodoDeletedPayload>("TodoDeleted", (payload) => {
    setTodos((prevState) => {
        const updatedTodos = [...prevState];
        updatedTodos.splice(Number(payload.index), 1);
        return updatedTodos;
    });
});

return (
    <TodoContext.Provider value={{ todos }}>
        {children}
    </TodoContext.Provider>
)

}

// eslint-disable-next-line react-refresh/only-export-components export const useTodo = () => { const context = useContext(TodoContext); return context; }

export default TodoContextProvider; This component works like a live-updating spreadsheet:

Listens for our translated app events. Updates the UI list of todos in real-time. No page refresh needed — changes appear magically! The User Interface (Todos Component) import { Text } from "@radix-ui/themes" import { useTodo } from "../context/todoContext" import Todo from "./Todo" import useTheme from "../hooks/events/useTheme"

export type TodoType = { title: string, description: string, status: string }

/**

  • Component that renders a list of todos.
  • Fetches the todos state from the TodoContext and renders each todo as a Todo component.
  • If there are no todos, it renders a message to the user.
  • @returns JSX.Element */ const Todos = () => { const { todos } = useTodo();
  • const { theme } = useTheme();
  • const textColor = theme === "dark" ? "text-stone-200" : "text-stone-800";
  • return (
  • My Todos
  • { todos.length === 0 ? <Text as="p" className={text-2xl font-medium ${textColor}}> There are no available todos : todos.map((todo: TodoType, index: number) => ( )) }
  • ) }

export default Todos This is where magic becomes visible:

Automatically receives updated todo list. Re-renders only changed parts. Users see changes as they happen on blockchain.

Advantages of Event-Driven Architecture in Dapps Real-Time Updates:

Users see changes immediately without refreshing or polling. Decoupling Components: Different parts of the application can react to blockchain events without tight coupling. Better Performance: Events eliminate the need for continuous blockchain queries, reducing RPC calls and network load. Improved User Experience: No delays between on-chain actions and UI updates. Cross-Component Communication: Components across your app can react to the same events independently. Scalability: The system scales efficiently as more components subscribe to events. Multiple Contract Monitoring: Can listen to multiple smart contracts simultaneously with the same pattern. Conclusion Event-driven architecture is crucial for building reactive and scalable applications, especially in blockchain-based Dapps where real-time updates from on-chain activities are essential. By using custom event hooks, developers gain more control over event handling and can efficiently manage real-time updates.

This approach creates a clean separation between blockchain interaction and UI updates, making your code more maintainable and easier to reason about. It also provides a consistent pattern for handling both application events and smart contract events in your React applications.

A
Posted by
Admin

Comments (0)

No comments yet. Be the first to leave one!

Leave A Reply


At LonStack, we are committed to delivering excellence through technology. Our team of experts partners with businesses to craft intelligent, reliable, and scalable digital experiences that stand the test of time.
Newsletter Subscribe