DEV Community

Cover image for Event-Driven Architecture for Clean React Component Communication
Nicola
Nicola

Posted on

Event-Driven Architecture for Clean React Component Communication

Are you tired of the endless tangle of props drilling and callback chains in your React applications? Does managing state and communication between deeply nested components feel like wrestling with spaghetti code?

An event-driven architecture can simplify your component interactions, reduce complexity, and make your app more maintainable. In this article, I’ll show you how to use a custom useEvent hook to decouple components and improve communication across your React app.

Let me walk you through it, let's start from


The Problem: Props Drilling and Callback Chains

In modern application development, managing state and communication between components can quickly become cumbersome. This is especially true in scenarios involving props drilling—where data must be passed down through multiple levels of nested components—and callback chains, which can lead to tangled logic and make code harder to maintain or debug.

These challenges often create tightly coupled components, reduce flexibility, and increase the cognitive load for developers trying to trace how data flows through the application. Without a better approach, this complexity can significantly slow down development and lead to a brittle codebase.

The Traditional Flow: Props Down, Callbacks Up

In a typical React application, parent components pass props to their children, and children communicate back to the parent by triggering callbacks. This works fine for shallow component trees, but as the hierarchy deepens, things start to get messy:

Props Drilling: Data must be passed down manually through multiple levels of components, even if only the deepest component needs it.

Callback Chains: Similarly, child components must forward event handlers up the tree, creating tightly coupled and hard-to-maintain structures.

A Common Problem: Callback Complexity

Take this scenario, for example:

  • The Parent passes props to Children A.
  • From there, props are drilled down to GrandChildren A/B and eventually to SubChildren N.
  • If SubChildren N needs to notify the Parent of an event, it triggers a callback that travels back up through each intermediate component.

This setup becomes harder to manage as the application grows. Intermediate components often act as nothing more than middlemen, forwarding props and callbacks, which bloats the code and reduces maintainability.

PropsDrilling and CallbackChains

To address props drilling, we often turn to solutions like global state management libraries (e.g., Zustand) to streamline data sharing. But what about managing callbacks?

This is where an event-driven approach can be a game-changer. By decoupling components and relying on events to handle interactions, we can significantly simplify callback management. Let’s explore how this approach works.


The Solution: Enter the Event-Driven Approach

Event lifecycle

Instead of relying on direct callbacks to communicate up the tree, an event-driven architecture decouples components and centralizes communication. Here’s how it works:

Event Dispatching

When SubChildren N triggers an event (e.g., onMyEvent), it doesn’t directly call a callback in the Parent.
Instead, it dispatches an event that is handled by a centralized Events Handler.

Centralized Handling

The Events Handler listens for the dispatched event and processes it.
It can notify the Parent (or any other interested component) or trigger additional actions as required.

Props Remain Downward

Props are still passed down the hierarchy, ensuring that components receive the data they need to function.

This can be solved with centralized state management tools like zustand, redux, but will not be covered in this article.


Implementation

But, how do we implement this architecture?

useEvent hook

Let's create a custom hook called useEvent, this hook will be responsible of handling event subscription and returning a dispatch function to trigger the target event.

As I am using typescript, I need to extend the window Event interface in order to create custom events:

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

export const useEvent = <PayloadType = unknown>(
  eventName: keyof CustomWindowEventMap,
  callback?: Dispatch<PayloadType> | VoidFunction
) => {
  ...
};
Enter fullscreen mode Exit fullscreen mode

By doing so, we can define custom events map and pass custom parameters:

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

export interface CustomWindowEventMap extends WindowEventMap {
  /* Custom Event */
  onMyEvent: AppEvent<string>; // an event with a string payload
}

export const useEvent = <PayloadType = unknown>(
  eventName: keyof CustomWindowEventMap,
  callback?: Dispatch<PayloadType> | VoidFunction
) => {
  ...
};
Enter fullscreen mode Exit fullscreen mode

Now that we defined needed interfaces, let's see the final hook code

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

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

export interface CustomWindowEventMap extends WindowEventMap {
  /* Custom Event */
  onMyEvent: AppEvent<string>;
}

export const useEvent = <PayloadType = unknown>(
  eventName: keyof CustomWindowEventMap,
  callback?: Dispatch<PayloadType> | 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 };
};

Enter fullscreen mode Exit fullscreen mode

The useEvent hook is a custom React hook for subscribing to and dispatching custom window events. It allows you to listen for custom events and trigger them with a specific payload.

What we are doing here is pretty simple, we are using the standard event management system and extending it in order to accommodate our custom events.

Parameters:

  • eventName (string): The name of the event to listen for.
  • callback (optional): A function to call when the event is triggered, receiving the payload as an argument.

Features:

  • Event Listener: It listens for the specified event and calls the provided callback with the event's detail (custom payload).
  • Dispatching Events: The hook provides a dispatch function to trigger the event with a custom payload.

Example:

const { dispatch } = useEvent("onMyEvent", (data) => console.log(data));

// To dispatch an event
dispatch("Hello, World!");

// when dispatched, the event will trigger the callback
Enter fullscreen mode Exit fullscreen mode

Ok cool but, what about a

Real World Example?

Check out this StackBlitz (if it does not load, please check it here)

This simple example showcases the purpose of the useEvent hook, basically the body's button is dispatching an event that is intercepted from Sidebar, Header and Footer components, that updates accordingly.

This let us define cause/effect reactions without the need to propagate a callback to many components.


Real-World Use Cases for useEvent

Here are some real-world use cases where the useEvent hook can simplify communication and decouple components in a React application:


1. Notifications System

A notification system often requires global communication.

  • Scenario:

    • When an API call succeeds, a "success" notification needs to be displayed across the app.
    • Components like a "Notifications Badge" in the header need to update as well.
  • Solution: Use the useEvent hook to dispatch an onNotification event with the notification details. Components like the NotificationBanner and Header can listen to this event and update independently.

2. Theme Switching

When a user toggles the theme (e.g., light/dark mode), multiple components may need to respond.

  • Scenario:

    • A ThemeToggle component dispatches a custom onThemeChange event.
    • Components like the Sidebar and Header listen for this event and update their styles accordingly.
  • Benefits: No need to pass the theme state or callback functions through props across the entire component tree.

3. Global Key Bindings

Implement global shortcuts, such as pressing "Ctrl+S" to save a draft or "Escape" to close a modal.

  • Scenario:
    • A global keydown listener dispatches an onShortcutPressed event with the pressed key details.
    • Modal components or other UI elements respond to specific shortcuts without relying on parent components to forward the key event.

4. Real-Time Updates

Applications like chat apps or live dashboards require multiple components to react to real-time updates.

  • Scenario:
    • A WebSocket connection dispatches onNewMessage or onDataUpdate events when new data arrives.
    • Components such as a chat window, notifications, and unread message counters can independently handle updates.

5. Form Validation Across Components

For complex forms with multiple sections, validation events can be centralized.

  • Scenario:
    • A form component dispatches onFormValidate events as users fill out fields.
    • A summary component listens for these events to display validation errors without tightly coupling with form logic.

6. Analytics Tracking

Track user interactions (e.g., button clicks, navigation events) and send them to an analytics service.

  • Scenario:
    • Dispatch onUserInteraction events with relevant details (e.g., the clicked button’s label).
    • A central analytics handler listens for these events and sends them to an analytics API.

7. Collaboration Tools

For collaborative tools like shared whiteboards or document editors, events can manage multi-user interactions.

  • Scenario:
    • Dispatch onUserAction events whenever a user draws, types, or moves an object.
    • Other clients and UI components listen for these events to reflect the changes in real time.

By leveraging the useEvent hook in these scenarios, you can create modular, maintainable, and scalable applications without relying on deeply nested props or callback chains.


Conclusions

Events can transform the way you build React applications by reducing complexity and improving modularity. Start small—identify a few components in your app that would benefit from decoupled communication and implement the useEvent hook.

With this approach, you’ll not only simplify your code but also make it easier to maintain and scale in the future.

Why Use Events?
Events shine when you need your components to react to something that happened elsewhere in your application, without introducing unnecessary dependencies or convoluted callback chains. This approach reduces the cognitive load and avoids the pitfalls of tightly coupling components.

My Recommendation
Use events for inter-component communication—when one component needs to notify others about an action or state change, regardless of their location in the component tree.
Avoid using events for intra-component communication, especially for components that are closely related or directly connected. For these scenarios, rely on React's built-in mechanisms like props, state, or context.

A Balanced Approach
While events are powerful, overusing them can lead to chaos. Use them judiciously to simplify communication across loosely connected components, but don’t let them replace React’s standard tools for managing local interactions.

Top comments (1)

Collapse
 
miketalbot profile image
Mike Talbot ⭐

Do you not need deps on the useEvent? Or is it rebinding every redraw? I have a very similar system, and needed that for it.