8.3 KiB
useEventListener Hook - Usage Guide
Overview
The useEventListener hook provides a React-friendly way to subscribe to EventDispatcher events. It solves the fundamental incompatibility between EventDispatcher's context-object-based cleanup and React's closure-based lifecycle.
Location
import { useEventListener } from '@noodl-hooks/useEventListener';
File: packages/noodl-editor/src/editor/src/hooks/useEventListener.ts
Basic Usage
Single Event
import { ProjectModel } from '@noodl-models/projectmodel';
import { useEventListener } from '../../../../hooks/useEventListener';
function MyComponent() {
const [updateCounter, setUpdateCounter] = useState(0);
// Subscribe to a single event
useEventListener(ProjectModel.instance, 'componentRenamed', () => {
setUpdateCounter((c) => c + 1);
});
return <div>Components updated {updateCounter} times</div>;
}
Multiple Events
// Subscribe to multiple events with one subscription
useEventListener(ProjectModel.instance, ['componentAdded', 'componentRemoved', 'componentRenamed'], () => {
console.log('Component changed');
setUpdateCounter((c) => c + 1);
});
With Event Data
interface RenameData {
component: ComponentModel;
oldName: string;
newName: string;
}
useEventListener<RenameData>(ProjectModel.instance, 'componentRenamed', (data) => {
console.log(`Renamed from ${data.oldName} to ${data.newName}`);
setUpdateCounter((c) => c + 1);
});
Advanced Usage
Conditional Subscription
Use the optional deps parameter to control when the subscription is active:
const [isActive, setIsActive] = useState(true);
useEventListener(
isActive ? ProjectModel.instance : null, // Pass null to disable
'componentRenamed',
() => {
setUpdateCounter((c) => c + 1);
}
);
With Dependencies
Re-subscribe when dependencies change:
const [filter, setFilter] = useState('all');
useEventListener(
ProjectModel.instance,
'componentAdded',
(data) => {
// Callback uses current filter value
if (shouldShowComponent(data.component, filter)) {
addToList(data.component);
}
},
[filter] // Re-subscribe when filter changes
);
Multiple Dispatchers
function MyComponent() {
// Subscribe to ProjectModel events
useEventListener(ProjectModel.instance, 'componentRenamed', handleProjectUpdate);
// Subscribe to WarningsModel events
useEventListener(WarningsModel.instance, 'warningsChanged', handleWarningsUpdate);
// Subscribe to EventDispatcher singleton
useEventListener(EventDispatcher.instance, 'viewer-refresh', handleViewerRefresh);
}
Common Patterns
Pattern 1: Trigger Re-render on Model Changes
function useComponentsPanel() {
const [updateCounter, setUpdateCounter] = useState(0);
// Re-render whenever components change
useEventListener(ProjectModel.instance, ['componentAdded', 'componentRemoved', 'componentRenamed'], () =>
setUpdateCounter((c) => c + 1)
);
// This will re-compute whenever updateCounter changes
const treeData = useMemo(() => {
return buildTreeFromProject(ProjectModel.instance);
}, [updateCounter]);
return { treeData };
}
Pattern 2: Update Local State from Events
function WarningsPanel() {
const [warnings, setWarnings] = useState([]);
useEventListener(WarningsModel.instance, 'warningsChanged', () => {
setWarnings(WarningsModel.instance.getWarnings());
});
return (
<div>
{warnings.map((warning) => (
<WarningItem key={warning.id} warning={warning} />
))}
</div>
);
}
Pattern 3: Side Effects on Events
function AutoSaver() {
const saveTimeoutRef = useRef<NodeJS.Timeout>();
useEventListener(ProjectModel.instance, ['componentAdded', 'componentRemoved', 'componentRenamed'], () => {
// Debounce saves
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
ProjectModel.instance.save();
}, 1000);
});
return null;
}
Migration from Manual Subscriptions
Before (Broken)
❌ // This doesn't work!
useEffect(() => {
const listener = { handleUpdate };
ProjectModel.instance.on('componentRenamed', () => handleUpdate(), listener);
return () => ProjectModel.instance.off(listener);
}, []);
After (Working)
✅ // This works perfectly!
useEventListener(ProjectModel.instance, 'componentRenamed', () => {
handleUpdate();
});
Before (Workaround)
❌ // Manual callback workaround
const [updateCounter, setUpdateCounter] = useState(0);
const forceRefresh = useCallback(() => {
setUpdateCounter((c) => c + 1);
}, []);
const performAction = (data, onSuccess) => {
// ... do action ...
if (onSuccess) onSuccess(); // Manual refresh
};
// In component:
performAction(data, () => forceRefresh());
After (Clean)
✅ // Automatic event handling
const [updateCounter, setUpdateCounter] = useState(0);
useEventListener(ProjectModel.instance, 'actionCompleted', () => {
setUpdateCounter((c) => c + 1);
});
const performAction = (data) => {
// ... do action ...
// Event fires automatically, no callbacks needed!
};
Type Safety
The hook is fully typed and works with TypeScript:
interface ComponentData {
component: ComponentModel;
oldName?: string;
newName?: string;
}
// Type the event data
useEventListener<ComponentData>(ProjectModel.instance, 'componentRenamed', (data) => {
// data is typed as ComponentData | undefined
if (data) {
console.log(data.component.name); // ✅ TypeScript knows this is safe
}
});
Supported Dispatchers
The hook works with any object that implements the IEventEmitter interface:
- ✅
EventDispatcher(andEventDispatcher.instance) - ✅
Modelsubclasses (ProjectModel, WarningsModel, etc.) - ✅ Any class with
on(event, listener, group)andoff(group)methods
Best Practices
✅ DO:
- Use
useEventListenerfor all EventDispatcher subscriptions in React components - Pass
nullas dispatcher if you want to conditionally disable subscriptions - Use the optional
depsarray when your callback depends on props/state - Type your event data with the generic parameter for better IDE support
❌ DON'T:
- Don't try to use manual
on()/off()subscriptions in React - they won't work - Don't forget to handle
nulldispatchers if using conditional subscriptions - Don't create new objects in the deps array - they'll cause infinite re-subscriptions
- Don't call
setStatedirectly inside event handlers without checking if component is mounted
Troubleshooting
Events Not Firing
Problem: Event subscription seems to work, but callback never fires.
Solution: Make sure you're using useEventListener instead of manual on()/off() calls.
Stale Closure Issues
Problem: Callback uses old values of props/state.
Solution: The hook already handles this with useRef. If you still see issues, add dependencies to the deps array.
Memory Leaks
Problem: Component unmounts but subscriptions remain.
Solution: The hook handles cleanup automatically. Make sure you're not holding references to the callback elsewhere.
TypeScript Errors
Problem: "Type X is not assignable to EventDispatcher"
Solution: The hook accepts any IEventEmitter. Your model might need to properly extend EventDispatcher or Model.
Examples in Codebase
See these files for real-world usage examples:
packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts- (More examples as other components are migrated)
Future Improvements
Potential enhancements for the future:
- Selective Re-rendering: Only re-render when specific event data changes
- Event Filtering: Built-in support for conditional event handling
- Debouncing: Optional built-in debouncing for high-frequency events
- Event History: Debug mode that tracks all received events
Related Documentation
- TASK-008 README - Investigation overview
- CHANGELOG - Implementation details
- NOTES - Discovery process
- LEARNINGS.md - Lessons learned