Introduction

When I first started building complex UI components, I noticed a recurring pattern: keeping the view in sync with the model required a lot of boilerplate. Adding listeners, checking for changes, and re‑rendering felt like a chore. A few years ago I stumbled upon JavaScript’s Proxy object and realized it could automate that boilerplate. Today I use a lightweight reactive store in every project, and the code is cleaner than ever.

The Problem with Manual State Synchronization

Imagine a todo application. You have an array of items, each with a completed flag. When a user checks a box, you dispatch an action, update the model, and then manually iterate over listeners to call render(). If you add nested objects, computed properties, or async updates, the logic quickly spirals out of control. The core issue is that JavaScript primitives are not observable by default; we have to write code to detect changes.

Proxy objects give you a way to intercept property accesses and assignments, turning any object into a reactive data source without extra bookkeeping.

Using Proxy to Observe Changes

A Proxy wraps a target object and defines traps for operations like get and set. By attaching a callback to the set trap we can react whenever a property changes. Below is a tiny utility that turns a plain object into a reactive store.


/**
 * createReactiveStore
 * Wraps an object with a Proxy that notifies subscribers when any property changes.
 *
 * @param {Object} initialState - The initial data structure.
 * @param {Function} onChange - Callback invoked with (path, value) after a change.
 * @returns {Proxy} A reactive proxy of the initialState.
 */
function createReactiveStore(initialState, onChange) {
  // Track subscribers for each path (simplified: single global callback)
  const subscribers = new Set();

  // Internal handler that intercepts get and set operations.
  const handler = {
    get(target, prop) {
      // Return the actual value; Proxy will also forward reads.
      return target[prop];
    },
    set(target, prop, value) {
      const oldValue = target[prop];

      // Allow setting only if value differs (shallow check).
      if (oldValue !== value) {
        target[prop] = value;
        // Notify all subscribers with the property path and new value.
        subscribers.forEach(cb => cb([prop], value));
      }
      return true;
    }
  };

  // Create the proxy.
  const proxy = new Proxy(initialState, handler);

  // Public API
  return {
    subscribe(cb) {
      subscribers.add(cb);
      // Return an unsubscribe function.
      return () => subscribers.delete(cb);
    },
    getState: () => proxy,
    // Helper to update nested paths (optional).
    set: (path, val) => {
      const obj = path.reduce((acc, key, i) => {
        if (i === path.length - 1) return acc;
        return acc[key];
      }, proxy);
      const finalKey = path[path.length - 1];
      obj[finalKey] = val;
    }
  };
}

The subscribe method lets you attach a callback that runs whenever any property changes. The set helper demonstrates how you could update nested keys, but the core idea is that the Proxy traps every assignment and triggers the notification.

Building a Simple Reactive Store

With the utility above you can now create a store that automatically propagates changes. The following snippet shows a todo store that updates a UI element (simulated with console.log) whenever the list or a todo’s status changes.


const todoStore = createReactiveStore(
  {
    items: [
      { id: 1, text: 'Learn Proxy', completed: false },
      { id: 2, text: 'Build a reactive app', completed: false }
    ],
    filter: 'all' // 'all' | 'active' | 'completed'
  },
  (path, value) => {
    console.log(`[${path.join('.')}] changed to`, value);
    // In a real app you would re‑run a render function here.
  }
);

// Subscribe to all changes and log them.
todoStore.subscribe((path, value) => {
  console.log('Update received:', path, value);
});

// Modify a property – the proxy will fire the callback.
todoStore.getState().items[0].completed = true;
// Output:
// [items.0.completed] changed to true
// Update received: ['items', 0, 'completed'], true

Notice that we didn’t need to write any event listeners. The Proxy itself intercepted the assignment and notified us. This pattern scales: you can add computed properties, validation, or async side‑effects inside the handler.

Real‑World Example: Todo List

Let’s walk through a tiny todo application that uses the reactive store to keep the DOM in sync. The HTML is minimal; the JavaScript is the focus.


<div id="app">
  <h1>Todos</h1>
  <input id="newTodo" placeholder="Add a todo" />
  <button id="addBtn">Add</button>
  <ul></ul>
  <div>Filter: <select id="filter">
    <option value="all">All</option>
    <option value="active">Active</option>
    <option value="completed">Completed</option>
  </select></div>
</div>

const { useState, useEffect } = React; // assume React is loaded

// Reuse the createReactiveStore from earlier.
const store = createReactiveStore(
  { todos: [], filter: 'all' },
  () => renderApp(store.getState())
);

function renderApp(state) {
  const ul = document.querySelector('#app ul');
  ul.innerHTML = '';

  const filtered = state.todos.filter(todo => {
    if (state.filter === 'active') return !todo.completed;
    if (state.filter === 'completed') return todo.completed;
    return true;
  });

  filtered.forEach(todo => {
    const li = document.createElement('li');
    const chk = document.createElement('input');
    chk.type = 'checkbox';
    chk.checked = todo.completed;
    chk.addEventListener('change', () => {
      todo.completed = chk.checked;
    });
    li.appendChild(chk);
    li.appendChild(document.createTextNode(todo.text));
    ul.appendChild(li);
  });
}

// Wire up controls.
document.querySelector('#addBtn').addEventListener('click', () => {
  const input = document.querySelector('#newTodo');
  if (!input.value) return;
  store.set(['todos'], [
    ...store.getState().todos,
    { id: Date.now(), text: input.value, completed: false }
  ]);
  input.value = '';
});

document.querySelector('#filter').addEventListener('change', (e) => {
  store.set(['filter'], e.target.value);
});

// Initial render.
renderApp(store.getState());

The store automatically triggers renderApp whenever todos or filter changes. No manual diffing or event delegation—just a reactive data source and a render callback.

Why Proxy Beats Other Approaches

  • Declarative intent. With Proxy you declare *what* you want to observe, not *how* to observe it.
  • Centralized change detection. All mutations go through a single trap, eliminating scattered listeners.
  • Extensibility. You can add validation, logging, or side‑effects without altering the business logic.
  • Performance. The overhead is minimal for moderate object graphs; you only pay for the traps you define.

Compared to libraries like Vue’s reactivity system, a custom Proxy store is often lighter and easier to reason about when you need a small, focused reactive slice of your application.

Performance Considerations

Proxies introduce a tiny overhead on each get/set operation. For large arrays, you might want to wrap the array itself or use a Map‑based store. Also, be aware that Proxy does not observe array mutations like push, splice, or sort unless you proxy the array itself. A common pattern is to keep arrays as plain values but replace them entirely when they change—this keeps the surface area small.

Summary

JavaScript’s Proxy API lets you turn any object into a reactive data source with a few lines of code. By intercepting property accesses and assignments, you can automatically propagate changes to UI, logging, or validation logic without manual bookkeeping. The technique is flexible enough for small stores or larger frameworks, and it keeps your code clean and focused on the domain logic. Give it a try in your next project, and you’ll likely find yourself reaching for Proxy more often than you expect.