How to Build a Reactive System in JavaScript: From Object.defineProperty to Proxy
This article walks through creating a JavaScript reactive system for front‑end development, covering the classic Object.defineProperty approach, dependency tracking, Proxy‑based deep reactivity, integration with React hooks, performance tweaks, and advanced considerations such as async queues and compile‑time helpers.
Background
Junior developer Xiao Li interviews for a front‑end engineer position and is asked to hand‑write a reactive system that automatically runs a function when an object property changes.
First Challenge
The interview asks for a simple implementation using Object.defineProperty to intercept get and set operations.
var obj = { a: 1 };
var fn = () => {
console.log(obj.a);
};
// Goal: when obj.a changes, fn runs automaticallyThe core API is Object.defineProperty, which can intercept get and set.
Object.defineProperty can intercept set and get operations and supports three parameters: target (source object) key (property name) config (descriptor) get / set interceptors value enumerable writable configurable
Initial implementation:
let value;
const reactive = (obj) => {
value = obj.a;
Object.defineProperty(obj, "a", {
get: () => {
return value;
},
set: (val) => {
value = val;
fn();
}
});
return obj;
};
// test
var obj = reactive({ a: 1 });
var fn = () => {
console.log(obj.a);
};
fn();
obj.a = 2;Issues Arise
The interviewer points out that the set interceptor hard‑codes the execution of fn and only supports a single property. To support all properties, a dependency collection mechanism is needed.
Because JavaScript is single‑threaded, the current running function can be captured and stored as a dependency.
let preObj = {};
let runner = undefined;
let deps = {};
const reactive = (obj) => {
preObj = { ...obj };
Object.keys(obj).forEach((key) => {
Object.defineProperty(obj, key, {
get: () => {
if (runner) {
if (Array.isArray(deps[key])) {
deps[key].push(runner);
} else {
deps[key] = [runner];
}
}
return preObj[key];
},
set: (newValue) => {
preObj[key] = newValue;
(deps[key] || []).forEach((run) => run());
}
});
});
};
const run = (fn) => {
runner = fn;
fn();
runner = undefined;
};
const count = { value1: 1, value2: 2 };
reactive(count);
function print1() {
console.log("print1", count.value1, count.value2);
}
run(print1);
count.value1 = 2;
count.value2 = 3;Into Trouble
The interviewer notes that array mutation and deep objects are not handled.
Tip: array mutation refers to methods like push that modify the original array.
Two solutions are suggested:
Rewrite all mutating array methods and re‑apply reactivity inside them.
Use the newer Proxy feature.
Differences between Proxy and Object.defineProperty: Proxy can intercept the whole object; defineProperty works per property. Proxy is not IE‑compatible; defineProperty fails in IE8 and below. Proxy natively supports arrays; defineProperty requires extra tricks. Proxy does not modify the original object; defineProperty does.
Implementation using Proxy:
const isObj = (_) => _ && typeof _ === "object";
const proxyDeps = new WeakMap();
let runner = undefined;
export const makeProxy = (target) => {
proxyDeps.set(target, []);
return new Proxy(target, {
get: (target, key) => {
const deps = proxyDeps.get(target) || {};
if (typeof runner === "function") {
if (!deps[key]) deps[key] = [];
deps[key].push(runner);
proxyDeps.set(target, deps);
}
return target[key];
},
set: (target, key, value) => {
const deps = proxyDeps.get(target) || {};
const oldValue = target[key];
target[key] = value;
if (oldValue !== target[key]) {
(deps[key] || []).forEach((dep) => dep());
}
return true;
}
});
};
export const reactive = (target) => {
if (!isObj(target)) {
throw Error("Only objects and arrays are supported");
}
Object.entries(target).forEach(([key, value]) => {
if (isObj(value)) {
target[key] = reactive(value);
}
});
return makeProxy(target);
};
export const reactiveRunner = (fn) => {
runner = fn;
fn();
runner = undefined;
};
const count = reactive({ value: 1 });
function print1() {
console.log("print1", count.value);
}
reactiveRunner(print1);
count.value = 2;Framework Integration
To use the reactive system inside a React Hook component, a higher‑order component watch is created.
import React, { useEffect, useRef, useState } from "react";
import { runner } from "./reactive";
export const watch = (Component) => {
return React.memo((props) => {
const [, setUpdateCount] = useState(0);
const mountedRef = useRef(false);
const update = () => {
setUpdateCount((pre) => pre + 1);
};
if (!mountedRef.current) {
runner = update;
}
useEffect(() => {
runner = undefined;
}, []);
return <Component {...props} />;
});
};The usage is similar to higher‑order components in Redux‑Saga or DVA.
Refining
The interviewer asks three deeper questions about component‑level dependency tracking, cleanup on unmount, and batching updates. The proposed solutions include:
Stack‑based runner management.
WeakMap to map runners to reactive objects for cleanup.
A task queue that processes updates asynchronously.
export const makeProxy = (proxyTarget) => {
proxyDeps.set(proxyTarget, {});
return new Proxy(proxyTarget, {
get: (target, key, receiver) => {
const current = currentRunner();
const deps = proxyDeps.get(target) || {};
const value = target[key];
if (typeof current === "function") {
// collect dependency
proxyDeps.set(target, current);
// reverse map for cleanup
proxyDepsKey.set(current, target);
}
return value;
},
set: (target, key, value, receiver) => {
const deps = proxyDeps.get(target) || {};
const oldValue = target[key];
target[key] = isObj(value) ? reactive(value) : value;
if (oldValue !== target[key]) {
// add to async queue
queue.add(/* ... */);
}
return true;
}
});
}; // run.js – manages the runner stack
import { runningFN, proxyDepsKey, proxyDeps } from "./constants";
export const currentRunner = () => {
return runningFN[runningFN.length - 1];
};
export const run = (fn) => {
run.start(fn);
fn();
run.end();
};
run.start = (fn) => {
runningFN.push(fn);
};
run.end = (fn) => {
runningFN.pop(fn);
};
run.destory = (fn) => {
// find objects the fn depends on via proxyDepsKey and remove fn from their deps
}; // Simple async queue implementation
class Queue {
constructor() {
this.queue = new Set();
this.status = "stop";
}
add(fn) {
this.queue.add(fn);
if (this.status === "stop") {
this.status = "pending";
Promise.resolve().then(() => {
this.status = "running";
Array.from(this.queue).forEach((fn) => fn());
this.status = "stop";
});
}
}
}
export default new Queue();Performance Optimization
Further thoughts include avoiding duplicate dependency collection with a Map, lazy deep‑reactivity, using RAF instead of Promise for the async queue, and acknowledging that Proxy is slightly slower but more adaptable.
// primitive reactive example
const num = reactive(0);
console.log(num);
// after compilation
const num = reactive({ value: 0 });
console.log(num.value);Peak Optimization
Advanced topics discussed:
How to merge re‑renders when both reactive updates and component updates occur.
Concurrent rendering queues.
Potential compile‑time injection via Babel plugins.
export default function App() {
const [value, setValue] = useState(0);
return <button onClick={() => setValue(pre => pre + 1)}>test{value}</button>;
}Interview Result
The interview concludes after about an hour, leaving the candidate to reflect on the challenges.
Appendix
Source code: https://codesandbox.io/s/fronted-share-202207-22gic1
Formily: https://formilyjs.org
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Goodme Frontend Team
Regularly sharing the team's insights and expertise in the frontend field
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
