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 and newValue 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 with Object.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.