Skip to content
Go back

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

Obligatory counter example:

const s = new Stater();
const count = s.useState(0);

count.listen((value) => {
  document.querySelector('#count').textContent = value;
});

const add = document.querySelector('#plus');
add.addEventListener('click', () => {
	count.value++;
});

const minus = document.querySelector('#minus');
minus.addEventListener('click', () => {
	count.value--;
});
0

By no means is this a production-ready solution, but it’s a fun way to learn about the Proxy object and how to use it to create a simple reactive system.


TKAdjustableNumber (just for fun)

When you eat 3 cookies, you consume 150 calories. That’s 7% of your recommended daily calories.


Share this post on:

Previous Post
Missed CSS: accent color
Next Post
How I UI