Proxy Reactivity

kinda neat

The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object. - MDN

Here is a method for basic reactivity using a Proxy.

// our initial state
const initialState = {}
// a map of properties to their subscribers
const listeners = new Map();
// our proxy
const state = new Proxy(initialState, {
set(target, property, value) {
const old = target[property];
target[property] = value;
if(old !== value){
if (listeners.has(property)) {
// if we have subscribers for this property, run them
listeners.get(property).forEach(fn => fn(value));
}
}
return true;
}
});
// now our listener creator
const listen = (property, fn) => {
listeners.set(property, [...(listeners.get(property) || []), fn]);
}
// we can now listen to changes on x
listen("x", (v) => {
console.log("x changed to", v);
});
state.x = 10; // Triggers the listener
state.x = 10; // No trigger (same value)
state.x = 20; // Triggers the listener again

But then we could get super original and wrap this in a helper function. And since we’re hip let’s call it a hook instead of a wrapper function… let’s call it the useState hook

let stateId = 0;
const useState = (initialValue) => {
const id = stateId++;
const property = `state_${id}`;
// Initialize the state if it doesn't exist
// Remember: this is our Proxy
if (!(property in state)) {
state[property] = initialValue;
}
// Create a reactive variable
return {
_property: property,
get value() { return state[property]; },
set value(newValue) { state[property] = newValue; }
};
};

Now we can use our hook to create a counter.

// Example usage
const count = useState(0);
// key is now on ._property. not the greatest API but it works.
listen(count._property, (value) => {
console.log("Count changed to:", value);
});
// value is now on .value. not the greatest API but it works.
console.log(count.value); // 0
count.value = 10; // Triggers the listener
count.value = 10; // No trigger (same value)
count.value = 20; // Triggers the listener again
console.log(count.value); // 20

Replace the ugly parts

We’re building our own thing so let’s make it a little more specific to our needs.

const listen = (property, fn) => {
listeners.set(property, [...(listeners.get(property) || []), fn]);
if (property?._property) {
listeners.set(property._property,
[...(listeners.get(property._property) || []),
fn]);
}
}
// an now we can just.
listen(count, (value) => {
console.log("Count changed to:", value);
});

Then we could just turn this all into an easy to use class

class Stater {
constructor(initialState = {}) {
this.stateId = 0;
this.listeners = new Map();
// our proxy
this.state = new Proxy(initialState, {
set: (target, property, value) => {
const old = target[property];
target[property] = value;
if (old !== value) {
if (this.listeners.has(property)) {
// if we have subscribers for this property, run them
this.listeners.get(property).forEach((fn) => fn(value));
}
}
return true;
},
});
}
listen(stateObj, fn) {
if (stateObj && stateObj._property) {
this.listeners.set(stateObj._property, [
...(this.listeners.get(stateObj._property) || []),
fn,
]);
}
}
useState(initialValue) {
const property = `state_${this.stateId++}`;
const listenRef = this.listen.bind(this);
const stateRef = this.state;
if (!(property in stateRef)) {
stateRef[property] = initialValue;
}
return {
_property: property,
get value() {
return stateRef[property];
},
set value(newValue) {
stateRef[property] = newValue;
},
// simpler access to listener creation
listen(fn) {
listenRef(this, fn);
},
};
}
}
const s = new Stater();
const count = s.useState(0);
count.listen((value) => {
console.log("Count changed to:", value);
});
count.value = 10; // Count changed to: 10
count.value = 15;// Count changed to: 15