Custom Event Types: Building Event Systems
React's built-in event system (onClick, onChange) covers most UI interactions, but for application-wide events (like "user logged in" or "cart updated"), you need custom events. A custom event system lets components communicate without direct prop passing or routing everything through global state. In TypeScript, you type custom events by defining an event map (which events exist and what data they carry) and creating a generic emitter that enforces type safety. This prevents bugs where one component emits an event and another listens for a misspelled event name or wrong data type.
How Do You Create a Typed Event Emitter in TypeScript?
A typed event emitter is a class or hook that maintains listeners for custom events. You define an event map as a record type where keys are event names and values are the event data types. The emitter's on() method registers a listener, and emit() triggers the event, both type-checked against the map.
// Create a typed event emitter
interface EventMap {
'user:login': { userId: number; username: string };
'user:logout': undefined;
'cart:update': { itemCount: number; total: number };
'notification': { message: string; severity: 'info' | 'warning' | 'error' };
}
class TypedEventEmitter {
private listeners: Map<string, Set<Function>> = new Map();
on<K extends keyof EventMap>(
event: K,
handler: (data: EventMap[K]) => void
): void {
if (!this.listeners.has(event as string)) {
this.listeners.set(event as string, new Set());
}
this.listeners.get(event as string)?.add(handler);
}
off<K extends keyof EventMap>(
event: K,
handler: (data: EventMap[K]) => void
): void {
this.listeners.get(event as string)?.delete(handler);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
this.listeners.get(event as string)?.forEach(handler => {
(handler as Function)(data);
});
}
}
// Create a singleton instance
export const eventEmitter = new TypedEventEmitter();
The <K extends keyof EventMap> constraint ensures the event name is one of the defined events. If you try to emit an event that doesn't exist in the map, TypeScript errors. The handler parameter type is EventMap[K], so TypeScript ensures the handler receives the correct data type.
Using the Typed Event Emitter in Components
With the emitter defined, components can emit and listen to custom events with full type safety:
import { useEffect } from 'react';
// Component that emits user login event
export const LoginButton = () => {
const handleClick = () => {
// TypeScript ensures 'user:login' is a valid event and data matches the type
eventEmitter.emit('user:login', {
userId: 123,
username: 'alice',
});
};
return <button onClick={handleClick}>Log In</button>;
};
// Component that listens for user login event
export const WelcomeMessage = () => {
useEffect(() => {
const handleLogin = (data: EventMap['user:login']) => {
console.log(`Welcome, ${data.username}!`);
};
eventEmitter.on('user:login', handleLogin);
return () => {
eventEmitter.off('user:login', handleLogin);
};
}, []);
return <div>Welcome</div>;
};
// Component that listens for logout
export const LogoutButton = () => {
useEffect(() => {
const handleLogout = (data: EventMap['user:logout']) => {
// data is undefined for this event
console.log('User logged out');
};
eventEmitter.on('user:logout', handleLogout);
return () => {
eventEmitter.off('user:logout', handleLogout);
};
}, []);
const handleClick = () => {
eventEmitter.emit('user:logout', undefined);
};
return <button onClick={handleClick}>Log Out</button>;
};
When you call eventEmitter.on('user:login', handler), TypeScript knows the handler receives an object with userId and username. If you try to emit user:login with different data, TypeScript errors immediately.
Creating a React Hook for Event Emitters
For easier use in React components, wrap the emitter in a custom hook:
import { useEffect, useCallback } from 'react';
function useEventListener<K extends keyof EventMap>(
event: K,
handler: (data: EventMap[K]) => void
): void {
const memoizedHandler = useCallback(handler, [handler]);
useEffect(() => {
eventEmitter.on(event, memoizedHandler);
return () => {
eventEmitter.off(event, memoizedHandler);
};
}, [event, memoizedHandler]);
}
function useEventEmitter<K extends keyof EventMap>(
event: K
): (data: EventMap[K]) => void {
return useCallback(
(data: EventMap[K]) => {
eventEmitter.emit(event, data);
},
[event]
);
}
// Use the hooks in components
export const UserCard = () => {
useEventListener('user:login', (data) => {
// TypeScript knows data has userId and username
console.log(`User ${data.username} logged in`);
});
return <div>User Card</div>;
};
export const NotificationCenter = () => {
useEventListener('notification', (data) => {
// TypeScript knows data has message and severity
console.log(`[${data.severity}] ${data.message}`);
});
return <div>Notifications</div>;
};
export const CartWidget = () => {
const emitCartUpdate = useEventEmitter('cart:update');
const handleAddToCart = () => {
emitCartUpdate({ itemCount: 5, total: 99.99 });
};
return <button onClick={handleAddToCart}>Add to Cart</button>;
};
The hooks hide the emitter implementation, making components cleaner and more testable.
Type-Safe Event Filtering and Transformation
Sometimes you want to listen to multiple related events or transform event data before using it:
// Listen to multiple events of different types
export const MultiEventListener = () => {
useEffect(() => {
const handleLogin = (data: EventMap['user:login']) => {
console.log('Login event:', data.username);
};
const handleLogout = () => {
console.log('Logout event');
};
eventEmitter.on('user:login', handleLogin);
eventEmitter.on('user:logout', handleLogout);
return () => {
eventEmitter.off('user:login', handleLogin);
eventEmitter.off('user:logout', handleLogout);
};
}, []);
return <div>Listening to events</div>;
};
// Transform event data before using it
export const NotificationWithTransform = () => {
useEventListener('notification', (data) => {
// TypeScript ensures data is a notification event
const emoji = data.severity === 'error' ? '❌' : data.severity === 'warning' ? '⚠️' : 'ℹ️';
const message = `${emoji} ${data.message}`;
console.log(message);
});
return <div>Notifications</div>;
};
By listening to specific events and transforming their data, you keep components decoupled while maintaining type safety.
Key Takeaways
- Define an event map as a record type mapping event names to data types.
- Use generics (
<K extends keyof EventMap>) to ensure event names and data types are type-checked. - Wrap the emitter in custom hooks for easier use in React components.
- Custom events enable communication between distant components without drilling props or managing global state for everything.
- Always clean up listeners in
useEffectreturn functions to prevent memory leaks.
Frequently Asked Questions
Should I use custom events instead of context or Redux?
Custom events are good for application-wide broadcasts (like "user logged in"). For data that many components read and write, use Context or Redux. Use events for one-off notifications and asynchronous side effects.
How do I handle event errors if a listener throws?
Wrap listener calls in try/catch within the emit method. Silently swallow errors or log them, so one failing listener doesn't break others.
Can I emit the same event multiple times with different data?
Yes, every call to emit triggers all listeners. If you want listeners to react to data changes, emit the same event with new data.
How do I prevent memory leaks with event listeners?
Always clean up in useEffect return functions. The hook example above does this. If you forget, listeners accumulate and cause memory leaks.
Is TypedEventEmitter more performant than Context or Redux?
Events are very fast for simple broadcasts. However, they don't provide global state management or time-travel debugging. Use them for events, not state.