Introduction

When I started building complex UI logic a few years ago, I kept hitting the same friction: how to keep different parts of the interface in sync with a single source of truth without writing a mountain of boilerplate. The classic approach—manually deep‑copy state, emit events, and attach listeners—works, but it feels brittle and easy to forget a corner case. One day I stumbled upon the Proxy object and realized it was the missing piece. Proxy lets me intercept every read and write operation on an object, turning a plain data bag into a reactive store automatically. In this article I’ll walk you through a pragmatic pattern I now use on every project that needs live‑updating state.

The Problem in Real‑World Code

Imagine a dashboard that shows a user’s profile, settings, and activity feed. All three sections read from the same configuration object:

const config = {
    theme: 'dark',
    notifications: true,
    language: 'en',
    profile: { name: 'Alice', avatar: '…', bio: '…' },
    activity: []
};

function renderTheme() { /* update UI based on config.theme */ }
function renderNotifications() { /* update UI based on config.notifications */ }
function renderProfile() { /* update UI based on config.profile */ }
function renderActivity() { /* update UI based on config.activity */ }

// Every time a value changes we must remember to notify every interested function
config.theme = 'light';
renderTheme(); // manual
config.profile.name = 'Bob';
renderProfile(); // manual again

The manual pattern quickly becomes a maintenance nightmare. A new UI component might forget to call its render function, or a setter might mutate the object directly, bypassing notifications. The result is a UI that lags behind the model.

How Proxy Solves It

Proxy is a meta‑programming primitive that wraps another object and defines traps for fundamental operations like get and set. By trapping these operations we can centralize change detection and propagation. The beauty is that the rest of the code can treat the store as a plain object—no special methods required.

Below is a tiny, production‑ready reactive store I built for a recent client project. It demonstrates the core concepts and can be dropped into any vanilla or framework‑agnostic JavaScript context.

/**
 * createReactiveStore
 * @param {Object} initialState - the starting data shape
 * @param {Function} [onChange] - optional global callback for every change
 * @returns {Object} a proxy‑wrapped store that auto‑notifies subscribers
 */
function createReactiveStore(initialState, onChange) {
    // Keep a list of subscribers that want to react to any change
    const subscribers = new Set();

    // Helper: invoke all subscribers with the changed path and new value
    const notify = (path, value) => {
        for (const fn of subscribers) {
            fn(path, value);
        }
        // Global callback if provided (e.g., for logging or side‑effects)
        if (typeof onChange === 'function') {
            onChange(path, value);
        }
    };

    // Convert the initial state to a plain object (avoid mutating the argument)
    const state = Array.isArray(initialState) ? [...initialState] : { ...initialState };

    // Create a Proxy that intercepts reads and writes
    return new Proxy(state, {
        get(target, prop) {
            // Reading is fine – just return the value
            return target[prop];
        },
        set(target, prop, value) {
            const oldValue = target[prop];
            // Allow setting only if the value actually changed
            if (oldValue !== value) {
                target[prop] = value;
                // Notify listeners with a simple path (e.g., 'profile.name')
                notify(prop, value);
            }
            return true; // indicate success
        }
    });
}

// Example usage
const store = createReactiveStore({
    theme: 'light',
    count: 0,
    user: { name: 'Guest', role: 'admin' }
});

// Subscribe to changes – this function will run whenever any property changes
store.subscribe = (callback) => {
    subscribers.add(callback);
    // Immediate invocation with current state (optional)
    callback('INIT', store);
};

// Simple UI updater (in a real app this would be your rendering engine)
function updateUI(path, value) {
    console.log(`[${path}] changed to`, value);
    if (path === 'theme') {
        document.documentElement.setAttribute('data-theme', value);
    } else if (path === 'count') {
        document.getElementById('counter').textContent = value;
    } else if (path.startsWith('user.')) {
        const field = path.split('.')[1];
        document.getElementById(`user-${field}`).textContent = value;
    }
}

store.subscribe(updateUI);

// Demonstrate reactivity
store.theme = 'dark';        // triggers updateUI -> data-theme='dark'
store.count = 42;            // triggers updateUI -> counter text
store.user.name = 'Alice';   // triggers updateUI -> user-name text

The Proxy intercepts every assignment, compares it with the previous value, and fires a notification only when something actually changed. Subscribers receive the property name (or a more sophisticated path if you extend the trap) and can react accordingly. Because the store behaves like a plain object, existing code that reads `store.theme` continues to work without a single modification.

Real‑World Use Case: Dynamic Form Validation

On a recent e‑commerce project we needed a form where each field’s validity influenced the “Submit” button state and also surfaced error messages in real time. Rather than wiring up listeners for each input, we created a reactive state object that held the raw values and a derived `errors` map.

The validation logic lived in a single place, and the UI simply observed the `errors` object. Whenever a user typed, the Proxy automatically propagated the change, recomputed errors, and updated the DOM. The result was a clean separation of concerns: state management, validation rules, and rendering were all independent.

Tip: If you need deep reactivity (e.g., nested objects), extend the Proxy trap to wrap nested values as well. This can be done lazily: when a nested property is accessed, replace it with another Proxy that mirrors the same pattern.

Here’s a compact version of that validation store:

function createFormStore(initial) {
    const proxy = new Proxy(initial, {
        get(target, key) {
            return target[key];
        },
        set(target, key, value) {
            target[key] = value;
            // Re‑run validation on the changed field
            const error = validateField(key, value);
            target.errors[key] = error;
            // Notify UI – in this case we just log for brevity
            console.log(`Field ${key} updated`, { value, error });
            return true;
        }
    });
    return proxy;
}

const form = createFormStore({
    email: '',
    password: '',
    errors: {}
});

function validateField(field, val) {
    if (field === 'email' && !/^[^@]+@[^@]+\.[^@]+$/.test(val)) {
        return 'Invalid email';
    }
    if (field === 'password' && val.length < 8) {
        return 'Password too short';
    }
    return null;
}

// UI code can simply watch form.errors
function renderErrors() {
    const container = document.getElementById('errors');
    container.innerHTML = '';
    for (const [field, msg] of Object.entries(form.errors)) {
        if (msg) {
            const p = document.createElement('p');
            p.textContent = `${field}: ${msg}`;
            container.appendChild(p);
        }
    }
}

// Example interaction
form.email = 'bad-email'; // triggers validation and UI update automatically
renderErrors();

Notice that the UI never needed to know about the Proxy; it just read `form.errors` and rendered whatever was present. The reactive nature came for free.

Performance Considerations

Proxy is powerful but not without cost. Each getter/setter incurs a small overhead compared to direct property access. In high‑frequency scenarios (e.g., game loops or heavy numeric computations) you might want to keep the Proxy thin—only wrap the objects that truly need reactivity. Also, be mindful of circular references; Proxy cannot intercept properties that are already proxied, so you’ll need a strategy for nested deep proxies if you want full depth tracking.

One pattern I prefer is to expose a subscribe method that lets you attach listeners only where needed, rather than broadcasting to every component. This keeps the notification cost proportional to the number of interested parties.

Wrapping Up

Proxy transforms a plain JavaScript object into a live, observable data source without forcing you to adopt a full‑fledged state management library. The technique is compact, idiomatic, and works in any modern JavaScript environment—whether you’re building a tiny script, a framework component, or a large single‑page application. By centralizing change detection, you reduce the risk of out‑of‑sync UI and make the intent of your code clearer: the object *is* the source of truth, and anything that reads from it will automatically reflect its current state.

If you’re tired of manually wiring up listeners, give Proxy a try. You might find it becomes the go‑to pattern for any scenario where state and UI need to stay in lockstep.