Modern React developers often reach for state management libraries (Redux, Zustand, Recoil, etc.) by default to handle shared state. However, using these state managers everywhere can introduce unnecessary complexity and boilerplate into your apps. In many cases, an event-driven architecture can be a simpler, more flexible solution. In this article, we’ll explore the pitfalls of overusing state managers, discuss the event-driven alternative, introduce react-eventizer
as a powerful event bus for React, and walk through real-world examples with code. We’ll also cover best practices and when to choose events vs. state managers. Let’s dive in!
The Pitfalls of Overusing State Managers
State management libraries like Redux, Zustand, and Recoil are powerful tools for complex apps. They provide a centralized store and fancy features (time-travel debugging, middleware, etc.), but they come at a cost. Introducing Redux or similar libraries in every situation can be overkill for small to medium apps, adding unnecessary overhead. Redux in particular often requires a lot of boilerplate (actions, reducers, providers) which can make code harder to understand and maintain. Even “lighter” solutions like Zustand or Recoil, while simpler, still add an extra layer of abstraction that you might not need for straightforward cases.
Another common issue is prop drilling and tangled callback chains in deep component hierarchies. In a typical React app, data flows down through props and events bubble up through callback props. In a simple component tree this is fine, but as your app grows, passing props through multiple layers (just to get data to a distant component) and wiring callbacks back up becomes cumbersome. You end up with intermediate components that exist only to shuttle data around, making the code harder to follow and maintain. This tight coupling of components increases cognitive load and brittleness in your codebase.
Prop drilling forces passing props (green arrows) down many layers, and bubbling callbacks (red arrows) up through intermediaries, which can tangle your component architecture.
To avoid prop drilling hell, many developers instinctively turn to a global state solution (Context API, Zustand, Redux, etc.) so components can share data without explicit prop passing ([Event-Driven Architecture for Clean React Component Communication - DEV Community]. While this can solve one problem, overusing global state introduces new complexity – you might be maintaining a giant store or context for things that could be handled more simply. So is there a middle ground? Event-driven architecture offers a compelling alternative for many scenarios.
The Event-Driven Alternative: Loosely Coupled Components
Event-driven architecture in React allows components to communicate through a publish/subscribe (pub-sub) pattern rather than via shared state or direct parent-child links. In essence, components can emit events (publish) and listen for events (subscribe) using a central event bus, without needing to know about each other’s existence. This decouples your components, making the app architecture more modular and easier to maintain.
In an event-driven approach, when something happens in one component, it can broadcast an event into a central channel. Any other component interested in that event can react to it, regardless of where it sits in the component tree. This means no more drilling props down or lifting state up just to get two distant components talking – the event bus handles that communication in a transparent, global way.
Event-driven communication: sub-components dispatch events to a central events handler (blue arrows), which notifies any listening components. This eliminates the need to thread callbacks up through every intermediate parent.
Because components remain unaware of who is listening or emitting, this pattern promotes loose coupling. Your components become more self-contained: they just announce what happened (e.g. “item X was deleted”) or respond to announcements (“someone deleted an item, I should update my list”) without tight integrations. The result is often simpler code flow – especially for cross-cutting concerns like global notifications, logging, or syncing data – and potentially fewer lines of code than an equivalent Redux setup. In fact, an event bus can bring clarity to a large codebase by centralizing how events are handled.
Meet react-eventizer
: An Event Bus for React
So how can we implement an event-driven architecture in React? react-eventizer
is a lightweight library that provides exactly this: a React-friendly event bus system. According to its documentation, react-eventizer
is a “lightweight, zero-dependency React event bus with full TypeScript support” that enables decoupled component communication using a pub/sub model. In simpler terms, it lets you set up a global event hub in your React app, with minimal code.
Key features of react-eventizer:
- 🔄 Simple Pub/Sub API: Provides easy methods or hooks to emit events and subscribe to them.
- 🔒 Type-Safe: Built with TypeScript generics, so you can define strict types for each event’s payload (catching mistakes at compile time).
- ⚛️ Seamless React Integration: Uses React Context under the hood and offers custom Hooks (
useSubscribe
,useEmitter
, etc.) to interface with the event bus anywhere in your component tree. - 🪶 Lightweight: Zero external dependencies and a minimal footprint. It won’t bloat your bundle or slow your app.
- 🧩 Decoupled Communication: Allows components to talk to each other without direct relationships, eliminating the need for prop drilling or overly complex state lifting.
In short, react-eventizer
gives you the benefits of an event bus pattern in a convenient React package.
Getting Started with react-eventizer
Using react-eventizer in your project is straightforward. You can install it via npm or yarn:
npm install react-eventizer
Then follow these general steps to set it up:
-
Define your events: For TypeScript users, define an EventMap interface listing all event names and their payload types. For example, you might have events like
'user:login'
,'notification:new'
,'theme:change'
, etc., each mapped to the shape of data it carries (or tovoid
if no payload). This step is optional but highly recommended for type safety. -
Create and provide the event bus: Initialize a new event bus instance (e.g.
const eventBus = new Eventizer<EventMap>()
) and make it available to your app by wrapping your app in an<EventizerProvider>
with the bus passed as a prop. Typically, you do this once at the root (e.g. inApp.tsx
). -
Subscribe to events: In any component that needs to react to a global event, use the
useSubscribe(eventName, callback)
hook provided byreact-eventizer
. This hook will register your callback to run whenever that event is emitted. (It also handles unsubscribing automatically on component unmount to prevent memory leaks). -
Emit events: Whenever a component needs to broadcast something, use the
useEmitter(eventName)
hook to get an emitter function, and call that function with the appropriate payload. All subscribers to that event will then be notified.
Let’s look at a quick example of react-eventizer
in action. Imagine we want to broadcast a user login event so various parts of the app can respond (maybe to display a welcome message, update a profile, fetch user-specific data, etc.). We’ll emit a "user:login"
event from a login form and subscribe to it in a user profile component:
/* LoginForm.tsx – emitter example */
import { useEmitter } from 'react-eventizer';
const LoginForm: React.FC = () => {
const [username, setUsername] = useState('');
const emitLogin = useEmitter('user:login'); // prepare an emitter for the 'user:login' event
const handleLogin = () => {
// Emit the user:login event with a payload when login happens
emitLogin({ username: username, id: 123 });
};
return (
<form onSubmit={e => { e.preventDefault(); handleLogin(); }}>
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="Username" />
<button type="submit">Login</button>
</form>
);
};
/* UserProfile.tsx – subscriber example */
import { useSubscribe } from 'react-eventizer';
const UserProfile: React.FC = () => {
const [username, setUsername] = useState<string>('');
// Subscribe to 'user:login' events
useSubscribe('user:login', payload => {
setUsername(payload.username);
});
return <div>{username ? `Welcome, ${username}!` : 'Please log in'}</div>;
};
In this example, the LoginForm doesn’t need to directly inform UserProfile through context or shared state. It simply emits a "user:login"
event when the form is submitted. The UserProfile component, anywhere in the app, subscribes to "user:login"
and updates its local state when that event occurs. The two components remain completely decoupled – they only share an event contract. If no component cared about "user:login"
, the event would just go into the void with no issues. If multiple components subscribe to it, all of them will receive the event. This pub-sub mechanism can dramatically simplify scenarios where many components need to respond to the same event.
Real-World Use Cases Made Easier with Events
What kinds of scenarios benefit most from an event-driven approach? Let’s explore a few common use cases where react-eventizer
can make React development easier, along with how you might implement them.
Cross-Component Communication (No More Prop Drilling)
Problem: You have components that need to talk to each other but are not directly related in the JSX hierarchy. For instance, a deeply nested component needs to send info to a top-level layout or sibling component far away. Traditionally, you might lift state up to a common ancestor or use context – or you suffer prop drilling through many layers.
How react-eventizer
helps: Use a global event instead! Any component can emit an event that any other component can listen for, without threading props. This achieves instant cross-component communication with zero prop drilling.
For example, suppose you have a list of items and a separate notifications area. When an item is deleted in the list, you want to show a notification banner. Without an event bus, you might have to pass a callback from the notifications system down into the item component, or use a global state flag. With react-eventizer
, it’s trivial:
// In a deeply nested component (e.g. DeleteButton inside an Item component)
const emitDelete = useEmitter('item:delete');
<button onClick={() => emitDelete(itemId)}>Delete Item</button>
// ... Meanwhile, in a totally different part of the app ...
useSubscribe('item:delete', (deletedItemId) => {
notify(`Item ${deletedItemId} was deleted successfully.`);
});
Here the Delete button simply emits an "item:delete"
event carrying an item ID. A Notification component (or any component interested in deletions) subscribes to "item:delete"
and, when it hears one, triggers a user notification. Neither component needs to know about the other – they only share the event name and data schema. This loose coupling means you can add or remove listeners freely without breaking functionality, and you don’t have to route that event through parent components. It makes your code more modular and your component tree cleaner.
Global Notifications and Alerts
Problem: You want to trigger notification messages or alerts from anywhere in the app (deep in the bowels of your code), but have them all display in a common UI component (like a toast container or alert stack). Without events, you might lift all notification state to a top-level store or use context to expose functions for adding notifications.
How react-eventizer
helps: Treat notifications as global events. Any part of the app can emit a "notification:new"
event with the message and type of notification. A single NotificationCenter
component can subscribe to that event and handle displaying all incoming notifications.
For example, using react-eventizer
you could do something like:
// Emit a notification event after some action (e.g., after saving data)
const emitNotification = useEmitter('notification:new');
...
emitNotification({ type: 'success', message: 'Data saved successfully!' });
// NotificationCenter component listens for any new notifications
useSubscribe('notification:new', (notif) => {
// push the new notification into local state (to render it)
setNotifications(prevList => [...prevList, notif]);
});
Every time an event is emitted (perhaps from anywhere: after API calls, user actions, errors caught, etc.), the NotificationCenter
will pick it up and update its list of notifications. This way, components don’t have to directly call the notification system or import it; they just fire an event and forget. The notification handling is centralized, which is easier to manage and avoids duplicating notification logic across components.
Global UI State Changes (Themes, Modals, etc.)
Problem: Some UI state changes need to affect multiple independent components. For instance, changing the app’s theme should update many components, or closing a modal might need to inform various parts of the UI.
How react-eventizer
helps: For one-off triggers like theme changes or modal toggles, an event can be more convenient than storing this state globally. You can emit a "theme:change"
event or "modal:close"
event that any component can respond to.
-
Theme switching: Instead of putting the current theme in a context and making every component consume that context, you could simply have a central theme store (or even just local storage) and emit
"theme:change"
events. Components that care (like those that style themselves differently or a<ThemeProvider/>
) subscribe to apply the new theme. This can reduce continuous re-renders that a context might cause, since an event will only trigger subscribers once when it’s emitted. -
Modal control: If a deeply nested component triggers a modal to open, and some other component needs to know when that modal closes, you can emit
"modal:close"
upon closure. Any component can listen for"modal:close"
to, say, refresh data or update the URL, without tightly coupling to the modal logic.
The event-driven approach essentially broadcasts UI intents (like “theme updated” or “modal closed”) in a way that any interested piece of UI can act on, without maintaining that intent as persistent state if it’s not needed beyond the moment of the change.
Handling Asynchronous or External Events
Problem: Your app receives asynchronous data or messages (from WebSockets, server-sent events, etc.) that multiple components may need to respond to. Managing this via state can get tricky, especially if not all components mount at the same time or if the data isn’t something you want to store globally.
How react-eventizer
helps: You can funnel external messages into events. For instance, imagine you have a WebSocket connection that receives real-time updates. With react-eventizer
, your WebSocket service can emit events whenever a message arrives:
// Pseudocode for a WebSocket message handler
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
eventBus.emit('socket:message', data);
};
// In any component that needs live updates
useSubscribe('socket:message', (data) => {
// handle the incoming data (update component state, etc.)
console.log("Received live update:", data);
});
By doing this, you decouple the WebSocket logic from your React components entirely. Components just declare an interest in 'socket:message'
events and react when they occur. If the WebSocket implementation changes or moves, the components don’t care – as long as events with that name are emitted. Similarly, you can handle other external or async events (like a countdown finishing, a media query change, etc.) in this pub-sub way.
Best Practices for Event-Driven Architecture in React
Using react-eventizer
(or any event bus) effectively requires a bit of forethought. Here are some best practices to keep your event-driven React architecture clean and maintain:
-
Define event types centrally: Declare all your event names and payload structures in one place (e.g. an
events.ts
file with the EventMap interface). This makes it easier to manage and document what events exist in the system, and ensures publishers and subscribers use the correct event names. -
Use namespaces for event names: Adopting a naming convention like
"category:action"
(for example,user:login
,cart:add
,socket:message
) helps avoid name collisions and keeps events organized. It’s easier to identify the purpose of an event at a glance, and you won’t accidentally have two different subsystems using generic names like"update"
that conflict. -
Clean up if needed: If you use the
useSubscribe
hook, it will automatically unsubscribe when the component unmounts. But if you ever subscribe manually via the event bus’son()
method, be sure to unsubscribe (off()
) when appropriate to prevent memory leaks or unwanted behavior. Keeping subscription lifecycles tied to component lifecycles is usually safest. - Don’t abuse events: While events are great for decoupling, avoid emitting events willy-nilly or in very tight loops. Use events for higher-level communication, not as a replacement for every function call or state update. Too many global events can make it hard to trace what's happening. Also, avoid using events to store long-term state – remember that an event is a moment in time, not a state container.
By following these practices, you can ensure that your event-driven approach remains predictable and scalable as your application grows.
When to Use Events vs. When to Use a State Manager
It’s important to strike a balance. Event-driven architecture can simplify many aspects of a React app, but it’s not a silver bullet for all state management needs. Here’s a balanced perspective on when to choose each approach:
Use an Event Bus (like
react-eventizer
) for:
Decoupled interactions and notifications. If your goal is to have parts of the app react to something happening elsewhere (user actions, global UI triggers, background processes) without maintaining a lot of shared state, events are ideal. They shine for ephemeral, momentary communications – things that happen, other parts respond, and that’s it. This includes the use cases we discussed: cross-component messages, broadcast notifications, one-off UI updates, and integrating external event streams. Events keep these interactions simple and avoid the overhead of global state for transient data.
Reducing coupling. Use events when you want to avoid forcing a direct link or dependency between components or modules. This can make your code more modular and testable (you can test components by faking events, for example).Use a State Manager (Redux, Zustand, Recoil, Context API) for:
Persistent and global state. If you have application-wide state that many components need to read and update over time (such as user authentication info, a large form data structure, or caching of fetched data), a state store or context might be more appropriate. These tools are designed to hold onto data and make it accessible anywhere, whereas an event bus by itself doesn’t store data – it just transmits it.
Complex state logic and debugging. When your state has complex transitions, strict requirements, or benefits from time-travel debugging and traceability (e.g. using Redux DevTools), a structured state manager is beneficial. Redux, for instance, can record every action and state change, which is great for debugging complex apps. Events are more fire-and-forget, which can be harder to debug if overused for critical state changes (though you can certainly log them).
Forms and synchronized state. Some scenarios (like multi-step forms or any feature where many parts of the UI must reflect the exact same state consistently) might still be easier with a centralized state or context that components read from directly, rather than emitting events to each other to stay in sync.
In practice, you don’t have to choose one or the other exclusively. You can mix and match: use an event bus for what it’s good at and a state store for what it’s good at. For example, you might use React Context or Zustand to hold the current user and theme (since those are needed as state by many components at all times), but use react-eventizer
to handle things like “user logged in” or “theme changed” as events to trigger animations, fetches, or notifications across the app. In fact, the combination can be powerful: the event can carry the new state, and subscribers can update their own state or even a context based on it.
Conclusion
“Stop using state managers everywhere” doesn’t mean state libraries are bad – it means you should use them judiciously. Global state managers like Redux, Recoil, and Zustand are fantastic for certain use cases, but not every problem is a nail requiring that hammer. Many times, an event-driven approach provides a simpler and more elegant solution for component communication without the ceremony of global stores. By using react-eventizer
or a similar event bus, you can make your React apps more modular, reduce prop drilling, and simplify how components interact.
The key is to choose the right tool for the job: if you find yourself adding a lot of complexity just to get components talking, consider using events to decouple and simplify. On the other hand, if you truly need a single source of truth for state and the ability to inspect or revert it, a state manager might be the way to go. Often, a hybrid approach yields the best of both worlds.
By understanding both paradigms, you can architect React applications that are simpler, more efficient, and easier to maintain. So next time you’re about to pull in a heavy state management library for a simple interaction, ask yourself: “Can an event solve this?” You might be surprised how often the answer is yes, and how much cleaner your code can be as a result.
Top comments (2)
very well described
🔥🔥🔥