Data Binding with JS Proxies Complete Guide

Build reactive data binding systems using JavaScript Proxies. Covers one-way and two-way binding, observable state management, computed properties, dependency tracking, batch updates, deep reactivity, and building a minimal reactive framework from scratch.

JavaScriptadvanced
18 min read

JavaScript Proxies enable reactive data binding by intercepting property changes and automatically propagating updates to the UI or dependent computations. This guide builds a reactive system from scratch using Proxy traps, dependency tracking, and batch scheduling.

For the fundamentals of the Proxy API, see Advanced JavaScript Proxies Complete Guide.

Observable State with Proxy

javascriptjavascript
// Core reactive primitive: make an object observable
// When any property changes, notify all watchers
 
function reactive(target) {
  const subscribers = new Map(); // property -> Set<callback>
 
  return new Proxy(target, {
    get(obj, property, receiver) {
      // Track dependencies if there's an active effect
      if (activeEffect) {
        if (!subscribers.has(property)) {
          subscribers.set(property, new Set());
        }
        subscribers.get(property).add(activeEffect);
      }
      const value = Reflect.get(obj, property, receiver);
      // Deep reactivity: wrap nested objects
      if (typeof value === "object" && value !== null) {
        return reactive(value);
      }
      return value;
    },
 
    set(obj, property, value, receiver) {
      const oldValue = obj[property];
      const result = Reflect.set(obj, property, value, receiver);
 
      // Only notify if value actually changed
      if (oldValue !== value && subscribers.has(property)) {
        const subs = subscribers.get(property);
        for (const callback of subs) {
          callback();
        }
      }
 
      return result;
    }
  });
}
 
// Effect tracking system
let activeEffect = null;
 
function effect(fn) {
  activeEffect = fn;
  fn(); // Run once to collect dependencies
  activeEffect = null;
}
 
// USAGE
const state = reactive({ count: 0, message: "Hello" });
 
effect(() => {
  console.log(`Count is: ${state.count}`);
});
// Immediately logs: "Count is: 0"
 
state.count = 1; // Logs: "Count is: 1"
state.count = 2; // Logs: "Count is: 2"
 
// Changing 'message' does NOT trigger the count effect
state.message = "World"; // No log (effect only depends on 'count')

Dependency Tracking

javascriptjavascript
// Advanced dependency tracking with cleanup and precise invalidation
 
class ReactiveSystem {
  #targetMap = new WeakMap(); // target -> Map<property, Set<effect>>
  #activeEffect = null;
  #effectStack = [];
 
  reactive(target) {
    const self = this;
 
    return new Proxy(target, {
      get(obj, property, receiver) {
        self.#track(obj, property);
        const value = Reflect.get(obj, property, receiver);
        if (typeof value === "object" && value !== null) {
          return self.reactive(value);
        }
        return value;
      },
 
      set(obj, property, value, receiver) {
        const oldValue = obj[property];
        const result = Reflect.set(obj, property, value, receiver);
        if (oldValue !== value) {
          self.#trigger(obj, property);
        }
        return result;
      },
 
      deleteProperty(obj, property) {
        const hadKey = property in obj;
        const result = Reflect.deleteProperty(obj, property);
        if (hadKey) {
          self.#trigger(obj, property);
        }
        return result;
      }
    });
  }
 
  #track(target, property) {
    if (!this.#activeEffect) return;
 
    let depsMap = this.#targetMap.get(target);
    if (!depsMap) {
      depsMap = new Map();
      this.#targetMap.set(target, depsMap);
    }
 
    let deps = depsMap.get(property);
    if (!deps) {
      deps = new Set();
      depsMap.set(property, deps);
    }
 
    deps.add(this.#activeEffect);
    // Track reverse dependency for cleanup
    this.#activeEffect._deps.add(deps);
  }
 
  #trigger(target, property) {
    const depsMap = this.#targetMap.get(target);
    if (!depsMap) return;
 
    const deps = depsMap.get(property);
    if (!deps) return;
 
    // Copy to avoid infinite loops if effect modifies same property
    const effectsToRun = new Set(deps);
    for (const eff of effectsToRun) {
      if (eff !== this.#activeEffect) {
        eff();
      }
    }
  }
 
  effect(fn) {
    const effectFn = () => {
      // Cleanup old dependencies
      for (const dep of effectFn._deps) {
        dep.delete(effectFn);
      }
      effectFn._deps.clear();
 
      // Push to stack (handles nested effects)
      this.#effectStack.push(this.#activeEffect);
      this.#activeEffect = effectFn;
 
      try {
        fn();
      } finally {
        this.#activeEffect = this.#effectStack.pop();
      }
    };
 
    effectFn._deps = new Set();
    effectFn(); // Initial run
    return effectFn;
  }
}
 
// USAGE
const system = new ReactiveSystem();
const store = system.reactive({
  firstName: "Alice",
  lastName: "Smith",
  items: [1, 2, 3]
});
 
system.effect(() => {
  console.log(`Full name: ${store.firstName} ${store.lastName}`);
});
 
store.firstName = "Bob"; // Logs: "Full name: Bob Smith"

Computed Properties

javascriptjavascript
// Computed values derive from reactive state and cache their result
// They only re-evaluate when their dependencies change
 
class Computed {
  #getter;
  #value;
  #dirty = true;
  #effect;
 
  constructor(getter, system) {
    this.#getter = getter;
 
    // Create an effect that marks this computed as dirty
    this.#effect = system.effect(() => {
      // Running the getter tracks dependencies
      if (this.#dirty) {
        this.#value = this.#getter();
        this.#dirty = false;
      }
    });
  }
 
  get value() {
    return this.#value;
  }
 
  invalidate() {
    this.#dirty = true;
  }
}
 
// STANDALONE COMPUTED IMPLEMENTATION
function computed(getter) {
  let cachedValue;
  let dirty = true;
 
  // Create a reactive wrapper that tracks when dependencies change
  const runner = () => {
    if (dirty) {
      cachedValue = getter();
      dirty = false;
    }
    return cachedValue;
  };
 
  // Mark dirty when any dependency changes
  const effectFn = () => {
    dirty = true;
    getter(); // Re-track dependencies
  };
 
  // Subscribe to changes
  activeEffect = effectFn;
  getter(); // Initial dependency collection
  activeEffect = null;
 
  return {
    get value() {
      return runner();
    }
  };
}
 
// USAGE WITH REACTIVE STATE
const state = reactive({
  price: 100,
  quantity: 3,
  taxRate: 0.08
});
 
const subtotal = computed(() => state.price * state.quantity);
const tax = computed(() => subtotal.value * state.taxRate);
const total = computed(() => subtotal.value + tax.value);
 
console.log(total.value); // 324
 
state.quantity = 5;
console.log(total.value); // 540
 
state.taxRate = 0.1;
console.log(total.value); // 550

Two-Way Data Binding

javascriptjavascript
// Two-way binding: model changes update the DOM, DOM changes update the model
 
class TwoWayBinder {
  #bindings = new Map();
  #state;
 
  constructor(initialState) {
    const self = this;
 
    this.#state = new Proxy(initialState, {
      set(target, property, value) {
        const result = Reflect.set(target, property, value);
 
        // Update all DOM elements bound to this property
        const elements = self.#bindings.get(property);
        if (elements) {
          for (const { element, attribute } of elements) {
            self.#updateElement(element, attribute, value);
          }
        }
 
        return result;
      }
    });
  }
 
  get state() {
    return this.#state;
  }
 
  // Bind a state property to a DOM element
  bind(property, element, attribute = "textContent") {
    if (!this.#bindings.has(property)) {
      this.#bindings.set(property, []);
    }
    this.#bindings.get(property).push({ element, attribute });
 
    // Set initial value
    this.#updateElement(element, attribute, this.#state[property]);
 
    // For input elements, listen for changes (DOM -> model)
    if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
      const eventType = element.type === "checkbox" ? "change" : "input";
      element.addEventListener(eventType, () => {
        const value = element.type === "checkbox"
          ? element.checked
          : element.value;
        this.#state[property] = value;
      });
    }
  }
 
  #updateElement(element, attribute, value) {
    if (attribute === "value" && "value" in element) {
      if (element.value !== String(value)) {
        element.value = value;
      }
    } else if (attribute === "checked") {
      element.checked = Boolean(value);
    } else if (attribute === "textContent") {
      element.textContent = value;
    } else {
      element.setAttribute(attribute, value);
    }
  }
}
 
// Usage:
// const binder = new TwoWayBinder({ name: '', age: 0, agree: false });
// binder.bind('name', document.querySelector('#name-input'), 'value');
// binder.bind('name', document.querySelector('#name-display'));
// binder.bind('agree', document.querySelector('#agree-checkbox'), 'checked');
//
// binder.state.name = "Alice"; // Updates both input and display
// User types in input -> state.name updates -> display updates

Batch Updates

javascriptjavascript
// Batch multiple state changes into a single update cycle
// Prevents intermediate renders and wasted computations
 
class BatchReactiveSystem {
  #subscribers = new Map();
  #pendingEffects = new Set();
  #isBatching = false;
  #isFlushing = false;
 
  reactive(target) {
    const self = this;
 
    return new Proxy(target, {
      get(obj, property, receiver) {
        if (activeEffect) {
          if (!self.#subscribers.has(property)) {
            self.#subscribers.set(property, new Set());
          }
          self.#subscribers.get(property).add(activeEffect);
        }
        return Reflect.get(obj, property, receiver);
      },
 
      set(obj, property, value, receiver) {
        const oldValue = obj[property];
        const result = Reflect.set(obj, property, value, receiver);
 
        if (oldValue !== value) {
          const subs = self.#subscribers.get(property);
          if (subs) {
            for (const effect of subs) {
              if (self.#isBatching) {
                self.#pendingEffects.add(effect);
              } else {
                effect();
              }
            }
          }
        }
 
        return result;
      }
    });
  }
 
  batch(fn) {
    this.#isBatching = true;
    try {
      fn();
    } finally {
      this.#isBatching = false;
      this.#flush();
    }
  }
 
  #flush() {
    if (this.#isFlushing) return;
    this.#isFlushing = true;
 
    try {
      for (const effect of this.#pendingEffects) {
        effect();
      }
    } finally {
      this.#pendingEffects.clear();
      this.#isFlushing = false;
    }
  }
}
 
// USAGE
const sys = new BatchReactiveSystem();
const form = sys.reactive({ name: "", email: "", age: 0 });
 
let renderCount = 0;
effect(() => {
  renderCount++;
  console.log(`Render #${renderCount}: ${form.name} (${form.email})`);
});
 
// WITHOUT batching: 3 renders
form.name = "Alice";
form.email = "alice@example.com";
form.age = 30;
// Render #2, #3, #4
 
// WITH batching: 1 render
sys.batch(() => {
  form.name = "Bob";
  form.email = "bob@example.com";
  form.age = 25;
});
// Single render after batch completes
 
// MICROTASK-BASED AUTO-BATCHING
function autoReactive(target) {
  let pendingEffects = new Set();
  let scheduled = false;
 
  const subscribers = new Map();
 
  return new Proxy(target, {
    get(obj, property, receiver) {
      if (activeEffect) {
        if (!subscribers.has(property)) {
          subscribers.set(property, new Set());
        }
        subscribers.get(property).add(activeEffect);
      }
      return Reflect.get(obj, property, receiver);
    },
 
    set(obj, property, value, receiver) {
      const oldValue = obj[property];
      const result = Reflect.set(obj, property, value, receiver);
 
      if (oldValue !== value) {
        const subs = subscribers.get(property);
        if (subs) {
          for (const eff of subs) {
            pendingEffects.add(eff);
          }
          // Schedule a microtask flush (automatic batching)
          if (!scheduled) {
            scheduled = true;
            queueMicrotask(() => {
              for (const eff of pendingEffects) eff();
              pendingEffects.clear();
              scheduled = false;
            });
          }
        }
      }
 
      return result;
    }
  });
}
Binding PatternDirectionUse CaseComplexity
One-way (model to view)State -> DOMDisplay data, labels, listsLow
One-way (view to model)DOM -> StateForm inputs, user actionsLow
Two-wayState <-> DOMForm fields with live previewMedium
ComputedDerived -> DependentsCalculated totals, filtersMedium
Deep reactiveNested objectsComplex state treesHigh
Rune AI

Rune AI

Key Insights

  • Reactive state uses the Proxy get trap for dependency tracking and the set trap for change notification: Effects automatically subscribe to their dependencies during execution
  • Dependency tracking with cleanup prevents stale subscriptions when an effect's dependencies change between runs: Each re-execution clears old dependencies and collects new ones
  • Computed properties cache derived values and only re-evaluate when their reactive dependencies actually change: This avoids redundant calculations in complex dependency chains
  • Batch updates collect all pending effects and run them once, preventing intermediate renders: Microtask-based auto-batching provides this benefit transparently
  • Deep reactivity wraps nested objects lazily on access rather than eagerly traversing the entire tree: This keeps initialization fast while still tracking nested property changes
RunePowered by Rune AI

Frequently Asked Questions

How does Vue.js use Proxy for reactivity?

Vue 3's reactivity system is built directly on JavaScript Proxy. The `reactive()` function wraps objects in a Proxy that uses `get` traps for dependency tracking and `set` traps for triggering updates. Vue tracks which components and computed properties read which reactive properties. When a property changes, only the effects that depend on that specific property re-run. Vue's implementation handles edge cases like arrays (intercepting push, pop, splice), Maps, Sets, and nested objects with lazy deep reactivity.

What is the performance cost of Proxy-based reactivity?

The main costs are the trap overhead per property access (~5-50x slower than direct access) and memory for dependency tracking data structures. In practice, this overhead is negligible because most CPU time is in DOM manipulation, not property reads. The key optimization is fine-grained tracking: only re-running effects whose specific dependencies changed, rather than re-rendering everything. Batch updates reduce the number of effect executions. For very large arrays (100K+ items), consider using shallowReactive to avoid deep proxying.

How do I handle arrays with reactive Proxies?

rrays require special handling because mutating methods (push, pop, splice, sort, reverse) modify length and indices. The Proxy `set` trap catches index assignments and length changes. For methods, intercept them in the `get` trap and wrap them to trigger notifications after the mutation completes. Track both index-level dependencies (for `arr[i]`) and length dependencies (for `arr.length` and iteration). Vue 3's implementation wraps array mutating methods to batch all index changes into a single notification.

Can Proxy-based reactivity cause memory leaks?

Yes, if effects are not properly cleaned up. Each effect holds references to the reactive objects it depends on, and the reactive objects hold references to their subscribers (effects). If a component is destroyed but its effects are not removed from the subscriber sets, those effects keep the component alive in memory. The solution is to provide a cleanup mechanism: each effect should track which dependency sets it belongs to and remove itself when disposed. Vue's `watchEffect` returns a stop function for this purpose.

Conclusion

Proxy-based data binding enables reactive programming in JavaScript through automatic dependency tracking and change notification. The pattern starts with wrapping objects in Proxy, tracking which effects read which properties, and triggering re-evaluation when properties change. Computed properties add derived values with caching. Batching prevents intermediate updates. For the Proxy API fundamentals, see Advanced JavaScript Proxies Complete Guide. For intercepting specific object operations, explore Intercepting Object Calls with JS Proxy Traps.