7.1 KiB
React + EventDispatcher: The Golden Pattern
TL;DR: Always use
useEventListenerhook. Never use.on()directly in React.
Quick Start
import { useEventListener } from '@noodl-hooks/useEventListener';
import { ProjectModel } from '@noodl-models/projectmodel';
function MyComponent() {
// Subscribe to events - it just works
useEventListener(ProjectModel.instance, 'componentRenamed', (data) => {
console.log('Component renamed:', data);
});
return <div>...</div>;
}
The Problem
EventDispatcher uses a context-object pattern for cleanup:
// How EventDispatcher works internally
model.on('event', callback, contextObject); // Subscribe
model.off(contextObject); // Unsubscribe by context
React's closure-based lifecycle is incompatible with this:
// ❌ This compiles, runs without errors, but SILENTLY FAILS
useEffect(() => {
const context = {};
ProjectModel.instance.on('event', handler, context);
return () => ProjectModel.instance.off(context); // Context reference doesn't match!
}, []);
The event is never received. No errors. Complete silence. Hours of debugging.
The Solution
The useEventListener hook handles all the complexity:
// ✅ This actually works
useEventListener(ProjectModel.instance, 'event', handler);
Internally, the hook:
- Uses
useRefto maintain a stable callback reference - Creates a unique group object per subscription
- Properly cleans up on unmount
- Updates the callback without re-subscribing
API Reference
Basic Usage
useEventListener(dispatcher, eventName, callback);
| Parameter | Type | Description |
|---|---|---|
dispatcher |
IEventEmitter | null |
The EventDispatcher instance |
eventName |
string | string[] |
Event name(s) to subscribe to |
callback |
(data?, eventName?) => void |
Handler function |
With Multiple Events
useEventListener(
ProjectModel.instance,
['componentAdded', 'componentRemoved', 'componentRenamed'],
(data, eventName) => {
console.log(`${eventName}:`, data);
}
);
With Dependencies
Re-subscribe when dependencies change:
const [filter, setFilter] = useState('all');
useEventListener(
ProjectModel.instance,
'componentAdded',
(data) => {
// Uses current filter value
if (matchesFilter(data, filter)) {
// ...
}
},
[filter] // Re-subscribe when filter changes
);
Conditional Subscription
Pass null to disable:
useEventListener(isEnabled ? ProjectModel.instance : null, 'event', handler);
Common Patterns
Pattern 1: Trigger Re-render on Changes
function useProjectData() {
const [updateCounter, setUpdateCounter] = useState(0);
useEventListener(ProjectModel.instance, ['componentAdded', 'componentRemoved', 'componentRenamed'], () =>
setUpdateCounter((c) => c + 1)
);
// Data recomputes when updateCounter changes
const data = useMemo(() => {
return computeFromProject(ProjectModel.instance);
}, [updateCounter]);
return data;
}
Pattern 2: Sync State with Model
function WarningsPanel() {
const [warnings, setWarnings] = useState([]);
useEventListener(WarningsModel.instance, 'warningsChanged', () => {
setWarnings(WarningsModel.instance.getWarnings());
});
return <WarningsList warnings={warnings} />;
}
Pattern 3: Side Effects
function AutoSaver() {
useEventListener(
ProjectModel.instance,
'settingsChanged',
debounce(() => {
ProjectModel.instance.save();
}, 1000)
);
return null;
}
Available Dispatchers
| Instance | Common Events |
|---|---|
ProjectModel.instance |
componentAdded, componentRemoved, componentRenamed, rootNodeChanged, settingsChanged |
NodeLibrary.instance |
libraryUpdated, moduleRegistered, moduleUnregistered |
WarningsModel.instance |
warningsChanged |
UndoQueue.instance |
undoHistoryChanged |
EventDispatcher.instance |
Model.*, viewer-refresh, ProjectModel.instanceHasChanged |
Debugging
Verify Events Are Received
useEventListener(ProjectModel.instance, 'componentRenamed', (data) => {
console.log('🔔 Event received:', data); // Should appear in console
// ... your handler
});
If Events Aren't Received
- Check event name: Spelling matters. Use the exact string.
- Check dispatcher instance: Is it
null? Is it the right singleton? - Check webpack cache: Run
npm run clean:alland restart - Check if component mounted: Add a console.log in the component body
Verify Cleanup
Watch for this error (indicates cleanup failed):
Warning: Can't perform a React state update on an unmounted component
If you see it, the cleanup isn't working. Check that you're using useEventListener, not manual .on()/.off().
Anti-Patterns
❌ Direct .on() in useEffect
// BROKEN - Will compile but events never received
useEffect(() => {
ProjectModel.instance.on('event', handler, {});
return () => ProjectModel.instance.off({});
}, []);
❌ Manual forceRefresh Callbacks
// WORKS but creates tech debt
const forceRefresh = useCallback(() => setCounter((c) => c + 1), []);
performAction(data, forceRefresh); // Must thread through everywhere
❌ Class Component Style
// DOESN'T WORK in functional components
this.model.on('event', this.handleEvent, this);
Migration Guide
Converting existing broken code:
Before
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
const listener = {};
ProjectModel.instance.on('componentRenamed', (d) => setData(d), listener);
return () => ProjectModel.instance.off(listener);
}, []);
return <div>{data}</div>;
}
After
import { useEventListener } from '@noodl-hooks/useEventListener';
function MyComponent() {
const [data, setData] = useState(null);
useEventListener(ProjectModel.instance, 'componentRenamed', setData);
return <div>{data}</div>;
}
History
- Discovered: 2025-12-22 during TASK-004B (ComponentsPanel React Migration)
- Investigated: TASK-008 (EventDispatcher React Investigation)
- Verified: TASK-010 (EventListener Verification)
- Documented: TASK-011 (This document)
The root cause is a fundamental incompatibility between EventDispatcher's context-object cleanup pattern and React's closure-based lifecycle. The useEventListener hook bridges this gap.