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.

May 1, 20263 min read2 / 6

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

  1. 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.
  2. Dispatch events on window, not document: When Shadow DOM is in use, components have their own document. window is the single shared context for the whole app.
  3. 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.
  4. set must return true: The trap must always return true to 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:

JavaScript
// services/Store.js const Store = { menu: null, cart: [] }; export default Store;

To make it reactive, wrap it in a Proxy before exporting:

JavaScript
// 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:

JavaScript
// 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.

JavaScript
// 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 post

That 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

Video: