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.
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
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
) => {
...
};
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
) => {
...
};
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 };
};
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'sdetail
(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
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 anonNotification
event with the notification details. Components like theNotificationBanner
andHeader
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 customonThemeChange
event. - Components like the Sidebar and Header listen for this event and update their styles accordingly.
- A
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.
- A global keydown listener dispatches an
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
oronDataUpdate
events when new data arrives. - Components such as a chat window, notifications, and unread message counters can independently handle updates.
- A WebSocket connection dispatches
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.
- A form component dispatches
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.
- Dispatch
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.
- Dispatch
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)
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.
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 foruseEvent
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.even if it wasn't just that, the event system doesn't really scale well for shared state.
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.
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.
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.
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: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):
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.
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.
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.
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
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
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
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.
Many thanks. This is a great idea
Great work!
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
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!
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.
.localservice@runtime.c8c182a3.js:26
Warning: Cannot update a component (
Sidebar
) while rendering a different component (Body
). To locate the bad setState() call insideBody
, follow the stack trace as described in reactjs.org/link/setstate-in-renderat Body (eventdrivearch-oc1y--5173--c8c182a...)
at div
at App
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.
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();
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
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
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?
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.