Building Reactive State with JavaScript Proxy – A Practical Guide
Introduction
When I first started building interactive web apps without a framework, I quickly realized that keeping the UI in sync with data required a lot of boilerplate. Setting up getter/setter pairs, manually diffing objects, or polling for changes was not only tedious but also error‑prone. A few years ago I discovered that the Proxy object could simplify this dramatically. By wrapping a plain JavaScript object in a Proxy, we can intercept property accesses and changes, turning any data structure into a reactive source. In this article I’ll share a compact, production‑ready snippet that turns a plain object into a reactive store and explain why this approach is both powerful and performant.
The Problem
Imagine you have a component that displays a user’s profile. The name, email, and avatar URL are stored in a plain object:
const profile = {
name: 'Alice',
email: 'alice@example.com',
avatar: 'https://example.com/avatar.png'
};
Any time one of those fields changes, you need to re‑render the view. Traditional patterns involve creating getter/setter methods or using a framework’s two‑way binding. Both approaches require you to manually notify listeners, which adds friction and can lead to missed updates.
The Solution: A Reactive Proxy Wrapper
The trick is to use a Proxy that listens for property assignments and triggers callbacks automatically. The following function, reactiveObject, returns a proxy and a helper to subscribe to changes. It works with nested objects as well, because each level is also proxified.
/**
* Creates a reactive JavaScript object.
* @param {Object} initial - The starting data.
* @returns {[Proxy, subscribe]} A reactive proxy and a subscribe function.
*/
function reactiveObject(initial) {
const listeners = new Set();
// Recursively turn plain objects into proxies
const deepProxy = (target) => new Proxy(target, {
get(receiver, prop) {
// If the property is an object, proxy it as well
const value = receiver[prop];
if (value && typeof value === 'object' && !(value instanceof Date)) {
return deepProxy(value);
}
return value;
},
set(receiver, prop, newValue) {
const oldValue = receiver[prop];
// Detect actual change to avoid unnecessary updates
if (oldValue !== newValue) {
receiver[prop] = newValue;
// Notify all listeners with the updated path and values
listeners.forEach(cb => cb({
type: 'update',
path: prop,
value: newValue,
oldValue
}));
}
return true;
}
});
const proxy = deepProxy(initial);
function subscribe(callback) {
listeners.add(callback);
// Return an unsubscribe function
return () => listeners.delete(callback);
}
return [proxy, subscribe];
}
Key points:
- Automatic nesting – the
deepProxy function ensures any object property is also wrapped, so changes deep in the tree trigger updates. - Change detection – we compare
oldValue andnewValue to avoid unnecessary notifications. - Simple subscription API –
subscribe adds a callback and returns an unsubscribe function, making cleanup straightforward.
Why Proxy Works for Reactivity
Unlike setter‑based patterns, a Proxy intercepts operations at the language level. This means we don’t need to write custom getter/setter pairs for each property, and the JavaScript engine handles the interception efficiently. The result is a declarative way to observe changes: you just read from the proxy, and listeners fire automatically.
Another advantage is that the proxy is transparent. Existing code that expects plain objects can work unchanged because the proxy implements the same interface. The only difference is the hidden side‑effect of notifying listeners.
Real‑World Use Case
Suppose you are building a simple todo list. The todos live in a reactive store, and the UI only needs to listen for updates:
// Create the store
const [todos, subscribe] = reactiveObject([
{ id: 1, text: 'Buy milk', done: false },
{ id: 2, text: 'Walk the dog', done: true }
]);
// Subscribe to changes
const unsubscribe = subscribe((event) => {
console.log(`Todos changed at path '${event.path}'`);
renderTodos(todos);
});
function renderTodos(list) {
const ul = document.getElementById('todo-list');
ul.innerHTML = '';
list.forEach(item => {
const li = document.createElement('li');
li.textContent = `${item.text} – ${item.done ? 'done' : 'pending'}`;
ul.appendChild(li);
});
}
// Simulate a user action
todos.push({ id: 3, text: 'Write article', done: false });
When the array changes, the proxy’s push method is called, which the underlying array preserves. However, our Proxy only intercepts property assignments, not array mutations. For a fully reactive array, you could also wrap the array in a custom class, but many use cases are satisfied by objects. If you need deep reactivity for arrays, you can extend the handler to detect array methods like push, splice, etc. The core idea remains the same: intercept and notify.
Tips & Gotchas
- Performance – each set triggers a loop over all listeners. For high‑frequency updates, batch notifications or limit the number of listeners.
- Prototype pollution – be careful not to proxy
Object.prototype properties, as they are inherited. - Serialisation – proxies are not preserved by
JSON.stringify. If you need to persist the state, extract the raw object withObject.assign or a deep clone. - Nested proxies – the recursive proxy creation can cause infinite loops if a property references its parent. Guard against circular references by tracking visited objects.
If you’re building a small‑to‑medium app and want reactivity without a framework, the Proxy approach gives you a clean, extensible foundation.
Conclusion
Using JavaScript Proxy to create a reactive store eliminates boilerplate and lets you focus on business logic rather than change‑detection plumbing. The snippet above is production‑ready for most UI scenarios, and with a few extra handlers you can support arrays, functions, and even remote data streams. Give it a try in your next project; you’ll find that watching state becomes almost magical.