When a Plain Object Needs to React

When I need to keep the UI in sync with a plain JavaScript object, I reach for the Proxy API. A Proxy wraps a target object and lets me intercept property reads and writes. Instead of wiring up manual event listeners or setters, I can declare exactly which changes matter and react to them automatically. This pattern is especially handy in small‑scale applications or when you want to avoid the overhead of a full‑featured state manager.

Why Proxy Beats Setters and Getters

Traditional JavaScript objects rely on explicit getter/setter pairs or a class with methods that mutate internal state. Those approaches are noisy, require boilerplate, and make it hard to observe deep changes. A Proxy, on the other hand, provides a declarative way to define what we care about and how we react.

  • It works on any object, even nested structures.
  • It triggers updates on both assignment and deletion.
  • It integrates cleanly with existing code because the target remains a normal object.

The why is simple: the Proxy sits at the boundary between our data and the consumer, giving us a single place to enforce logic without polluting the model.

Building a Reactive Wrapper

Below is a tiny utility that turns a plain object into a reactive store. It uses a Proxy with a handler that records changes and notifies registered callbacks.

/**
 * makeReactive
 * Wraps `target` in a Proxy that notifies `callback` whenever a property
 * is set, deleted, or accessed (get). The callback receives the property
 * name and the new value.
 */
function makeReactive(target, callback) {
    // Keep a reference to the original target so we can still read its
    // current state without triggering the callback.
    const raw = target;

    const handler = {
        get(proxy, prop) {
            // Forward reads to the underlying object – no side effects.
            return raw[prop];
        },
        set(proxy, prop, value) {
            // Update the raw object first.
            raw[prop] = value;
            // Notify listeners that a change occurred.
            callback(prop, value);
            // Return true to indicate the assignment succeeded.
            return true;
        },
        deleteProperty(proxy, prop) {
            // Delete from the raw object and notify.
            delete raw[prop];
            callback(prop, undefined);
            return true;
        }
    };

    // Return the proxy – it behaves like the original object.
    return new Proxy(target, handler);
}

// Example usage:
const state = { count: 0, label: 'Items' };
const reactive = makeReactive(state, (prop, val) => {
    console.log(`[${prop}] changed to ${val}`);
    // In a real app you would update the DOM or dispatch an event.
    document.getElementById('output').textContent = `${prop}: ${val}`;
});

// mutate via the proxy
reactive.count = 5;          // logs "[count] changed to 5"
reactive.label = 'Things';    // logs "[label] changed to Things"
delete reactive.count;       // logs "[count] changed to undefined"

The implementation is deliberately small, but it already showcases the core idea: we intercept mutations, apply them to the raw object, and let the caller decide what to do with the change.

Real‑World Example: A Counter Component

Imagine a simple counter UI that lives in a single file and does not depend on React, Vue, or Redux. We can keep the counter state in a Proxy and update a DOM element whenever the value changes.

// Initialize the store with a default value.
const store = { value: 0 };
const proxy = makeReactive(store, (prop, val) => {
    // Only react when 'value' changes.
    if (prop === 'value') {
        const el = document.getElementById('counter');
        if (el) {
            el.textContent = val;
            // Optionally add a CSS class for styling.
            el.classList.add('updated');
            // Remove the class after a short animation.
            setTimeout(() => el.classList.remove('updated'), 300);
        }
    }
});

// Wire up buttons.
document.getElementById('inc').addEventListener('click', () => {
    proxy.value += 1;
});
document.getElementById('dec').addEventListener('click', () => {
    proxy.value -= 1;
});

// Render the initial state.
const initEl = document.getElementById('counter');
if (initEl) initEl.textContent = proxy.value;

This snippet demonstrates that the Proxy is transparent to the rest of the code. The component logic stays simple, and we avoid the mental overhead of tracking state changes manually.

Performance Considerations

Proxy traps are fast, but they are not free. In a hot loop or with deeply nested objects, the overhead can add up. Profile your application and only wrap data that truly needs observation.

When the number of listeners grows, each mutation triggers every callback sequentially. If you anticipate many subscribers, consider batching updates (e.g., using requestAnimationFrame) or moving to a dedicated state management library. For small‑scale apps, the Proxy approach is both expressive and performant enough.

When to Stop Using Proxy

Proxy shines when you need fine‑grained control over a few objects. However, it has limits:

  • It does not automatically detect property additions on the prototype chain.
  • Circular references can cause infinite loops if the handler calls back into the proxy.
  • Some environments (older browsers) lack full Proxy support.

If you start dealing with complex validation, async persistence, or need time‑travel debugging, a dedicated state container like Redux or Zustand may be a better fit. The Proxy pattern is a great stepping stone, though.

Wrapping Up

Using a Proxy to create reactive state is a clean, declarative way to keep data and UI in sync without pulling in a framework. The technique is small enough to drop into a utility module, yet powerful enough to replace manual setter boilerplate in many scenarios. Try it on your next small feature; you might find yourself reaching for it again and again.