Data Binding with JS Proxies: Complete Guide

Data binding keeps your UI in sync with your data automatically. Learn how to build a reactive data binding system using JavaScript Proxy that updates the DOM whenever state changes.

7 min read

JavaScript data binding means your UI updates itself whenever your data changes, without you writing manual DOM update code after every state change. Change a value in one place, and every part of the page that depends on it refreshes automatically.

JavaScript Proxy makes this pattern straightforward. You wrap your state object in a Proxy with a set trap, and inside that trap you update the DOM for every property that was just changed. Before Proxy existed, developers reached for Object.defineProperty getters and setters or a manual publish-subscribe system to get the same effect, both of which needed far more boilerplate for the same result.

How Proxy Data Binding Works

The flow is simple: you change a property on the proxy, the set trap fires, the DOM updates:

Proxy-based data binding flow

The key insight is that the proxy knows exactly which property changed. You do not need to diff the entire DOM or re-render everything. You update only the elements bound to the specific property that was modified, which is cheaper than the virtual DOM diffing that frameworks like React perform on every render.

Building a Minimal Reactive System

Start with a state object, a proxy that watches changes, and a function that updates the DOM:

javascriptjavascript
// Step 1: Define the state
const state = { count: 0, message: "Hello" };
 
// Step 2: Map properties to DOM elements
const bindings = {
  count: document.querySelector("#count-display"),
  message: document.querySelector("#message-display")
};

The bindings object is the link between state keys and the DOM elements that display them. The proxy below reads from this map inside its set trap:

javascriptjavascript
// Step 3: Create the reactive proxy
const reactive = new Proxy(state, {
  set(target, prop, value) {
    target[prop] = value;                // Write to state
    if (bindings[prop]) {
      bindings[prop].textContent = value; // Update DOM
    }
    return true;
  }
});
 
// Step 4: Use the proxy. The DOM updates automatically.
reactive.count = 5;     // #count-display now shows "5"
reactive.message = "Hi"; // #message-display now shows "Hi"

Change a property on the reactive proxy, and the matching DOM element updates immediately. The calling code does not mention the DOM at all. It just changes data.

One-Way Binding -- A Complete Example

Here is a self-contained example with real HTML elements and a proxy that keeps them in sync:

javascriptjavascript
// HTML:
// <h1 id="title"></h1>
// <p id="subtitle"></p>
// <button id="update-btn">Update</button>
 
const appState = { title: "Welcome", subtitle: "Click the button" };
 
// Collect elements that match property names, then paint the initial state
const elements = {};
for (const key of Object.keys(appState)) {
  const el = document.getElementById(key);
  if (el) elements[key] = el;
}
for (const [key, el] of Object.entries(elements)) {
  el.textContent = appState[key];
}

This setup step matches each state key to a DOM element with the same id and renders the starting values. The proxy below reuses that same elements map every time a property changes:

javascriptjavascript
const $state = new Proxy(appState, {
  set(target, prop, value) {
    target[prop] = value;
    if (elements[prop]) {
      elements[prop].textContent = value;
    }
    return true;
  }
});

With the proxy in place, the button handler only has to change data on click. It never touches the DOM directly, and it never needs to know which elements exist:

javascriptjavascript
document.querySelector("#update-btn").addEventListener("click", () => {
  $state.title = "Updated!";
  $state.subtitle = `Clicked at ${new Date().toLocaleTimeString()}`;
});

The button handler only changes properties. It has no idea which DOM elements exist or how they should update. The proxy handles everything.

Two-Way Binding -- Inputs That Write Back

Two-way binding is one-way binding plus the reverse: when the user types in an input, the state updates. The proxy pushes data to the DOM, and event listeners push data from the DOM back to the proxy:

javascriptjavascript
// HTML:
// <input id="name-input" type="text" />
// <p id="name-display"></p>
 
const user = { name: "" };
 
const nameDisplay = document.querySelector("#name-display");
const nameInput = document.querySelector("#name-input");
 
const $user = new Proxy(user, {
  set(target, prop, value) {
    target[prop] = value;
    if (prop === "name") {
      nameDisplay.textContent = value || "(empty)";
    }
    return true;
  }
});

That covers the state-to-DOM direction. The other direction needs an event listener that reads each keystroke and writes it back into the proxy as the user types:

javascriptjavascript
// User types → state updates
nameInput.addEventListener("input", (e) => {
  $user.name = e.target.value;
});
 
// State changes → input also updates (for programmatic changes)
$user.name = "Alex"; // Input now shows "Alex", display shows "Alex"

The data flows in both directions. The proxy writes to the DOM, and the event listener reads from the DOM and writes to the proxy.

Neither side knows about the other.

Binding Multiple Properties to the Same Element

Sometimes one element depends on multiple state properties. A greeting might combine firstName and lastName:

javascriptjavascript
const person = { firstName: "Alex", lastName: "Chen" };
 
const greetingEl = document.querySelector("#greeting");
 
function updateGreeting() {
  greetingEl.textContent = `Hello, ${person.firstName} ${person.lastName}!`;
}
 
const $person = new Proxy(person, {
  set(target, prop, value) {
    target[prop] = value;
    if (prop === "firstName" || prop === "lastName") {
      updateGreeting(); // Recompute derived value
    }
    return true;
  }
});
 
$person.firstName = "Sam";
// #greeting now shows "Hello, Sam Chen!"

The updateGreeting function is a computed property. It depends on two pieces of state and must re-run when either changes. The proxy set trap detects the relevant properties and calls the updater only when needed.

Tracking Dependencies Automatically

The previous example hardcoded which properties affect which DOM elements. A more sophisticated system tracks dependencies at runtime:

javascriptjavascript
function createReactive(target, render) {
  let currentDeps = null;
  const depMap = new Map();
 
  const proxy = new Proxy(target, {
    get(obj, prop) {
      // If we are inside a render pass, record this property as a dependency
      if (currentDeps) {
        if (!depMap.has(prop)) depMap.set(prop, new Set());
        depMap.get(prop).add(currentDeps);
      }
      return obj[prop];
    },
    set(obj, prop, value) {
      obj[prop] = value;
      // Re-run every render function that depends on this property
      const deps = depMap.get(prop);
      if (deps) deps.forEach(fn => fn());
      return true;
    }
  });

The get trap only records a dependency while a render pass is running, tracked by the currentDeps variable. The watch helper below sets that variable before calling the render function, so every property it reads gets tagged automatically:

javascriptjavascript
  // Wrap a render function so its property reads are tracked
  function watch(fn) {
    const wrapped = () => {
      currentDeps = wrapped;
      fn();
      currentDeps = null;
    };
    wrapped(); // Run once to discover dependencies
  }
 
  watch(render);
  return proxy;
}

Calling watch(render) once at setup time discovers the dependencies by simply running the render function and watching which properties it reads. From then on, changing any of those properties re-runs it automatically:

javascriptjavascript
const state = createReactive(
  { price: 100, quantity: 2 },
  () => {
    document.querySelector("#total").textContent =
      `Total: $${state.price * state.quantity}`;
  }
);
 
state.price = 150;    // #total updates to "Total: $300"
state.quantity = 3;   // #total updates to "Total: $450"

This is the core of how Vue's reactivity works. The get trap records which properties a render function reads, and the set trap re-runs only the render functions that depend on the changed property.

You never manually declare dependencies. They are discovered by running the code.

Handling Arrays Reactively

Arrays need special handling because common mutations like push, splice, and sort change the array in place:

javascriptjavascript
function createReactiveArray(arr, onChange) {
  return new Proxy(arr, {
    set(target, prop, value) {
      target[prop] = value;
      onChange(); // Notify on any change
      return true;
    },
    get(target, prop) {
      const value = target[prop];
      // Wrap mutating methods so they trigger onChange
      if (typeof value === "function") {
        return function (...args) {
          const result = value.apply(target, args);
          onChange();
          return result;
        };
      }
      return value;
    }
  });
}

The get trap intercepts method access and wraps every array method so it calls onChange after mutating. The set trap catches direct index assignments. Together they make arrays fully reactive, whether code mutates through a method or writes to an index directly:

javascriptjavascript
const renderList = () => {
  const el = document.querySelector("#list");
  el.innerHTML = todos.map((t, i) => `<li>${i}: ${t}</li>`).join("");
};
 
const todos = createReactiveArray(["Learn Proxy", "Build demo"], renderList);
renderList();
 
todos.push("Ship it"); // List re-renders automatically
todos[0] = "Master Proxy"; // List re-renders automatically

Common Mistakes with Reactive Proxies

A few mistakes come up often when building this kind of system by hand, and each one silently breaks the automatic updates the pattern promises.

  • Mutating the original object instead of the proxy. If you keep a reference to the plain state object and write to it directly, the set trap never fires and the DOM never updates. Always change data through the proxy variable, not the object you originally wrapped.
  • Assuming nested objects are reactive too. Wrapping a state object with a nested user object only makes the top-level user key reactive. Writing to a property inside that nested object changes something no trap is watching, so nothing updates. Deep reactivity needs to wrap nested objects recursively, which frameworks like Vue do for you.
  • Forgetting to return true from the set trap. The Proxy specification requires trap handlers to report success. In strict mode, a set trap that returns a falsy value causes the assignment itself to throw a TypeError, even if the value was written correctly.
  • Doing expensive work inside a trap that fires often. A set trap that queries the DOM or recalculates a large list on every keystroke can make typing feel laggy. Batch or debounce expensive updates instead of running them on every single property write.

A Pattern, Not a Framework

This data binding pattern is the foundation of modern reactive frameworks. Vue 3 uses Proxy exactly like the examples above, plus a virtual DOM diffing layer on top.

MobX uses a similar dependency-tracking approach, and Alpine.js uses a lighter version.

Understanding the pattern matters even if you use a framework. When you write state.count = 5 in Vue and the template updates, you now know exactly what happens: a set trap fires, dependency lookup runs, and only the DOM nodes bound to that property re-render, not the whole component tree.

For the underlying Proxy API mechanics, see /javascript/advanced-javascript-proxies-complete-guide. For a full reactive UI built on this pattern, see /javascript/building-a-reactive-ui-with-the-js-observer.

Rune AI

Rune AI

Key Insights

  • Data binding means the UI updates automatically when the underlying data changes.
  • A Proxy set trap can detect every state change and trigger targeted DOM updates.
  • The pattern separates state logic from DOM manipulation: you change data, the proxy handles the rest.
  • Two-way binding adds an input event listener that writes back to the proxy on every keystroke.
  • Real frameworks build on this foundation with deep reactivity, batching, and virtual DOM diffing.
RunePowered by Rune AI

Frequently Asked Questions

How is this different from Vue or React reactivity?

Vue 3 uses Proxy internally for its reactivity system. React uses a different model (state setters trigger re-renders). The pattern shown here is the same foundation Vue 3 uses, simplified for learning. Understanding it helps you see what frameworks do under the hood.

Does this work with nested objects?

The basic version only tracks top-level properties. For deep reactivity, you need to recursively wrap nested objects and arrays in proxies. This is called deep reactive and frameworks like Vue handle it automatically.

Conclusion

Data binding with Proxy gives you a transparent, automatic way to keep the DOM in sync with your data. By intercepting every property change through a set trap, you can update exactly the right part of the UI without manual DOM calls scattered throughout your code. This is the same pattern that powers modern reactive frameworks, and understanding it demystifies what happens when you change a piece of state in Vue or MobX.