How to Use CustomEvent for Cross-Component State Management in Vanilla JavaScript
The Problem
I was building a vanilla JavaScript application with multiple independent components. Component A (a shopping cart) needed to notify Component B (a checkout display) and Component C (notification system) when items were added or removed.
Without a framework like React or Vue, I faced a dilemma:
- Direct coupling: I could pass references between components, but that created spaghetti code
- External dependencies: Redux, MobX, or RxJS would solve it, but that adds npm packages and complexity
- Global state object: A shared object works, but who owns it? How do components react to changes?
I tried the global state approach first. It worked for reading state, but components had no way to react when state changed:
// Global state - every component can read itconst appState = { cartItems: [], totalPrice: 0};
// Component A modifies stateappState.cartItems.push({ id: 1, name: 'Widget', price: 29.99 });
// Component B doesn't know state changed!// It still shows stale data until manually refreshedThis approach failed because there was no notification mechanism. Components had to poll the state or manually call update functions. That’s worse than coupling.
Then I discovered that a Reddit post about a React interview question mentioned CustomEvent as a production state management solution. The interviewer’s company uses CustomEvent as their primary approach for Web Components architecture.
The Solution: CustomEvent as Event Bus
CustomEvent is a native browser API that lets components communicate through the DOM event system. No npm packages, no build step, works everywhere.
Basic Pattern: Dispatch and Listen
The simplest approach uses document as the communication hub:
// Component A: Publish state changefunction addToCart(product) { // ... update local state ...
document.dispatchEvent(new CustomEvent('cart-updated', { detail: { items: cartItems, total: calculateTotal(), itemCount: cartItems.length, timestamp: Date.now() }, bubbles: true }));}
// Component B: Subscribe to state changesdocument.addEventListener('cart-updated', (e) => { updateCheckoutDisplay(e.detail);});
// Component C: Also subscribes independentlydocument.addEventListener('cart-updated', (e) => { showNotification(`Cart has ${e.detail.itemCount} items`);});The detail property carries structured data. bubbles: true ensures the event propagates through the DOM tree. Any component anywhere in the document can listen.
EventTarget as Standalone Event Bus
Using document works, but events bubble through all DOM ancestors. For cleaner pub/sub without DOM dependency, I created a dedicated EventBus class:
class EventBus extends EventTarget { emit(eventName, data) { this.dispatchEvent(new CustomEvent(eventName, { detail: data })); }
on(eventName, handler) { this.addEventListener(eventName, handler); }
off(eventName, handler) { this.removeEventListener(eventName, handler); }
// Track handlers for cleanup constructor() { super(); this.handlers = new Map(); }}
// Singleton instanceconst bus = new EventBus();Now components use a pure pub/sub pattern:
// Component A emitsbus.emit('user-logged-in', { userId: 123, role: 'admin' });
// Component B subscribesconst loginHandler = (e) => { updateUIForUser(e.detail.userId);};bus.on('user-logged-in', loginHandler);
// Cleanup when component unmountsbus.off('user-logged-in', loginHandler);This pattern is framework-agnostic. It works in vanilla JS, Web Components, and even React applications.
Web Components: Shadow DOM Boundary
Here’s where CustomEvent shines. Web Components use Shadow DOM for encapsulation. Events inside Shadow DOM don’t escape by default.
I built a user card component and discovered this issue:
class UserCard extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); }
connectedCallback() { this.shadowRoot.innerHTML = ` <button id="select">Select User</button> `;
this.shadowRoot.querySelector('#select') .addEventListener('click', () => { // Event stays inside Shadow DOM! this.dispatchEvent(new CustomEvent('user-selected', { detail: { userId: this.dataset.userId }, bubbles: true })); }); }}
// External listener never catches the eventdocument.addEventListener('user-selected', handler); // Nothing happens!The fix is composed: true:
this.dispatchEvent(new CustomEvent('user-selected', { detail: { userId: this.dataset.userId }, bubbles: true, composed: true // CRITICAL: Cross Shadow DOM boundary}));
// Now external listeners receive the eventdocument.addEventListener('user-selected', (e) => { console.log('Selected:', e.detail.userId); // Works!});The composed property lets events escape Shadow DOM encapsulation. Without it, events are trapped inside the component’s shadow tree.
React Integration: useLocalStorage Hook
I was surprised to learn from the Reddit discussion that even React developers use CustomEvent. The example: a useLocalStorage hook that syncs state across components.
Here’s how it works:
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) { const [storedValue, setStoredValue] = useState(() => { const item = localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; });
const setValue = (value) => { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); localStorage.setItem(key, JSON.stringify(valueToStore));
// Emit CustomEvent to sync across all components window.dispatchEvent(new CustomEvent('localStorage-change', { detail: { key, value: valueToStore } })); };
// Listen for changes from other components useEffect(() => { const handleChange = (e) => { if (e.detail.key === key) { setStoredValue(e.detail.value); } };
window.addEventListener('localStorage-change', handleChange);
// Also sync across browser tabs const handleStorageEvent = (e) => { if (e.key === key && e.newValue) { setStoredValue(JSON.parse(e.newValue)); } }; window.addEventListener('storage', handleStorageEvent);
return () => { window.removeEventListener('localStorage-change', handleChange); window.removeEventListener('storage', handleStorageEvent); }; }, [key]);
return [storedValue, setValue];}Two React components using the same localStorage key stay synchronized through CustomEvent. When one updates, the other receives the change immediately.
// ComponentA.jsxfunction ComponentA() { const [user, setUser] = useLocalStorage('currentUser', null);
return ( <button onClick={() => setUser({ id: 123, name: 'Alice' })}> Set User </button> );}
// ComponentB.jsx - Automatically syncedfunction ComponentB() { const [user] = useLocalStorage('currentUser', null);
return ( <p>Current user: {user?.name || 'None'}</p> );}This works because CustomEvent is native. No React-specific context, no provider wrapping, no Redux store. Just DOM events.
Common Mistakes
I made these mistakes while learning CustomEvent:
Mistake 1: Missing bubbles: true
// WRONG: Event doesn't propagatechildElement.dispatchEvent(new CustomEvent('state-change', { detail: { value: newValue }}));
// Parent won't catch itparentElement.addEventListener('state-change', handler); // Never fires!Fix:
childElement.dispatchEvent(new CustomEvent('state-change', { detail: { value: newValue }, bubbles: true // Propagates up DOM tree}));Mistake 2: Missing composed: true in Shadow DOM
// WRONG: Event trapped in Shadow DOMthis.dispatchEvent(new CustomEvent('action', { detail: { data: 'test' }, bubbles: true // Still trapped!}));
// External listener failsdocument.addEventListener('action', handler); // NothingFix:
this.dispatchEvent(new CustomEvent('action', { detail: { data: 'test' }, bubbles: true, composed: true // Escapes Shadow DOM}));Mistake 3: Anonymous Functions Can’t Be Removed
// WRONG: No reference to handlerbus.on('event', (e) => { console.log(e.detail);});
// Can't remove later!bus.off('event', ???); // What handler?Fix:
// Store referenceconst handler = (e) => { console.log(e.detail);};
bus.on('event', handler);
// Can remove properlybus.off('event', handler);
// Alternative: AbortController for batch cleanupconst controller = new AbortController();bus.addEventListener('event', handler, { signal: controller.signal});
// Remove all listeners at oncecontroller.abort();Mistake 4: Non-Serializable Detail Data
// WRONG: DOM nodes and functions in detailconst event = new CustomEvent('data-update', { detail: { element: document.querySelector('#myElement'), // DOM node callback: () => console.log('test') // Function }});
// These don't serialize well for debugging or storageFix:
// Keep detail serializableconst event = new CustomEvent('data-update', { detail: { elementId: '#myElement', // Just the selector callbackName: 'testCallback' // Reference name }});When to Use CustomEvent State Management
This pattern fits specific scenarios:
- Web Components architecture - Shadow DOM isolation requires
composed: true - Vanilla JavaScript apps - Avoid framework lock-in
- Cross-framework communication - React + vanilla components
- Micro-frontends - Loose coupling between independently deployed pieces
- Lightweight apps - Redux/Zustand is overkill
- Component libraries - Framework-agnostic design
For complex state with time-travel debugging, Redux still wins. For deep component trees in pure React, Context is simpler. But for decoupled, framework-agnostic communication, CustomEvent is the cleanest native solution.
Summary
CustomEvent provides zero-dependency, framework-agnostic state management through the native DOM event system. The pattern:
- Dispatch with
detailfor structured data - Listen anywhere via
addEventListener - Use
bubbles: truefor DOM tree propagation - Use
composed: truefor Shadow DOM crossing - Clean up with named handlers or AbortController
The Reddit discussion confirmed this isn’t theoretical. Production Web Components architectures use CustomEvent as their primary state communication mechanism. Even React developers use it for cross-component localStorage sync.
No npm install. No build configuration. No framework dependency. Just native browser APIs that work everywhere.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 MDN CustomEvent Documentation
- 👨💻 MDN EventTarget API
- 👨💻 Reddit Discussion on CustomEvent State Management
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments