Scalable Interaction Architecture for Complex UIs
Modern web applications often feature complex user interfaces with hundreds or thousands of interactive elements. Managing event listeners for each individual element can quickly become a performance nightmare and maintenance burden. This is where event delegation and custom events shine as fundamental patterns for building scalable interaction architectures.
Event delegation leverages the DOM’s natural event bubbling mechanism to handle events at a higher level in the DOM tree, while custom events provide a clean way to create decoupled, reusable components that communicate through well-defined interfaces. Together, these patterns form the backbone of maintainable, performant web applications.
In this article, we’ll explore how to implement these patterns effectively, examine real-world use cases, and build a comprehensive understanding of why they’re essential for complex UI development.
Understanding Event Delegation
Event delegation is a technique that takes advantage of event bubbling to handle events for multiple elements using a single event listener attached to a common ancestor. Instead of attaching individual listeners to each element, we listen for events at a higher level and determine which child element triggered the event.
Basic Event Delegation Implementation
Let’s start with a simple example that demonstrates the core concept:
document.querySelectorAll('.button').forEach(button => {
button.addEventListener('click', handleButtonClick);
});
document.addEventListener('click', function(event) {
if (event.target.matches('.button')) {
handleButtonClick(event);
}
});
function handleButtonClick(event) {
console.log('Button clicked:', event.target.textContent);
}
The delegated approach uses only one event listener regardless of how many buttons exist on the page. When any button is clicked, the event bubbles up to the document level where our single listener catches it and checks if the target matches our selector.
Advanced Delegation with Action Mapping
For more complex scenarios, we can create a sophisticated delegation system that maps different actions to specific handlers:
class EventDelegator {
constructor(container) {
this.container = container;
this.actions = new Map();
this.init();
}
init() {
this.container.addEventListener("click", this.handleClick.bind(this));
this.container.addEventListener("change", this.handleChange.bind(this));
}
register(selector, eventType, handler) {
const key = `${eventType}:${selector}`;
if (!this.actions.has(key)) {
this.actions.set(key, []);
}
this.actions.get(key).push(handler);
}
handleClick(event) {
this.executeHandlers("click", event);
}
handleChange(event) {
this.executeHandlers("change", event);
}
executeHandlers(eventType, event) {
this.actions.forEach((handlers, key) => {
const [type, selector] = key.split(":");
if (type === eventType && event.target.matches(selector)) {
handlers.forEach((handler) => handler(event));
}
});
}
}
const delegator = new EventDelegator(document.body);
delegator.register(".delete-btn", "click", (event) => {
console.log("Delete button clicked");
// Handle deletion logic
});
delegator.register(".edit-btn", "click", (event) => {
console.log("Edit button clicked");
// Handle edit logic
});
delegator.register(".status-checkbox", "change", (event) => {
console.log("Status changed:", event.target.checked);
// Handle status change
});
<div>
<button class=”delete-btn”>Delete button</button>
<button class=”edit-btn”>Edit button</button>
<input type=”checkbox” class=”status-checkbox”>Status button</input>
</div>
This advanced delegator creates a scalable system where you can register multiple handlers for different element types and events. The system automatically routes events to the appropriate handlers based on CSS selectors, making it easy to manage complex interactions without cluttering the DOM with individual listeners.
You can see the code here.
Custom Events.
Building Decoupled Communication
Custom events provide a powerful mechanism for creating loosely coupled components that can communicate without direct references to each other. This pattern is essential for building maintainable, reusable UI components.
Creating and Dispatching Custom Events
Here’s how to create and use custom events effectively:
// Custom event creation and dispatch
function createNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `notification notification--${type}`;
notification.textContent = message;
// Dispatch custom event with data
const customEvent = new CustomEvent('notification:created', {
bubbles: true,
detail: {
element: notification,
message: message,
type: type,
timestamp: Date.now()
}
});
document.dispatchEvent(customEvent);
return notification;
}
// Listen for custom events
document.addEventListener('notification:created', function(event) {
const { element, message, type, timestamp } = event.detail;
console.log(`Notification created: ${message} (${type}) at ${new Date(timestamp)}`);
// Add to notification container
const container = document.querySelector('.notification-container');
if (container) {
container.appendChild(element);
// Auto-remove after 5 seconds
setTimeout(() => {
element.remove();
// Dispatch removal event
document.dispatchEvent(new CustomEvent('notification:removed', {
bubbles: true,
detail: { message, type }
}));
}, 5000);
}
});
// Usage
createNotification('User saved successfully', 'success');
createNotification('Error occurred', 'error');
This example demonstrates how custom events create a clean separation between the notification creation logic and the display logic. The createNotification function only needs to know how to create the notification and dispatch the event; it doesn’t need to know about DOM manipulation or removal logic. This makes the code more modular and testable.
You can found the code here.
Advanced Custom Event Patterns
Let’s build a more sophisticated example that shows how custom events can orchestrate complex UI interactions:
class ShoppingCart {
constructor() {
this.items = [];
this.container = document.querySelector(".shopping-cart");
this.init();
}
init() {
// Listen for cart-related events
document.addEventListener("cart:add", this.handleAddItem.bind(this));
document.addEventListener("cart:remove", this.handleRemoveItem.bind(this));
document.addEventListener(
"cart:update",
this.handleUpdateQuantity.bind(this),
);
document.addEventListener("cart:clear", this.handleClearCart.bind(this));
}
handleAddItem(event) {
const { product, quantity = 1 } = event.detail;
const existingItem = this.items.find((item) => item.id === product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({ ...product, quantity });
}
this.render();
this.dispatchCartEvent("cart:updated", { items: this.items });
}
handleRemoveItem(event) {
const { productId } = event.detail;
this.items = this.items.filter((item) => item.id !== productId);
this.render();
this.dispatchCartEvent("cart:updated", { items: this.items });
}
handleUpdateQuantity(event) {
const { productId, quantity } = event.detail;
const item = this.items.find((item) => item.id === productId);
if (item) {
if (quantity <= 0) {
this.handleRemoveItem({ detail: { productId } });
} else {
item.quantity = quantity;
this.render();
this.dispatchCartEvent("cart:updated", { items: this.items });
}
}
}
handleClearCart() {
this.items = [];
this.render();
this.dispatchCartEvent("cart:cleared", {
previousItemCount: this.items.length,
});
}
dispatchCartEvent(eventName, data) {
document.dispatchEvent(
new CustomEvent(eventName, {
bubbles: true,
detail: {
...data,
total: this.getTotal(),
itemCount: this.items.length,
},
}),
);
}
getTotal() {
return this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
}
render() {
if (!this.container) return;
this.container.innerHTML = this.items
.map(
(item) => `
<div class="cart-item" data-product-id="${item.id}">
<span class="item-name">${item.name}</span>
<span class="item-price">$${item.price}</span>
<input type="number" class="item-quantity" value="${item.quantity}" min="1">
<button class="remove-item" data-product-id="${item.id}">Remove</button>
</div>
`,
)
.join("");
}
}
// Cart UI Controller
class CartUIController {
constructor() {
this.init();
}
init() {
// Listen for UI events using delegation
document.addEventListener("click", this.handleClick.bind(this));
document.addEventListener("change", this.handleChange.bind(this));
// Listen for cart events to update UI
document.addEventListener(
"cart:updated",
this.handleCartUpdated.bind(this),
);
document.addEventListener(
"cart:cleared",
this.handleCartCleared.bind(this),
);
}
handleClick(event) {
if (event.target.matches(".add-to-cart")) {
const productData = JSON.parse(event.target.dataset.product);
document.dispatchEvent(
new CustomEvent("cart:add", {
detail: { product: productData },
}),
);
}
if (event.target.matches(".remove-item")) {
const productId = event.target.dataset.productId;
document.dispatchEvent(
new CustomEvent("cart:remove", {
detail: { productId },
}),
);
}
if (event.target.matches(".clear-cart")) {
document.dispatchEvent(new CustomEvent("cart:clear"));
}
}
handleChange(event) {
if (event.target.matches(".item-quantity")) {
const productId = event.target.closest(".cart-item").dataset.productId;
const quantity = parseInt(event.target.value);
document.dispatchEvent(
new CustomEvent("cart:update", {
detail: { productId, quantity },
}),
);
}
}
handleCartUpdated(event) {
const { itemCount, total } = event.detail;
// Update cart badge
const badge = document.querySelector(".cart-badge");
if (badge) {
badge.textContent = itemCount;
badge.style.display = itemCount > 0 ? "inline" : "none";
}
// Update total display
const totalElement = document.querySelector(".cart-total");
if (totalElement) {
totalElement.textContent = `Total: $${total.toFixed(2)}`;
}
}
handleCartCleared(event) {
console.log("Cart cleared");
// Show notification
document.dispatchEvent(
new CustomEvent("notification:created", {
detail: {
message: "Cart cleared successfully",
type: "info",
},
}),
);
}
}
// Initialize the system
const cart = new ShoppingCart();
const cartUI = new CartUIController();
This comprehensive example shows how custom events create a robust, decoupled architecture. The ShoppingCart class handles business logic and data management, while the CartUIController manages UI interactions. They communicate entirely through custom events, making each component independently testable and reusable. When a product is added to the cart, the event flows through the system automatically updating the cart display, badge counter, and total price without any direct coupling between components.
You can found the code here.
Combining Delegation and Custom Events
The real power emerges when we combine event delegation with custom events to create truly scalable architectures:
class InteractiveTable {
constructor(container) {
this.container = container;
this.data = [];
this.init();
}
init() {
// Use delegation for all table interactions
this.container.addEventListener('click', this.handleClick.bind(this));
this.container.addEventListener('change', this.handleChange.bind(this));
// Listen for data events
document.addEventListener('table:sort', this.handleSort.bind(this));
document.addEventListener('table:filter', this.handleFilter.bind(this));
document.addEventListener('table:refresh', this.handleRefresh.bind(this));
}
handleClick(event) {
// Sort by column header
if (event.target.matches('.sortable-header')) {
const column = event.target.dataset.column;
const direction = event.target.dataset.sortDirection === 'asc' ? 'desc' : 'asc';
document.dispatchEvent(new CustomEvent('table:sort', {
detail: { column, direction }
}));
}
// Row selection
if (event.target.matches('.row-selector')) {
const rowId = event.target.closest('tr').dataset.rowId;
const isSelected = event.target.checked;
document.dispatchEvent(new CustomEvent('table:row-selected', {
detail: { rowId, isSelected, selectedRows: this.getSelectedRows() }
}));
}
// Action buttons
if (event.target.matches('.action-btn')) {
const action = event.target.dataset.action;
const rowId = event.target.closest('tr').dataset.rowId;
document.dispatchEvent(new CustomEvent(`table:${action}`, {
detail: { rowId, rowData: this.getRowData(rowId) }
}));
}
}
handleChange(event) {
if (event.target.matches('.filter-input')) {
const column = event.target.dataset.column;
const value = event.target.value;
document.dispatchEvent(new CustomEvent('table:filter', {
detail: { column, value }
}));
}
}
handleSort(event) {
const { column, direction } = event.detail;
this.data.sort((a, b) => {
const aVal = a[column];
const bVal = b[column];
const modifier = direction === 'asc' ? 1 : -1;
if (aVal < bVal) return -1 * modifier;
if (aVal > bVal) return 1 * modifier;
return 0;
});
this.render();
this.updateSortIndicators(column, direction);
}
handleFilter(event) {
const { column, value } = event.detail;
if (!value) {
this.render(this.originalData);
} else {
const filtered = this.originalData.filter(row =>
row[column].toString().toLowerCase().includes(value.toLowerCase())
);
this.render(filtered);
}
}
updateSortIndicators(column, direction) {
// Clear all sort indicators
this.container.querySelectorAll('.sortable-header').forEach(header => {
header.removeAttribute('data-sort-direction');
header.classList.remove('sorted-asc', 'sorted-desc');
});
// Set current sort indicator
const currentHeader = this.container.querySelector(`[data-column="${column}"]`);
if (currentHeader) {
currentHeader.dataset.sortDirection = direction;
currentHeader.classList.add(`sorted-${direction}`);
}
}
getSelectedRows() {
return Array.from(this.container.querySelectorAll('.row-selector:checked'))
.map(checkbox => checkbox.closest('tr').dataset.rowId);
}
getRowData(rowId) {
return this.data.find(row => row.id === rowId);
}
render(data = this.data) {
// Render table with current data
const tbody = this.container.querySelector('tbody');
if (tbody) {
tbody.innerHTML = data.map(row => this.renderRow(row)).join('');
}
}
renderRow(row) {
return `
<tr data-row-id="${row.id}">
<td><input type="checkbox" class="row-selector"></td>
<td>${row.name}</td>
<td>${row.email}</td>
<td>${row.status}</td>
<td>
<button class="action-btn" data-action="edit">Edit</button>
<button class="action-btn" data-action="delete">Delete</button>
</td>
</tr>
`;
}
}
// Initialize table
const table = new InteractiveTable(document.querySelector('.data-table'));
// External components can listen to table events
document.addEventListener('table:edit', function(event) {
const { rowId, rowData } = event.detail;
console.log('Edit row:', rowData);
// Open edit modal
});
document.addEventListener('table:delete', function(event) {
const { rowId, rowData } = event.detail;
console.log('Delete row:', rowData);
// Show confirmation dialog
});
document.addEventListener('table:row-selected', function(event) {
const { selectedRows } = event.detail;
console.log(`${selectedRows.length} rows selected`);
// Update bulk action buttons
});
This table implementation demonstrates the power of combining both patterns. Event delegation handles all user interactions with a minimal number of listeners, while custom events provide a clean API for external components to interact with the table. The table can handle thousands of rows efficiently because it uses delegation, and other parts of the application can respond to table events without being tightly coupled to the table implementation.
Performance Benefits and Best Practices
Memory Management
Event delegation significantly reduces memory usage by minimizing the number of event listeners:
// Performance comparison example
class PerformanceDemo {
// Inefficient: Individual listeners (avoid this)
attachIndividualListeners() {
const buttons = document.querySelectorAll('.item-button');
buttons.forEach(button => {
button.addEventListener('click', this.handleClick.bind(this));
});
console.log(`Attached ${buttons.length} individual listeners`);
}
// Efficient: Single delegated listener
attachDelegatedListener() {
document.addEventListener('click', (event) => {
if (event.target.matches('.item-button')) {
this.handleClick(event);
}
});
console.log('Attached 1 delegated listener');
}
handleClick(event) {
console.log('Button clicked:', event.target.textContent);
}
}
With 1000 buttons, the individual approach creates 1000 event listeners in memory, while delegation uses only 1. This becomes critical in large applications where memory efficiency directly impacts performance.
Best Practices Implementation
class BestPracticesExample {
constructor() {
this.eventCache = new WeakMap();
this.init();
}
init() {
// Use capture phase for better performance with deep DOM trees
document.addEventListener('click', this.handleClick.bind(this), true);
// Throttle expensive operations
document.addEventListener('scroll', this.throttle(this.handleScroll.bind(this), 16));
}
handleClick(event) {
// Use matches() for better browser compatibility
if (event.target.matches('.action-item')) {
// Cache expensive DOM queries
const container = this.getOrCacheElement(event.target, 'container', () =>
event.target.closest('.action-container')
);
this.processAction(event.target, container);
}
}
getOrCacheElement(element, key, getter) {
if (!this.eventCache.has(element)) {
this.eventCache.set(element, {});
}
const cache = this.eventCache.get(element);
if (!cache[key]) {
cache[key] = getter();
}
return cache[key];
}
throttle(func, delay) {
let timeoutId;
let lastExecTime = 0;
return function (...args) {
const currentTime = Date.now();
if (currentTime - lastExecTime > delay) {
func.apply(this, args);
lastExecTime = currentTime;
} else {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
lastExecTime = Date.now();
}, delay - (currentTime - lastExecTime));
}
};
}
processAction(target, container) {
// Dispatch custom event with rich context
container.dispatchEvent(new CustomEvent('action:performed', {
bubbles: true,
detail: {
action: target.dataset.action,
timestamp: Date.now(),
context: this.gatherContext(container)
}
}));
}
gatherContext(container) {
return {
containerId: container.id,
itemCount: container.querySelectorAll('.item').length,
scrollPosition: window.scrollY
};
}
}
This implementation showcases several performance optimizations: using capture phase for faster event handling, throttling expensive operations like scroll handlers, caching DOM queries to avoid repeated selections, and providing rich context in custom events to make them more useful for debugging and analytics.
Error Handling and Debugging
Robust error handling is crucial when working with event delegation and custom events:
class RobustEventSystem {
constructor() {
this.errorHandlers = new Map();
this.debugMode = localStorage.getItem('debug-events') === 'true';
this.init();
}
init() {
// Global error handling for event delegation
document.addEventListener('click', (event) => {
try {
this.handleClickSafely(event);
} catch (error) {
this.handleError('click-delegation', error, event);
}
});
// Global error handling for custom events
this.originalDispatchEvent = EventTarget.prototype.dispatchEvent;
EventTarget.prototype.dispatchEvent = (event) => {
try {
if (this.debugMode) {
console.log('Dispatching event:', event.type, event.detail);
}
return this.originalDispatchEvent.call(this, event);
} catch (error) {
this.handleError('custom-event', error, event);
return false;
}
};
}
handleClickSafely(event) {
const handlers = this.getHandlersForElement(event.target);
handlers.forEach(handler => {
try {
handler(event);
} catch (error) {
this.handleError('individual-handler', error, { event, handler });
}
});
}
handleError(context, error, data) {
const errorInfo = {
context,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
data: this.serializeEventData(data)
};
console.error('Event system error:', errorInfo);
// Notify error handlers
if (this.errorHandlers.has(context)) {
this.errorHandlers.get(context).forEach(handler => {
try {
handler(errorInfo);
} catch (handlerError) {
console.error('Error in error handler:', handlerError);
}
});
}
// Dispatch error event for global handling
document.dispatchEvent(new CustomEvent('system:error', {
detail: errorInfo
}));
}
serializeEventData(data) {
try {
return JSON.stringify(data, (key, value) => {
if (value instanceof Event) {
return {
type: value.type,
target: value.target?.tagName,
currentTarget: value.currentTarget?.tagName
};
}
if (value instanceof Element) {
return {
tagName: value.tagName,
id: value.id,
className: value.className
};
}
return value;
});
} catch (error) {
return 'Serialization failed';
}
}
onError(context, handler) {
if (!this.errorHandlers.has(context)) {
this.errorHandlers.set(context, []);
}
this.errorHandlers.get(context).push(handler);
}
}
// Usage
const eventSystem = new RobustEventSystem();
eventSystem.onError('click-delegation', (errorInfo) => {
// Send to analytics service
console.log('Logging error to analytics:', errorInfo);
});
This error handling system ensures that a single failing event handler doesn’t break the entire interaction system. It provides detailed error context, supports custom error handlers for different scenarios, and includes debugging capabilities. The system gracefully degrades when errors occur, maintaining application functionality.
Real-World Application: Component System
Here’s a comprehensive example that brings together all concepts in a realistic component architecture:
class ComponentSystem {
constructor() {
this.components = new Map();
this.middlewares = [];
this.init();
}
init() {
// Master event delegation
document.addEventListener('click', this.routeEvent.bind(this, 'click'));
document.addEventListener('change', this.routeEvent.bind(this, 'change'));
document.addEventListener('submit', this.routeEvent.bind(this, 'submit'));
// Component lifecycle events
document.addEventListener('component:mounted', this.handleComponentMounted.bind(this));
document.addEventListener('component:unmounted', this.handleComponentUnmounted.bind(this));
}
routeEvent(eventType, event) {
// Apply middlewares
const context = { event, eventType, timestamp: Date.now() };
for (const middleware of this.middlewares) {
if (middleware(context) === false) {
return; // Middleware cancelled the event
}
}
// Find component responsible for this element
const component = this.findComponentForElement(event.target);
if (component && component[`handle${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`]) {
component[`handle${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`](event);
}
}
findComponentForElement(element) {
let current = element;
while (current && current !== document) {
const componentId = current.dataset.component;
if (componentId && this.components.has(componentId)) {
return this.components.get(componentId);
}
current = current.parentElement;
}
return null;
}
registerComponent(id, component) {
this.components.set(id, component);
component.system = this;
// Notify component registration
document.dispatchEvent(new CustomEvent('component:registered', {
detail: { id, component }
}));
}
addMiddleware(middleware) {
this.middlewares.push(middleware);
}
}
// Base component class
class Component {
constructor(element) {
this.element = element;
this.id = this.generateId();
this.element.dataset.component = this.id;
this.state = {};
this.init();
}
init() {
// Override in subclasses
}
generateId() {
return `component_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
setState(newState) {
const prevState = { ...this.state };
this.state = { ...this.state, ...newState };
this.dispatchEvent('state:changed', {
prevState,
newState: this.state,
changes: newState
});
this.render();
}
dispatchEvent(eventName, detail = {}) {
this.element.dispatchEvent(new CustomEvent(eventName, {
bubbles: true,
detail: { ...detail, component: this, componentId: this.id }
}));
}
render() {
// Override in subclasses
}
destroy() {
this.dispatchEvent('component:unmounted');
delete this.element.dataset.component;
this.system?.components.delete(this.id);
}
}
// Example: Todo List Component
class TodoList extends Component {
init() {
this.state = {
todos: JSON.parse(localStorage.getItem('todos') || '[]'),
filter: 'all'
};
this.render();
this.dispatchEvent('component:mounted');
}
handleClick(event) {
if (event.target.matches('.todo-toggle')) {
const todoId = event.target.dataset.todoId;
this.toggleTodo(todoId);
}
if (event.target.matches('.todo-delete')) {
const todoId = event.target.dataset.todoId;
this.deleteTodo(todoId);
}
if (event.target.matches('.filter-btn')) {
const filter = event.target.dataset.filter;
this.setFilter(filter);
}
}
handleSubmit(event) {
if (event.target.matches('.todo-form')) {
event.preventDefault();
const input = event.target.querySelector('.todo-input');
if (input.value.trim()) {
this.addTodo(input.value.trim());
input.value = '';
}
}
}
addTodo(text) {
const todo = {
id: Date.now().toString(),
text,
completed: false,
createdAt: new Date().toISOString()
};
this.setState({
todos: [...this.state.todos, todo]
});
this.dispatchEvent('todo:added', { todo });
this.saveTodos();
}
toggleTodo(id) {
const todos = this.state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
this.setState({ todos });
const todo = todos.find(t => t.id === id);
this.dispatchEvent('todo:toggled', { todo });
this.saveTodos();
}
deleteTodo(id) {
const todo = this.state.todos.find(t => t.id === id);
const todos = this.state.todos.filter(t => t.id !== id);
this.setState({ todos });
this.dispatchEvent('todo:deleted', { todo });
this.saveTodos();
}
setFilter(filter) {
this.setState({ filter });
this.dispatchEvent('filter:changed', { filter });
}
saveTodos() {
localStorage.setItem('todos', JSON.stringify(this.state.todos));
}
getFilteredTodos() {
switch (this.state.filter) {
case 'active':
return this.state.todos.filter(todo => !todo.completed);
case 'completed':
return this.state.todos.filter(todo => todo.completed);
default:
return this.state.todos;
}
}
render() {
const filteredTodos = this.getFilteredTodos();
this.element.innerHTML = `
<div class="todo-app">
<form class="todo-form">
<input type="text" class="todo-input" placeholder="Add a new todo...">
<button type="submit">Add</button>
</form>
<div class="filter-buttons">
<button class="filter-btn ${this.state.filter === 'all' ? 'active' : ''}"
data-filter="all">All</button>
<button class="filter-btn ${this.state.filter === 'active' ? 'active' : ''}"
data-filter="active">Active</button>
<button class="filter-btn ${this.state.filter === 'completed' ? 'active' : ''}"
data-filter="completed">Completed</button>
</div>
<ul class="todo-list">
${filteredTodos.map(todo => `
<li class="todo-item ${todo.completed ? 'completed' : ''}">
<input type="checkbox" class="todo-toggle"
data-todo-id="${todo.id}"
${todo.completed ? 'checked' : ''}>
<span class="todo-text">${todo.text}</span>
<button class="todo-delete" data-todo-id="${todo.id}">×</button>
</li>
`).join('')}
</ul>
<div class="todo-stats">
Total: ${this.state.todos.length} |
Active: ${this.state.todos.filter(t => !t.completed).length} |
Completed: ${this.state.todos.filter(t => t.completed).length}
</div>
</div>
`;
}
}
// Initialize the system
const componentSystem = new ComponentSystem();
// Add logging middleware
componentSystem.addMiddleware((context) => {
console.log(`Event ${context.eventType} at ${context.timestamp}:`, context.event.target);
return true; // Continue processing
});
// Add analytics middleware
componentSystem.addMiddleware((context) => {
if (context.event.target.dataset.track) {
// Send to analytics
console.log('Analytics:', {
action: context.event.target.dataset.track,
timestamp: context.timestamp
});
}
return true;
});
// Create todo list component
const todoContainer = document.querySelector('#todo-container');
if (todoContainer) {
const todoList = new TodoList(todoContainer);
componentSystem.registerComponent(todoList.id, todoList);
}
// Global event listeners for cross-component communication
document.addEventListener('todo:added', function(event) {
const { todo } = event.detail;
console.log('New todo added:', todo.text);
// Show notification
document.dispatchEvent(new CustomEvent('notification:show', {
detail: {
message: `Added: ${todo.text}`,
type: 'success'
}
}));
});
document.addEventListener('todo:deleted', function(event) {
const { todo } = event.detail;
console.log('Todo deleted:', todo.text);
// Show notification with undo option
document.dispatchEvent(new CustomEvent('notification:show', {
detail: {
message: `Deleted: ${todo.text}`,
type: 'info',
actions: [{
label: 'Undo',
action: () => {
document.dispatchEvent(new CustomEvent('todo:restore', {
detail: { todo }
}));
}
}]
}
}));
});
This comprehensive component system demonstrates the full power of combining event delegation with custom events. The ComponentSystem class manages all components through a single set of delegated event listeners, automatically routing events to the appropriate component methods. Components communicate through custom events, creating a loosely coupled architecture where components can be developed, tested, and maintained independently. The middleware system allows for cross-cutting concerns like logging and analytics to be handled transparently.
you can found a complete example here.
Advanced Patterns and Optimization Techniques
Event Pooling for High-Performance Applications
For applications that generate thousands of events, implementing an event pooling system can significantly improve performance:
class EventPool {
constructor() {
this.pool = [];
this.maxPoolSize = 100;
this.activeEvents = new Set();
}
getEvent(type, detail = {}) {
let event;
if (this.pool.length > 0) {
event = this.pool.pop();
// Reset event properties
Object.defineProperty(event, 'type', { value: type, configurable: true });
Object.defineProperty(event, 'detail', { value: detail, configurable: true });
} else {
event = new CustomEvent(type, { bubbles: true, detail });
}
this.activeEvents.add(event);
return event;
}
releaseEvent(event) {
if (this.activeEvents.has(event) && this.pool.length < this.maxPoolSize) {
this.activeEvents.delete(event);
this.pool.push(event);
}
}
// Automatic cleanup after event dispatch
dispatchAndRelease(target, type, detail) {
const event = this.getEvent(type, detail);
target.dispatchEvent(event);
// Release after a short delay to allow all handlers to complete
setTimeout(() => this.releaseEvent(event), 0);
return event;
}
}
// High-performance event manager
class HighPerformanceEventManager {
constructor() {
this.eventPool = new EventPool();
this.batchedEvents = [];
this.batchTimeout = null;
this.requestIdleCallbackSupported = typeof requestIdleCallback !== 'undefined';
}
// Batch similar events to reduce DOM thrashing
batchDispatch(target, type, detail) {
this.batchedEvents.push({ target, type, detail });
if (this.batchTimeout) {
clearTimeout(this.batchTimeout);
}
this.batchTimeout = setTimeout(() => {
this.processBatchedEvents();
}, 16); // ~60fps
}
processBatchedEvents() {
const eventGroups = new Map();
// Group events by type and target
this.batchedEvents.forEach(({ target, type, detail }) => {
const key = `${type}:${target.id || 'unknown'}`;
if (!eventGroups.has(key)) {
eventGroups.set(key, { target, type, details: [] });
}
eventGroups.get(key).details.push(detail);
});
// Process batched events
eventGroups.forEach(({ target, type, details }) => {
if (details.length === 1) {
this.eventPool.dispatchAndRelease(target, type, details[0]);
} else {
// Combine multiple events into a batch event
this.eventPool.dispatchAndRelease(target, `${type}:batch`, {
items: details,
count: details.length
});
}
});
this.batchedEvents = [];
this.batchTimeout = null;
}
// Use requestIdleCallback for non-critical events
dispatchWhenIdle(target, type, detail) {
const dispatch = () => {
this.eventPool.dispatchAndRelease(target, type, detail);
};
if (this.requestIdleCallbackSupported) {
requestIdleCallback(dispatch, { timeout: 1000 });
} else {
setTimeout(dispatch, 0);
}
}
}
// Usage example
const performanceManager = new HighPerformanceEventManager();
// For rapid-fire events like mouse movements or scrolling
document.addEventListener('mousemove', (event) => {
performanceManager.batchDispatch(document, 'cursor:move', {
x: event.clientX,
y: event.clientY
});
});
// Handle batched cursor movements efficiently
document.addEventListener('cursor:move:batch', (event) => {
const { items, count } = event.detail;
const lastPosition = items[items.length - 1];
// Only process the final position to avoid unnecessary work
updateCursorIndicator(lastPosition.x, lastPosition.y);
console.log(`Processed ${count} cursor movements in batch`);
});
This event pooling system dramatically reduces garbage collection pressure in high-frequency event scenarios. By reusing event objects and batching similar events, the system can handle thousands of events per second without performance degradation. The batching mechanism prevents DOM thrashing by consolidating rapid-fire events into single, more meaningful updates.
Memory-Efficient Observer Pattern
For large-scale applications, implementing a memory-efficient observer pattern with automatic cleanup prevents memory leaks:
class WeakEventRegistry {
constructor() {
this.listeners = new WeakMap();
this.globalHandlers = new Map();
this.cleanupInterval = setInterval(() => this.cleanup(), 30000); // Cleanup every 30 seconds
}
addListener(element, eventType, handler, options = {}) {
if (!this.listeners.has(element)) {
this.listeners.set(element, new Map());
}
const elementListeners = this.listeners.get(element);
if (!elementListeners.has(eventType)) {
elementListeners.set(eventType, new Set());
}
const handlerInfo = {
handler,
options,
addedAt: Date.now(),
callCount: 0,
lastCalled: null
};
elementListeners.get(eventType).add(handlerInfo);
// Add actual DOM listener if this is the first for this event type
if (elementListeners.get(eventType).size === 1) {
const delegatedHandler = (event) => this.handleEvent(element, eventType, event);
element.addEventListener(eventType, delegatedHandler, options);
// Store reference to remove later
handlerInfo.delegatedHandler = delegatedHandler;
}
return handlerInfo;
}
removeListener(element, eventType, handlerInfo) {
if (!this.listeners.has(element)) return;
const elementListeners = this.listeners.get(element);
if (!elementListeners.has(eventType)) return;
const handlers = elementListeners.get(eventType);
handlers.delete(handlerInfo);
// Remove DOM listener if no more handlers
if (handlers.size === 0) {
element.removeEventListener(eventType, handlerInfo.delegatedHandler);
elementListeners.delete(eventType);
}
// Clean up element entry if no more event types
if (elementListeners.size === 0) {
this.listeners.delete(element);
}
}
handleEvent(element, eventType, event) {
const elementListeners = this.listeners.get(element);
if (!elementListeners || !elementListeners.has(eventType)) return;
const handlers = elementListeners.get(eventType);
const now = Date.now();
handlers.forEach(handlerInfo => {
try {
handlerInfo.handler(event);
handlerInfo.callCount++;
handlerInfo.lastCalled = now;
} catch (error) {
console.error('Event handler error:', error);
// Remove problematic handlers
if (handlerInfo.options.removeOnError) {
handlers.delete(handlerInfo);
}
}
});
}
// Automatic cleanup of unused handlers
cleanup() {
const now = Date.now();
const maxAge = 300000; // 5 minutes
this.listeners.forEach((elementListeners, element) => {
elementListeners.forEach((handlers, eventType) => {
const toRemove = [];
handlers.forEach(handlerInfo => {
// Remove handlers that haven't been called recently
if (handlerInfo.lastCalled && (now - handlerInfo.lastCalled) > maxAge) {
if (handlerInfo.options.autoCleanup !== false) {
toRemove.push(handlerInfo);
}
}
});
toRemove.forEach(handlerInfo => {
this.removeListener(element, eventType, handlerInfo);
});
});
});
}
getStats() {
let totalElements = 0;
let totalEventTypes = 0;
let totalHandlers = 0;
this.listeners.forEach((elementListeners) => {
totalElements++;
elementListeners.forEach((handlers) => {
totalEventTypes++;
totalHandlers += handlers.size;
});
});
return { totalElements, totalEventTypes, totalHandlers };
}
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.listeners = new WeakMap();
this.globalHandlers.clear();
}
}
// Smart event delegation with automatic resource management
class SmartEventDelegator {
constructor(container) {
this.container = container;
this.registry = new WeakEventRegistry();
this.selectorHandlers = new Map();
this.init();
}
init() {
// Single delegated listener for all events
this.masterHandler = (event) => this.routeEvent(event);
// Listen for common events
['click', 'change', 'input', 'submit', 'focus', 'blur'].forEach(eventType => {
this.container.addEventListener(eventType, this.masterHandler, true);
});
}
routeEvent(event) {
// Find matching selectors
this.selectorHandlers.forEach((handlers, selector) => {
if (event.target.matches(selector)) {
handlers.forEach(({ handler, eventType }) => {
if (event.type === eventType) {
try {
handler(event);
} catch (error) {
console.error(`Handler error for selector ${selector}:`, error);
}
}
});
}
});
}
on(selector, eventType, handler, options = {}) {
const key = selector;
if (!this.selectorHandlers.has(key)) {
this.selectorHandlers.set(key, []);
}
const handlerInfo = { handler, eventType, options, addedAt: Date.now() };
this.selectorHandlers.get(key).push(handlerInfo);
return {
remove: () => {
const handlers = this.selectorHandlers.get(key);
if (handlers) {
const index = handlers.indexOf(handlerInfo);
if (index > -1) {
handlers.splice(index, 1);
// Clean up empty entries
if (handlers.length === 0) {
this.selectorHandlers.delete(key);
}
}
}
}
};
}
emit(eventType, detail = {}, target = this.container) {
const event = new CustomEvent(eventType, {
bubbles: true,
cancelable: true,
detail
});
return target.dispatchEvent(event);
}
getStats() {
const registryStats = this.registry.getStats();
return {
...registryStats,
selectorHandlers: this.selectorHandlers.size,
totalSelectorListeners: Array.from(this.selectorHandlers.values())
.reduce((sum, handlers) => sum + handlers.length, 0)
};
}
}
This memory-efficient system uses WeakMap to ensure that event handlers don’t prevent garbage collection of DOM elements. The automatic cleanup mechanism removes unused handlers, preventing memory leaks in long-running applications. The smart delegator provides a simple API while managing resources efficiently behind the scenes.
Event delegation and custom events form the foundation of scalable interaction architectures in modern web applications. Throughout this article, we’ve explored how these patterns work together to create maintainable, performant, and flexible user interfaces.
Event delegation dramatically reduces memory overhead by leveraging the DOM’s natural event bubbling mechanism, allowing us to handle interactions for thousands of elements with minimal listeners. This approach not only improves performance but also simplifies dynamic content management, as new elements automatically inherit event handling without requiring additional setup.
Custom events provide the communication layer that enables truly decoupled architectures. By creating well-defined event interfaces, components can interact without tight coupling, making code more modular, testable, and reusable. The examples we’ve covered demonstrate how custom events can orchestrate complex user interactions while maintaining clean separation of concerns.
The advanced patterns we explored—including component systems, performance optimizations, memory management, and testing strategies—show how these fundamental concepts scale to enterprise-level applications. By combining event delegation with custom events, we can build sophisticated UI architectures that remain maintainable as they grow in complexity.
Key takeaways for implementing these patterns effectively include: starting with simple delegation before adding complexity, using custom events to create clear component APIs, implementing proper error handling and cleanup mechanisms, and establishing comprehensive testing strategies to ensure reliability.
As web applications continue to evolve in complexity and scale, mastering these event-driven patterns becomes increasingly crucial for senior developers. They provide the architectural foundation needed to build applications that are not only performant today but can adapt and scale with future requirements.
The investment in understanding and implementing these patterns pays dividends in reduced bugs, easier maintenance, improved performance, and more pleasant developer experience. Whether you’re building a simple interactive widget or a complex enterprise application, event delegation and custom events provide the scalable foundation your architecture needs to succeed.
(DOM)Event Delegation & CustomEvent was originally published in Client-side JavaScript CarlosRojasDev on Medium, where people are continuing the conversation by highlighting and responding to this story.