Reactive Store
Wrapping the app store in a Proxy turns every data change into a broadcast event. Any part of the app can listen and react without knowing where the change came from.
The Store holds the app's data. The UI needs to know when that data changes. The cleanest way to connect them -- without coupling the data layer to the view layer -- is to put a Proxy in front of the Store and have it broadcast events on every write.
The Essentials
- Export the proxy, not the original: The original Store object becomes private. Everything outside the module works through the proxy. This is the same idea as a higher-order component: wrap the thing, return the wrapper.
- Dispatch events on
window, notdocument: When Shadow DOM is in use, components have their own document.windowis the single shared context for the whole app. - Custom event names as namespaced strings: Use a prefix like
app:to namespace events (app:menuchange,app:cartchange). Any string works; the prefix prevents collisions with built-in events. setmust returntrue: The trap must always returntrueto confirm the assignment was handled, otherwise the browser logs a TypeError warning.
Wrapping the Store
The Store from the services layer is a plain object with two properties:
// services/Store.js
const Store = {
menu: null,
cart: []
};
export default Store;To make it reactive, wrap it in a Proxy before exporting:
// services/Store.js
const Store = {
menu: null,
cart: []
};
const proxyStore = new Proxy(Store, {
set(target, property, value) {
target[property] = value;
if (property === 'menu') {
window.dispatchEvent(new Event('app:menuchange'));
}
if (property === 'cart') {
window.dispatchEvent(new Event('app:cartchange'));
}
return true;
}
});
export default proxyStore;The original Store object is never exported. Anyone importing this module receives the proxy. When they write Store.menu = data, they are writing to the proxy, which applies the change and then fires the event.
Why window, not document
Web components with Shadow DOM have their own document context. A CustomEvent dispatched on document inside a Shadow DOM component does not bubble to the outer document. window is shared across all DOM contexts -- the main document and every shadow root in the app.
For global application events, window is the correct target.
Listening for Store Changes
Any component can subscribe to these events on window:
// In any component or module
window.addEventListener('app:menuchange', () => {
this.render();
});
window.addEventListener('app:cartchange', () => {
this.updateBadge();
});The subscriber does not know who changed the data. The proxy does not know who is listening. This broadcast pattern decouples the data layer from every consumer of it.
The Higher-Order Object Pattern
Exporting the proxy instead of the original object is structurally similar to a higher-order function in React. The original is the implementation. The wrapper adds behavior. Consumers never see the original.
// Consumers import the proxy, which behaves like Store
import Store from './services/Store.js';
Store.menu = await API.fetchMenu(); // triggers app:menuchange
Store.cart.push(item); // does NOT trigger -- see next postThat last comment is important: pushing to an array does not replace the cart property. The proxy set trap only fires when a property is reassigned, not when its contents are mutated. The next post covers why this matters and how to handle it.
Further Reading and Watching
- MDN: CustomEvent - Creating and dispatching custom events with optional detail data.
- MDN: Window.dispatchEvent - How dispatching events on window differs from dispatching on document.
Video:
- JavaScript Proxy in 15 Minutes by Fireship. Covers the set trap in the context of building reactive data systems.
Keep reading