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.
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
// 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
// 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
// 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); // 550Two-Way Data Binding
// 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 updatesBatch Updates
// 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 Pattern | Direction | Use Case | Complexity |
|---|---|---|---|
| One-way (model to view) | State -> DOM | Display data, labels, lists | Low |
| One-way (view to model) | DOM -> State | Form inputs, user actions | Low |
| Two-way | State <-> DOM | Form fields with live preview | Medium |
| Computed | Derived -> Dependents | Calculated totals, filters | Medium |
| Deep reactive | Nested objects | Complex state trees | High |
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
Frequently Asked Questions
How does Vue.js use Proxy for reactivity?
What is the performance cost of Proxy-based reactivity?
How do I handle arrays with reactive Proxies?
Can Proxy-based reactivity cause memory leaks?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.