DEV Community

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

Posted on • Edited on

123 4 3 7 5

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

Note: This mechanism is not intended as a replacement for a global state management system but an alternative approach to component communication flow.


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.

Note
As pointed out in the comments remember to memoize the callback function using useCallback in order to avoid continous event removal and creation, as the callback itself will be a dependency of the useEvent internal useEffect.


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 (31)

Collapse
 
vikkio88 profile image
Vincenzo

this looks like it will get messy quickly depending on what type of state you need to maintain.

a state management solution would be superior to this even if the state is simpler, less code and future proof in case it needs to grow.

Collapse
 
nicolalc profile image
Nicola

Hi Vincenzo, thanks for your comment. Are you talking about the cloned [count, setCount] state management? If so it's just for example purposes and not the right use-case for useEvent hook, it's just to demonstrate how to communicate between components and no callbacks at all. I will change the example if that's the case as I agree it might be confusing.

Collapse
 
vikkio88 profile image
Vincenzo

even if it wasn't just that, the event system doesn't really scale well for shared state.

Thread Thread
 
nicolalc profile image
Nicola

Indeed, as I wrote at the start of the article, to manage shared state there are solutions like Zustand or Redux, the event system is intended for communication, not for sharing data.

Think about it like: "Something happened elsewhere, and I need to react accordingly".

Hope this clarify its responsibility.

Thread Thread
 
chandragie profile image
chandragie

I was thinking the same as him. Thought that Redux etc would be enough. But I remember you highlighted the callback chains bottom up. Thanks for the example.

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.

Collapse
 
nicolalc profile image
Nicola

Thanks Mike for pointing that out, for sure something I need to add to the article.

As the callback itself is part of the useEffect deps array:

  useEffect(() => {
    [...]
  }, [callback, eventName]);
Enter fullscreen mode Exit fullscreen mode

it works like any classic hook dependency, and I suggest to memoize it else it will result in a new function at every re-render as in javascript functions are not comparable.

What I usually do is that (until React 19 compiler at least):

const myCallback = useCallback([...], [deps])

useEvent("eventName", myCallback);
Enter fullscreen mode Exit fullscreen mode

By doing so you will pass a memoized function to the hook, and if I'm not wrong (need to check that but pretty sure it works) the ref to that function will not change and the effect not triggered again.

Collapse
 
miketalbot profile image
Mike Talbot ⭐

Yeah that's a way! I also ended up wiring the event to my global dispatcher in a useMemo and removing it in the release function of a useEffect to get it there quickly as I raise quite a few lifecycle events and occasionally they were missed. Due to this I also capture events before the component is mounted and then dispatch them when it is.

export function useEvent(eventName, handler, deps = []) {
    const mounted = useRef()
    const mountFn = useRef(noop)
    const remove = useRef(noop)
    // eslint-disable-next-line react-hooks/exhaustive-deps
    const innerHandler = useCallback(readyHandler, [...deps, eventName])

    innerHandler.priority = handler.priority
    useMemo(() => {
        remove.current()
        remove.current = handle(eventName, innerHandler)
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [...deps, eventName])
    useEffect(() => {
        mounted.current = true
        mountFn.current()
        return () => {
            if (remove.current) {
                remove.current()
                remove.current = noop
            }
            if (mounted.current) {
                mounted.current = false
            }
        }
    }, [eventName])
    return handler

    function readyHandler(...params) {
        const self = this
        if (mounted.current) {
            return handler.call(self, ...params)
        }
        const existing = mountFn.current
        mountFn.current = () => {
            try {
                existing()
            } catch (e) {
                //
            }
            handler.call(self, ...params)
        }
        return undefined
    }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
alaindet profile image
Alain D'Ettorre

The browser-native CustomEvent is great and is also the standard in web components. However, keeping track of those is quite hard as they're not part of a component's props object. I still find them useful but I'm scared to use them and would probably rely on using a Context.

I believe using CustomEvent even skips rendering cycles and is easier to use compared to context, but still it would be unconventional and potentially non-maintainable for me.

Collapse
 
nicolalc profile image
Nicola

Thanks Alain for sharing your thoughts, in general in programming there is no best solution for everything, you need to find the best one for your needs and you personal developer experience. For me context are super good but I hate the doom they create when you have too many, and you need to create super contexts to avoid too complex context trees

Collapse
 
alaindet profile image
Alain D'Ettorre

You're right, Contexts can get ugly fast and I honestly don't like them much, BUT they are idiomatic to React and they're guaranteed to be the go-to first-party solution for sharing state and behavior, so it's ok for me.

About CustomEvent again, despite being great and standard, it's clearly not idiomatic in React and using a message bus that's external to fix an internal concern feels like a hack. My 2 cents

Thread Thread
 
nicolalc profile image
Nicola

Yep you’re right, keep in mind that this solution starts from a react “problem” but extends to an architecture that you can use everywhere. Keep in mind that you don’t need to fully replace react standards as this system can help you in some scenarios, but not for every one. As always you need to balance solutions based on problems.

For example, I like to use events when you open a table item details view in a modal that overlays the table, you edit the item and you need the table to refresh. In that case events are great as you can just say “an item has changed” and the table fetch data again. These two views might be not aware of each other depending on your implementation, so don’t take this approach as the best one for every case, but for some cases

Collapse
 
lucasmedina profile image
Lucas Medina

I like this approach. It's really interesting when dealing with state that's only used by a single component, but triggered from anywhere.
Maybe a good example would be status alerts, or UI notifications, since it's commonly triggered by various places but its data is only used by one component.

Collapse
 
panda_bc3691bedb3ab5e6 profile image
Panda张向北

Many thanks. This is a great idea

Collapse
 
businessdirectorysites profile image
Business Directory Sites

Great work!

Collapse
 
isocroft profile image
Okechukwu Ifeora

Hey there, Great article!

Would you be open to checking this out: npmjs.com/package/react-busser?

It is a package based on event-driven data communication using a hook useBus(). I built it for the purposes you have listed in your wonderful article. It has some building-block hooks for lists (arrays), composites (object literal) and properties (primitive types).

It also has other helper hooks too that are great for UI development.

Let me know what you thick

Collapse
 
nicolalc profile image
Nicola

Thanks dude, first of all, amazing job here!

There are soo many things that it will require quite some time in order to analyze everything, so don't be mad if I will take some time to review it.

Anything you would like me to check in particular? For instance code reviewing, logic reviewing or anything else?

Keep working on this!

Collapse
 
isocroft profile image
Okechukwu Ifeora

Thanks a lot. It's okay if you take time to review it.

The more, the better i'd say.

Yes, i would love that you help me with some logic reviewing specifically on 2 codesandbox code snippets that use the package (react-busser).

This first one is an example/sample of how the package (react-busser)) works without the need to pass too many props just like in your article.

It's a simple todo app and the way it works is depicted in this image.

The concept of how the events work is what is known as (i coined this) CASCADE BROADCAST. There is a source (component) for the events implemented as a source hook and there is a target (component) for the events implemented as a target hook.

The source and target hooks send and receive events respectively.

You can play around with the simple todo app in the preview section of the codesandbox link above.

The real logic issue comes with this second codesandbox. whose link is below this line.

This is a simple e-commerce page that also uses the package (react-busser) but there's a bug when i try to filter the list of product items with the search bar.

The functionality for adding a product to the cart works well.

I would like a logic review on what you think might be the issue.

Thanks a lot for this.

Collapse
 
nick_fe8c88c99b72333303a5 profile image
Nick

.localservice@runtime.c8c182a3.js:26
Warning: Cannot update a component (Sidebar) while rendering a different component (Body). To locate the bad setState() call inside Body, follow the stack trace as described in reactjs.org/link/setstate-in-render
at Body (eventdrivearch-oc1y--5173--c8c182a...)
at div
at App

Collapse
 
dsaga profile image
Dusan Petkovic

I mix of the 2 would also be a good solution, I actually use an event driven approach along with normal event callbacks, so that I can easily trigger global events like notifications, errors and easily capture them, also to decouple certain components and allow them to communicate and handle logic without needing to prop drill event handles that don't belong.

Collapse
 
dsaga profile image
Dusan Petkovic • Edited

in essence you have one EventProvider that you wrap your app with, then have a context that passes methods for triggering, subscribing, unsubscribing, and have a custom hook for it called
useEvent();

const { subscribe, trigger } = useEvent();


trigger("post:published",arg1,arg2); 


useEffect(() => {

const handler = (arg1,arg2) => {
  console.log("do something here");
}

subscribe("post:published",handler);
return () => unsubscribe("post:published",handler)

},[subscribe])

Enter fullscreen mode Exit fullscreen mode
Collapse
 
jrock2004 profile image
John Costanzo

A thing to consider here is if someone has your app opened in multiple tabs. I could be mistaken but that window event would reach both tabs

Collapse
 
nicolalc profile image
Nicola

Thanks John, indeed I need to check that,I would prefer to keep tabs independent from each others but I need to check how does this works in that case. Anyway, the solution to that should be simple by just triggering the events within the document context instead of the window, but smth to look at

Collapse
 
chandragie profile image
chandragie

But, isn't that a good thing? 🤔 If I open a same page in two tabs (purposely or not), then if I clicked on some action (say, add to cart), I would also want the cart being updated in the idle tab. Am I imagining it wrong?

Collapse
 
am4nn profile image
Aman Arya

Not exactly. The problem arises when actions from both tabs start interfering with each other. For example, if you're editing a document in two tabs and save different changes in both, merging them could lead to conflicts or data loss. It’s better to keep actions isolated per tab to maintain consistency and avoid unexpected behavior.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.

👋 Kindness is contagious

Immerse yourself in a wealth of knowledge with this piece, supported by the inclusive DEV Community—every developer, no matter where they are in their journey, is invited to contribute to our collective wisdom.

A simple “thank you” goes a long way—express your gratitude below in the comments!

Gathering insights enriches our journey on DEV and fortifies our community ties. Did you find this article valuable? Taking a moment to thank the author can have a significant impact.

Okay