Build Reactive State Management for WordPress Admin UI with JavaScript Proxy

The JavaScript Proxy object wraps a target object and intercepts fundamental operations on it — property reads (get trap), property writes (set trap), property deletion (deleteProperty trap), and more — enabling reactive data binding, validation on assignment, and computed properties without a framework. Reflect provides the same set of operations as static methods (Reflect.get(), Reflect.set()) and is used inside Proxy traps to perform the default operation after any custom logic, preventing infinite recursion and maintaining correct behavior for this binding. A reactive store built on Proxy detects when any property on the state object is set, looks up a list of subscriber callbacks registered for that property key, and calls them with the new value — achieving Vue.js-style reactivity with ~30 lines of vanilla JavaScript. For WordPress admin pages, this pattern is valuable for: settings forms where changing one option reveals or hides related options, product price calculators where multiple inputs affect a live preview, and custom block editor side panels where state changes propagate to multiple UI components without prop drilling. Recursive proxying (proxying nested objects when a property is accessed via the get trap) extends reactivity to deeply nested state — any property at any depth triggers the subscriber chain. The Proxy approach is the underlying mechanism in Vue 3’s reactivity system and Solid.js’s signal-based updates, making understanding it valuable beyond vanilla use. Validation proxies add a set trap that throws or returns false for invalid values — ensuring that state can only ever hold valid data and centralizing validation logic at the data layer rather than spreading it across event handlers. The Web Workers post offloads computation from the main thread; Proxy-based reactivity efficiently manages state updates on the main thread with minimal DOM operations.

Problem: A WordPress settings page has 15 inter-dependent options — enabling “Advanced Mode” reveals 8 additional fields, changing the currency field updates three price display previews, and toggling “Enable API” shows an API key field. The current implementation uses 30 jQuery event listeners that update a global state object manually and then query the DOM on every change — it is brittle and 400 lines of imperative code.

Solution: Replace manual DOM synchronization with a Proxy-based reactive store — declare the state once, register DOM elements as subscribers to specific state properties, and let the Proxy dispatch updates automatically whenever state changes.

// reactive-store.js — lightweight Proxy-based reactive state for WP admin

function createStore(initialState) {
    const subscribers = new Map();  // key → Set of callback functions

    function subscribe(key, callback) {
        if (!subscribers.has(key)) subscribers.set(key, new Set());
        subscribers.get(key).add(callback);
        return () => subscribers.get(key)?.delete(callback); // unsubscribe
    }

    function notify(key, newValue, oldValue) {
        subscribers.get(key)?.forEach(cb => cb(newValue, oldValue));
        subscribers.get('*')?.forEach(cb => cb(key, newValue, oldValue));
    }

    function makeReactive(obj, path = '') {
        return new Proxy(obj, {
            get(target, key, receiver) {
                const value = Reflect.get(target, key, receiver);
                if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
                    return makeReactive(value, path ? `${path}.${key}` : key);
                }
                return value;
            },
            set(target, key, newValue, receiver) {
                const oldValue = Reflect.get(target, key, receiver);
                const fullKey  = path ? `${path}.${String(key)}` : String(key);

                if (oldValue === newValue) return true;

                const result = Reflect.set(target, key, newValue, receiver);
                if (result) notify(fullKey, newValue, oldValue);
                return result;
            },
        });
    }

    const state = makeReactive(initialState);
    return { state, subscribe };
}

// ── Usage in a WordPress settings page ──────────────────────────────────────
const { state, subscribe } = createStore({
    advancedMode: false,
    currency:     'USD',
    apiEnabled:   false,
    apiKey:       '',
    basePrice:    29.99,
});

// Show/hide advanced fields when advancedMode changes
subscribe('advancedMode', (enabled) => {
    document.querySelectorAll('.advanced-only').forEach(el => {
        el.style.display = enabled ? '' : 'none';
    });
});

// Update price previews when currency or basePrice changes
function updatePreviews() {
    const symbol    = state.currency === 'USD' ? '$' : '€';
    const formatted = symbol + state.basePrice.toFixed(2);
    document.querySelectorAll('[data-price-preview]').forEach(el => {
        el.textContent = formatted;
    });
}
subscribe('currency',  updatePreviews);
subscribe('basePrice', updatePreviews);

// Show/hide API key field
subscribe('apiEnabled', (enabled) => {
    const field = document.getElementById('api-key-field');
    if (field) field.style.display = enabled ? '' : 'none';
});

// Wire up form inputs → state
document.querySelectorAll('[data-bind]').forEach(input => {
    const key     = input.dataset.bind;
    const initial = state[key];

    if (input.type === 'checkbox') input.checked = !!initial;
    else input.value = initial ?? '';

    input.addEventListener('change', () => {
        state[key] = input.type === 'checkbox' ? input.checked : input.value;
    });
});

NOTE: The Proxy approach has one important gotcha: identity checks with === on proxied objects fail — a proxied object is not strictly equal to its target. This matters when using Map, Set, or WeakMap with proxied objects as keys, and when checking object identity in framework integrations. For simple key-value state (strings, numbers, booleans) as shown above, this is not an issue. Also note that JSON.stringify(proxyObject) works correctly because it calls the get trap for each property — but libraries that use instanceof checks (like some form validators) may not recognize a proxied array as an Array instance unless you explicitly handle this case in the get trap with if (key === Symbol.toStringTag) return 'Array'.