Event-Driven Architecture
Event-Driven Architecture (EDA) is a design pattern where components communicate by emitting and listening for events rather than directly calling each other. This creates loose coupling between components, as event emitters don't need to know who (if anyone) is listening to their events.
Core Concepts
1. Events
An event represents something that has happened. Events carry information about what occurred but don't dictate what should happen in response.
// Native DOM events
button.addEventListener('click', (event) => {
console.log('Button clicked');
console.log('Mouse position:', event.clientX, event.clientY);
});
input.addEventListener('input', (event) => {
console.log('Input changed:', event.target.value);
});
// Custom events
const customEvent = new CustomEvent('user-login', {
detail: {
username: 'alice',
timestamp: Date.now()
},
bubbles: true, // Event bubbles up through DOM
cancelable: true // Can be cancelled
});
element.dispatchEvent(customEvent); 2. Event Emitters (Publishers)
Components that emit events when something significant happens. They don't know or care who is listening.
class ShoppingCart extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.items = [];
}
addItem(item) {
this.items.push(item);
// Emit event - don't know or care who listens
this.dispatchEvent(new CustomEvent('item-added', {
detail: {
item,
totalItems: this.items.length,
totalPrice: this.calculateTotal()
},
bubbles: true
}));
this.render();
}
removeItem(itemId) {
const item = this.items.find(i => i.id === itemId);
this.items = this.items.filter(i => i.id !== itemId);
this.dispatchEvent(new CustomEvent('item-removed', {
detail: {
item,
totalItems: this.items.length,
totalPrice: this.calculateTotal()
},
bubbles: true
}));
this.render();
}
checkout() {
const order = {
items: [...this.items],
total: this.calculateTotal(),
timestamp: Date.now()
};
this.dispatchEvent(new CustomEvent('checkout', {
detail: order,
bubbles: true
}));
}
calculateTotal() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
render() {
// Render cart UI
}
}
customElements.define('shopping-cart', ShoppingCart); 3. Event Listeners (Subscribers)
Components that listen for events and react accordingly. Multiple listeners can respond to the same event in different ways.
// Multiple listeners for the same event
const cart = document.querySelector('shopping-cart');
// Listener 1: Update cart badge
cart.addEventListener('item-added', (event) => {
const badge = document.querySelector('.cart-badge');
badge.textContent = event.detail.totalItems;
});
// Listener 2: Show notification
cart.addEventListener('item-added', (event) => {
showNotification(`Added ${event.detail.item.name} to cart`);
});
// Listener 3: Track analytics
cart.addEventListener('item-added', (event) => {
analytics.track('cart_item_added', {
productId: event.detail.item.id,
price: event.detail.item.price
});
});
// Listener 4: Update recommendation engine
cart.addEventListener('item-added', (event) => {
recommendationEngine.updateBasedOnItem(event.detail.item);
}); Event Bubbling and Delegation
Events bubble up through the DOM tree, allowing parent elements to listen for events from their children. This enables event delegation - handling many elements with a single listener.
// Without delegation - inefficient
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', handleDelete);
});
// With delegation - efficient
document.querySelector('.product-list').addEventListener('click', (event) => {
// Check if clicked element matches our target
if (event.target.matches('.delete-btn')) {
const productId = event.target.dataset.productId;
handleDelete(productId);
}
if (event.target.matches('.edit-btn')) {
const productId = event.target.dataset.productId;
handleEdit(productId);
}
if (event.target.matches('.add-to-cart-btn')) {
const productId = event.target.dataset.productId;
handleAddToCart(productId);
}
});
// Benefits:
// 1. Works for dynamically added elements
// 2. Single listener instead of hundreds
// 3. Better memory usage
// 4. Easier to manage Event Bus Pattern
An Event Bus is a centralized event system that allows components to communicate without direct references to each other. Components publish events to the bus and subscribe to events from the bus.
// Simple Event Bus implementation
class EventBus {
constructor() {
this.listeners = new Map();
}
// Subscribe to an event
on(eventName, callback) {
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, []);
}
this.listeners.get(eventName).push(callback);
// Return unsubscribe function
return () => this.off(eventName, callback);
}
// Unsubscribe from an event
off(eventName, callback) {
if (!this.listeners.has(eventName)) return;
const callbacks = this.listeners.get(eventName);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
// Publish an event
emit(eventName, data) {
if (!this.listeners.has(eventName)) return;
this.listeners.get(eventName).forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in event listener for ${eventName}:`, error);
}
});
}
// Subscribe to an event once
once(eventName, callback) {
const unsubscribe = this.on(eventName, (data) => {
callback(data);
unsubscribe();
});
return unsubscribe;
}
}
// Create global event bus
const eventBus = new EventBus();
// Export for use in other modules
export default eventBus; Using the Event Bus
import eventBus from './event-bus.js';
// Component A - doesn't know about Component B or C
class ProductCatalog extends HTMLElement {
addToCart(product) {
// Just publish the event
eventBus.emit('product:added-to-cart', {
productId: product.id,
name: product.name,
price: product.price,
timestamp: Date.now()
});
}
}
// Component B - listens for cart events
class CartSummary extends HTMLElement {
connectedCallback() {
// Subscribe to events
this.unsubscribe = eventBus.on('product:added-to-cart', (data) => {
this.itemCount++;
this.totalPrice += data.price;
this.render();
});
}
disconnectedCallback() {
// Clean up subscription when component is removed
this.unsubscribe();
}
}
// Component C - also listens, completely independent
class AnalyticsTracker extends HTMLElement {
connectedCallback() {
this.unsubscribe = eventBus.on('product:added-to-cart', (data) => {
// Track the event
this.sendToAnalytics('add_to_cart', data);
});
}
disconnectedCallback() {
this.unsubscribe();
}
}
// Component D - also listens for notifications
class NotificationManager extends HTMLElement {
connectedCallback() {
this.unsubscribe = eventBus.on('product:added-to-cart', (data) => {
this.showNotification(`Added ${data.name} to cart`);
});
}
disconnectedCallback() {
this.unsubscribe();
}
} Web Components with Custom Events
Web Components can emit custom events to communicate with their parent or with an event bus. This maintains encapsulation while enabling interaction.
class FormValidator extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
.field { margin-bottom: 1rem; }
.error { color: red; font-size: 0.875rem; }
input.invalid { border-color: red; }
</style>
<div class="field">
<input type="email" placeholder="Enter email" />
<div class="error"></div>
</div>
`;
const input = this.shadowRoot.querySelector('input');
const errorDiv = this.shadowRoot.querySelector('.error');
input.addEventListener('blur', () => {
const value = input.value;
const isValid = this.validateEmail(value);
if (!isValid) {
input.classList.add('invalid');
errorDiv.textContent = 'Please enter a valid email';
// Emit validation failed event
this.dispatchEvent(new CustomEvent('validation-failed', {
detail: {
field: 'email',
value,
error: 'Invalid email format'
},
bubbles: true,
composed: true // Cross shadow DOM boundary
}));
} else {
input.classList.remove('invalid');
errorDiv.textContent = '';
// Emit validation success event
this.dispatchEvent(new CustomEvent('validation-success', {
detail: {
field: 'email',
value
},
bubbles: true,
composed: true
}));
}
});
}
validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
}
customElements.define('form-validator', FormValidator); Using the validator with event listeners:
<form id="signup-form">
<form-validator></form-validator>
<button type="submit">Sign Up</button>
</form>
<script is:inline>
const form = document.getElementById('signup-form');
const validator = form.querySelector('form-validator');
const submitBtn = form.querySelector('button');
let isEmailValid = false;
// Listen for validation events
validator.addEventListener('validation-success', (event) => {
console.log('Valid email:', event.detail.value);
isEmailValid = true;
submitBtn.disabled = false;
});
validator.addEventListener('validation-failed', (event) => {
console.log('Invalid email:', event.detail.error);
isEmailValid = false;
submitBtn.disabled = true;
});
form.addEventListener('submit', (event) => {
if (!isEmailValid) {
event.preventDefault();
alert('Please fix validation errors');
}
});
</script> Real-World Example: E-Commerce System
// Event Bus
const eventBus = new EventBus();
// Product Component - emits events
class ProductCard extends HTMLElement {
connectedCallback() {
this.shadowRoot.innerHTML = `
<div class="product">
<h3>${this.getAttribute('name')}</h3>
<p>$${this.getAttribute('price')}</p>
<button class="add-to-cart">Add to Cart</button>
</div>
`;
this.shadowRoot.querySelector('.add-to-cart').addEventListener('click', () => {
eventBus.emit('cart:item-added', {
id: this.getAttribute('product-id'),
name: this.getAttribute('name'),
price: parseFloat(this.getAttribute('price'))
});
});
}
}
// Cart Component - listens and emits
class ShoppingCartWidget extends HTMLElement {
constructor() {
super();
this.items = [];
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
// Listen for items being added
this.unsubscribe = eventBus.on('cart:item-added', (item) => {
this.items.push(item);
this.render();
// Emit updated cart state
eventBus.emit('cart:updated', {
items: this.items,
total: this.calculateTotal(),
count: this.items.length
});
});
// Listen for checkout
this.shadowRoot.addEventListener('click', (e) => {
if (e.target.matches('.checkout-btn')) {
eventBus.emit('cart:checkout-started', {
items: this.items,
total: this.calculateTotal()
});
}
});
}
calculateTotal() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
render() {
this.shadowRoot.innerHTML = `
<div class="cart">
<h3>Cart (${this.items.length})</h3>
<ul>
${this.items.map(item => `
<li>${item.name} - $${item.price}</li>
`).join('')}
</ul>
<p>Total: $${this.calculateTotal()}</p>
<button class="checkout-btn">Checkout</button>
</div>
`;
}
disconnectedCallback() {
this.unsubscribe();
}
}
// Analytics Component - listens only
class AnalyticsTracker extends HTMLElement {
connectedCallback() {
this.subscriptions = [
eventBus.on('cart:item-added', (data) => {
this.track('add_to_cart', data);
}),
eventBus.on('cart:checkout-started', (data) => {
this.track('begin_checkout', {
value: data.total,
items: data.items.length
});
})
];
}
track(eventName, data) {
console.log(`[Analytics] ${eventName}`, data);
// Send to analytics service
}
disconnectedCallback() {
this.subscriptions.forEach(unsubscribe => unsubscribe());
}
}
// Notification Component - listens only
class NotificationToast extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
eventBus.on('cart:item-added', (item) => {
this.show(`Added ${item.name} to cart`);
});
eventBus.on('cart:checkout-started', () => {
this.show('Proceeding to checkout...');
});
}
show(message) {
this.shadowRoot.innerHTML = `
<style>
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background: #333;
color: white;
padding: 1rem;
border-radius: 4px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
</style>
<div class="toast">${message}</div>
`;
setTimeout(() => {
this.shadowRoot.innerHTML = '';
}, 3000);
}
}
customElements.define('product-card', ProductCard);
customElements.define('shopping-cart-widget', ShoppingCartWidget);
customElements.define('analytics-tracker', AnalyticsTracker);
customElements.define('notification-toast', NotificationToast); Benefits
- Loose Coupling: Components don't need direct references to each other
- Flexibility: Easy to add/remove event listeners without changing emitters
- Scalability: New features can subscribe to existing events
- Testability: Components can be tested in isolation
- Reusability: Components are self-contained and portable
- Maintainability: Changes to one component don't require changes to others
Challenges
- Debugging: Hard to trace event flow through the system
- Event Naming: Requires consistent naming conventions
- Memory Leaks: Forgotten event listeners can cause leaks
- Event Order: No guaranteed order when multiple listeners exist
- Error Handling: Errors in one listener shouldn't break others
- Over-Engineering: Simple direct calls may be better for tightly coupled components
Best Practices
- Use namespaced event names (e.g.,
cart:item-addednot justadded) - Always clean up event listeners when components are removed
- Include relevant data in event detail, but keep it minimal
- Use
bubbles: truefor events that should propagate up the DOM - Use
composed: truefor events that need to cross shadow DOM boundaries - Document what events a component emits and listens for
- Consider using TypeScript for type-safe event data
- Wrap event listeners in try-catch to prevent one failing listener from breaking others
Key Takeaways
- Event-driven architecture enables loose coupling between components
- Events represent "something happened" without dictating responses
- Multiple listeners can respond independently to the same event
- Event bubbling and delegation enable efficient event handling
- Event buses provide centralized communication for complex applications
- Web Components use CustomEvent for communication
- Always clean up event listeners to prevent memory leaks
- Balance event-driven design with simplicity - not everything needs events
See Also
- Component-Based Architecture - Components emit and listen for events
- Layered Architecture - Events often flow between layers
- Client-Server Architecture - Events can trigger server communication