JavaScript and React course Β· Module 15: Patterns and Architecture
Event-Driven Architecture in React - Loose communication
In this lesson8
The engine panel changes speed, and the display on the bridge and the ship's log, which sit in other branches of the component tree, both need to find out about it. Props passed down through five levels quickly turn the code into a tangle of cables. On a spaceship, systems do not know each other: the engine, the shields and navigation communicate through a central event system. This is exactly Event-Driven Architecture: a pattern where components communicate through events instead of direct dependencies.
What is Event-Driven Architecture?
Event-Driven Architecture (EDA) is based on:
- Emitting events: a component announces that something happened
- Listening to events: other components react to these announcements
- Loose coupling: the sender does not know who is listening, and the receiver does not know who is emitting
This is the classic Observer pattern, also called publish/subscribe (pub/sub): the sender publishes, the subscribers react.
EventBus - central event bus
The heart of the system is the EventBus, a relay. It stores a map in which every event name has a set of functions waiting for it. The on method registers a listener and immediately returns a function that will unregister it:
1// Simple EventBus with named events
2class EventBus {
3 constructor() {
4 this.listeners = new Map();
5 }
6
7 on(event, callback) {
8 if (!this.listeners.has(event)) {
9 this.listeners.set(event, new Set());
10 }
11 this.listeners.get(event).add(callback);
12
13 // Return cleanup function
14 return () => {
15 this.listeners.get(event)?.delete(callback);
16 };
17 }A Set ensures the same function is not registered twice. Two methods remain: emit calls all the listeners of a given event, and off removes one manually. At the end we create a single instance for the whole application:
1 emit(event, payload) {
2 if (this.listeners.has(event)) {
3 this.listeners.get(event).forEach(cb => cb(payload));
4 }
5 }
6
7 off(event, callback) {
8 this.listeners.get(event)?.delete(callback);
9 }
10}
11
12// Singleton - one bus for the entire application
13const eventBus = new EventBus();Names like engine:speedChanged are just a convention: the prefix says which module is broadcasting, and the rest says what happened. The EventBus itself is a plain JavaScript class, with no React involved.
Custom hooks for event handling
A component should not have to remember about subscribing and unsubscribing on its own. We hide that in the useEventListener hook, which registers the listener after mounting and removes it on unmounting:
1// Hook for listening to events
2function useEventListener(event, handler) {
3 const savedHandler = useRef(handler);
4
5 useEffect(() => {
6 savedHandler.current = handler;
7 }, [handler]);
8
9 useEffect(() => {
10 const listener = (payload) => savedHandler.current(payload);
11 const unsubscribe = eventBus.on(event, listener);
12 return unsubscribe; // Cleanup on unmount
13 }, [event]);
14}The clever detail is useRef: the hook always calls the latest version of handler, but renews the subscription only when the event name changes. In React 19.2 the built-in useEffectEvent hook solves the same problem. Broadcasting is simpler, because it needs no cleanup:
1// Hook for emitting events
2function useEventEmitter() {
3 return useCallback((event, payload) => {
4 eventBus.emit(event, payload);
5 }, []);
6}So the life cycle of a listener looks like this: the component mounts, useEffect registers it through eventBus.on(), every emit() calls it, and on unmounting the cleanup function removes it from the bus.
Practical example: Ship alert system
Time for real modules. The engine panel is the sender (publisher): after every click it emits two events, a speed change and a log entry:
1// Engine component - emits events
2function EnginePanel() {
3 const emit = useEventEmitter();
4
5 const setSpeed = (speed) => {
6 emit('engine:speedChanged', { speed, timestamp: Date.now() });
7 emit('ship:log', { source: 'Engine', message: `Speed: ${speed}` });
8 };
9
10 return (
11 <div className="panel">
12 <h3>Engine</h3>
13 <button onClick={() => setSpeed('MAX')}>MAX</button>
14 <button onClick={() => setSpeed('CRUISE')}>CRUISE</button>
15 <button onClick={() => setSpeed('STOP')}>STOP</button>
16 </div>
17 );
18}The speed display is a receiver (subscriber). It listens to just one event and stores the speed it receives in its own state:
1// Display component - listens for events
2function SpeedDisplay() {
3 const [speed, setSpeed] = useState('IDLE');
4
5 useEventListener('engine:speedChanged', (data) => {
6 setSpeed(data.speed);
7 });
8
9 return <div className="display">Speed: {speed}</div>;
10}The ship's log listens to all ship:log entries, no matter which module sent them, and keeps the ten most recent ones:
1// Log component - listens to all logs
2let nextEntryId = 0;
3
4function ShipLog() {
5 const [entries, setEntries] = useState([]);
6
7 useEventListener('ship:log', (data) => {
8 const entry = { ...data, id: nextEntryId++, time: new Date().toLocaleTimeString() };
9 setEntries(prev => [entry, ...prev].slice(0, 10));
10 });
11
12 return (
13 <ul className="log">
14 {entries.map(entry => (
15 <li key={entry.id}>[{entry.time}] {entry.source}: {entry.message}</li>
16 ))}
17 </ul>
18 );
19}Every entry gets its own id from a counter, because new entries land at the top of the list, and an index-based key would shift with every event. Notice: EnginePanel does not import SpeedDisplay or ShipLog. The components are completely independent. You can remove ShipLog from the tree and EnginePanel keeps working without changes. When you add shield controls, they only need to emit ship:log as well, and the log will show their entries.
Native EventTarget API
Instead of your own EventBus, you can use the native EventTarget API built into the browser. The event data travels in the detail field of a CustomEvent object:
1// EventTarget - built into the browser
2const shipEvents = new EventTarget();
3
4function useNativeEventListener(eventName, handler) {
5 useEffect(() => {
6 const listener = (e) => handler(e.detail);
7 shipEvents.addEventListener(eventName, listener);
8 return () => shipEvents.removeEventListener(eventName, listener);
9 }, [eventName, handler]);
10}
11
12function emitNativeEvent(eventName, detail) {
13 shipEvents.dispatchEvent(new CustomEvent(eventName, { detail }));
14}The effect is the same, and you do not have to write the class. Here, however, handler is among the effect's dependencies, so a new function on every render means registering the listener again. That works correctly, but it is better to pass a stable function or use the useRef trick.
Advanced EventBus with middleware
You can extend the EventBus with middleware, functions that intercept events before delivery. Each one receives the name and the data, and returns the data onward or null to stop the event:
1class AdvancedEventBus extends EventBus {
2 constructor() {
3 super();
4 this.middleware = [];
5 }
6
7 use(middlewareFn) {
8 this.middleware.push(middlewareFn);
9 }
10
11 emit(event, payload) {
12 // Pass through middleware
13 let finalPayload = payload;
14 for (const mw of this.middleware) {
15 finalPayload = mw(event, finalPayload);
16 if (finalPayload === null) return; // Middleware blocked the event
17 }
18 super.emit(event, finalPayload);
19 }
20}The class does nothing until we create an instance and attach the functions. The first one logs every event, the second asks for confirmation before full power:
1const advancedBus = new AdvancedEventBus();
2
3// Middleware: logging
4advancedBus.use((event, payload) => {
5 console.log(`[Event] ${event}`, payload);
6 return payload;
7});
8
9// Middleware: validation
10advancedBus.use((event, payload) => {
11 if (event === 'engine:speedChanged' && payload.speed === 'MAX') {
12 if (!window.confirm('Confirm full power?')) return null; // Block!
13 }
14 return payload;
15});Middleware runs in the order it was added, and when validation returns null, the event does not reach any listener. You will find the same idea in Redux and Express.
EDA vs Context API vs Props
| Feature | Props | Context API | Event-Driven |
|---|---|---|---|
| Direction | Parent -> Child | Ancestor -> Descendants | Any |
| Coupling | Tight | Medium | Loose |
| Re-renders | Direct | All consumers | Only subscribers |
| Cross-branch communication | Only through a common parent | Requires a common ancestor | Direct |
| Debuggability | Easy | Medium | Requires logging |
When a context value changes, React re-renders every component that reads it. With an EventBus, only the subscribers that change their own state in response re-render.
When to use Event-Driven Architecture?
- Communication between distant components: you do not want to pass props through 5 levels
- Notification/alert system: many sources generate events, one component displays them
- Logging and analytics: user events emitted once, listened to by many systems
- Real-time updates: data updated in real time (WebSocket -> EventBus -> components)
My advice: an EventBus is for announcing that something happened, not for storing data. Keep the state a screen has to show in React, in context or in a state library, where every change is easy to trace. EDA is the ship's central communication system, and the key to success is cleanup on unmounting, otherwise you get memory leaks, like a broken relay that keeps drawing power. In the next lesson we will organize the modules themselves with Atomic Design.
Remember: the sender announces what happened and does not care who is listening.
Code for this lesson: App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3// === EVENT BUS ===
4class EventBus {
5 constructor() { this.listeners = new Map(); }
6
7 on(event, callback) {
8 if (!this.listeners.has(event)) this.listeners.set(event, new Set());
9 this.listeners.get(event).add(callback);
10 return () => this.listeners.get(event)?.delete(callback);
11 }
12
13 emit(event, payload) {
14 if (this.listeners.has(event)) {
15 this.listeners.get(event).forEach(cb => cb(payload));
16 }
17 }
18}
19
20const eventBus = new EventBus();
21
22// === CUSTOM HOOKS ===
23function useEventListener(event, handler) {
24 const saved = useRef(handler);
25 useEffect(() => { saved.current = handler; }, [handler]);
26 useEffect(() => {
27 const listener = (p) => saved.current(p);
28 return eventBus.on(event, listener);
29 }, [event]);
30}
31
32function useEventEmitter() {
33 return useCallback((event, payload) => eventBus.emit(event, payload), []);
34}
35
36// === PUBLISHER: Engine Panel ===
37function EnginePanel() {
38 const emit = useEventEmitter();
39 const setSpeed = (speed) => {
40 emit('engine:speedChanged', { speed });
41 emit('ship:log', { source: 'Engine', message: 'Speed: ' + speed });
42 };
43 return (
44 <div className="panel publisher">
45 <h3>Engine Control</h3>
46 <span className="tag">Publisher</span>
47 <div className="btn-group">
48 <button className="btn btn-danger" onClick={() => setSpeed('MAX')}>MAX</button>
49 <button className="btn btn-primary" onClick={() => setSpeed('CRUISE')}>CRUISE</button>
50 <button className="btn btn-default" onClick={() => setSpeed('STOP')}>STOP</button>
51 </div>
52 </div>
53 );
54}
55
56// === PUBLISHER: Shield Panel ===
57function ShieldPanel() {
58 const emit = useEventEmitter();
59 const [level, setLevel] = useState(100);
60 const update = (v) => {
61 setLevel(v);
62 emit('shields:level', { level: v });
63 emit('ship:log', { source: 'Shields', message: v + '%' });
64 };
65 return (
66 <div className="panel publisher">
67 <h3>Shield Control</h3>
68 <span className="tag">Publisher</span>
69 <input type="range" min="0" max="100" value={level} onChange={e => update(Number(e.target.value))} />
70 <span className="value">{level}%</span>
71 </div>
72 );
73}
74
75// === SUBSCRIBER: Speed Display ===
76function SpeedDisplay() {
77 const [speed, setSpeed] = useState('IDLE');
78 useEventListener('engine:speedChanged', (data) => setSpeed(data.speed));
79 const colors = { MAX: '#f44336', CRUISE: '#4fc3f7', STOP: '#666', IDLE: '#999' };
80 return (
81 <div className="panel subscriber">
82 <h3>Speed</h3>
83 <span className="tag sub-tag">Subscriber</span>
84 <div className="big-value" style={{ color: colors[speed] }}>{speed}</div>
85 </div>
86 );
87}
88
89// === SUBSCRIBER: Shield Display ===
90function ShieldDisplay() {
91 const [level, setLevel] = useState(100);
92 useEventListener('shields:level', (data) => setLevel(data.level));
93 const color = level > 60 ? '#4caf50' : level > 30 ? '#ff9800' : '#f44336';
94 return (
95 <div className="panel subscriber">
96 <h3>Shields</h3>
97 <span className="tag sub-tag">Subscriber</span>
98 <div className="bar"><div className="bar-fill" style={{ width: level + '%', background: color }} /></div>
99 <span className="value">{level}%</span>
100 </div>
101 );
102}
103
104// === SUBSCRIBER: Ship Log ===
105let nextEntryId = 0;
106
107function ShipLog() {
108 const [entries, setEntries] = useState([]);
109 useEventListener('ship:log', (data) => {
110 const entry = { ...data, id: nextEntryId++, time: new Date().toLocaleTimeString() };
111 setEntries(prev => [entry, ...prev].slice(0, 10));
112 });
113 return (
114 <div className="panel subscriber log-panel">
115 <h3>Ship Log</h3>
116 <span className="tag sub-tag">Subscriber (all events)</span>
117 {entries.length === 0 ? <p className="empty">No events yet</p> : (
118 <ul>{entries.map(e => <li key={e.id}>[{e.time}] {e.source}: {e.message}</li>)}</ul>
119 )}
120 </div>
121 );
122}
123
124// === MAIN ===
125export default function App() {
126 return (
127 <div className="app">
128 <h1>Event-Driven Architecture</h1>
129 <p className="subtitle">Publishers emit events, Subscribers react independently</p>
130 <div className="grid">
131 <div className="col">
132 <EnginePanel />
133 <ShieldPanel />
134 </div>
135 <div className="col">
136 <SpeedDisplay />
137 <ShieldDisplay />
138 </div>
139 </div>
140 <ShipLog />
141 </div>
142 );
143}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the main purpose of Event-Driven Architecture in a React application?
2. Which characteristic DISTINGUISHES Event-Driven Architecture from Context API in React?
Hands-on tasks in the game
- Code editor
Finish the event bus and the hooks through which two independent components talk. ___BLANK1___: the function returned by on removes only this one callback from the listeners of the event. ___BLANK2___: emit calls every listener with the payload data. ___BLANK3___: in useEffect subscribe the listener to eventBus and return the unsubscribe function as the effect cleanup. ___BLANK4___: on a click EnginePanel emits the engine:speedChanged event with the object { speed }, and SpeedDisplay shows the received speed.
- Vertical ordering
Arrange the steps for implementing Event-Driven Architecture in React:
- Code editor
The EventBus and the hooks are ready, finish two publishers and two subscribers. ___BLANK1___: after the slider moves, ShieldControl emits shields:level with the new level as a number (next, not the old value from state). ___BLANK2___: on a click AlertSystem emits alert:status with the alert level (alert.level: green, yellow or red). ___BLANK3___: StatusDashboard listens to the event that changes the alert state. ___BLANK4___: ShipLog adds a new entry at the beginning of the list and keeps at most the 8 newest entries.
- Vertical ordering
Arrange the Event-Driven Architecture layers from lowest to highest: