In this article

All code in this article series can be found on GitHub.

So far we've explored what proxies are and why they shouldn't be the first tool you reach for and how to use a proxy to hide properties on an object from outside observers. For this third part, we're going to look at using a proxy to listen for property changes and updating other data based on the changed property.

Handlers, triggers, watchers and more

To start out, we will only need to use the set handler function, since we only care about when a property is changed, not when it is accessed. We can set up a simple situation where a list of functions are all called whenever the set function is triggered:

const watchers = [];

const watchHandler = {
    set(target, prop, value, proxy) {
        target[prop] = value;

        watchers.forEach((fn) => fn(proxy));
    }
};

function watchable(obj) {
    return new Proxy(obj, watchHandler);
}

const obj = watchable({ prop: 1 });

We won't get very far with this code though, since now every object wrapped with this handler will call the same set of functions, meaning we are actually watching all proxied objects with all of our functions. There are quite a few methods we could use to handle this:

  1. We could wrap the creation of each proxy in a closure so that each references its own array of watchers, although this also requires the recreation of the handler function every time as well since it needs to close around the array.
  2. We could have each registered function associated with the object it is watching in our list of watchers and filter based on equality, although this leaves us with many references to the same object, potentially long after we care about it.
  3. We can use a WeakMap with each target as a key pointing to an array of functions.

A WeakMap allows us to maintain a reference to an object so long as it is referenced elsewhere. If the object exits scope or gets deleted, a WeakMap simply forgets about it at the same time, hence the Weak prefix. The other options above would require us to create some form of manual cleanup, either through "garbage collecting" it ourselves or by instructing those using this utility to call a method themselves when they are done with an object. When using a WeakMap, we will simply get the list of functions associated with that object:

const watchers = new WeakMap();

const watchHandler = {
    set(target, prop, value, proxy) {
        target[prop] = value;

        watchers.get(target).forEach(({ run, on }) => {
            if (!on.size || on.has(prop)) {
                return run(proxy);
            }
        })
    }
};

function watchable(target) {
    const watched = new Proxy(target, watchHandler);

    watchers.set(target, []);

    return watched;
}

function watch(obj, fn) {
    watchers.get(obj).push(fn);
}

const obj = watchable({ prop: 1 });

Now we can easily only watch the objects we want to… but maybe we also want to change a property on the same object we're watching. Currently that would cause an infinite call stack, but we can change that by allowing a watcher to specify a list of properties as dependencies, or a more scoped condition to trigger on:

const watchHandler = {
    set(target, prop, value) {
        target[prop] = value;

        watchers.get(target).forEach(({ run, on }) => {
            if (!on.size || on.has(prop)) {
                return run(proxied);
            }
        })
    }
};

function watch(obj, run, on) {
    watchers.get(obj).push({ run, on: new Set(on) });
}

We'll add a few final touches to ensure the object being watched is being tracked and give the developer a way to stop watching:

function watch(obj, run, on) {
    if (!watchers.has(obj)) {
        throw new Error('Objects that should be watched must first be registered as watchable.');
    }

    watchers.get(obj).push({ run, on: new Set(on) });

    return unwatch(obj, run);
}

function unwatch(obj, run) {
    return function() {
        const index = watchers.get(obj).findIndex(watcher => watcher.run === run);

        watchers.get(obj).splice(index, 1);
    }
}

And we can now watch our work in action:

const a = watchable({ prop: 0 });

const b = watchable({ prop: 0 });

const c = {};

watch(a, ({ prop }) => b.prop += prop, ['prop']);
watch(b, ({ prop }) => c.prop = prop * 2, ['prop']);
watch(a, ({ input1, input2 }) => input1 && input2 ? a.prop = input1 * input2 : null, ['input1', 'input2']);
console.log(a, b, c); // { prop: 0 } { prop: 0 } { }

a.prop++;
console.log(a, b, c); // { prop: 1 } { prop: 1 } { prop: 2 }

a.prop++;
console.log(a, b, c); // { prop: 2 } { prop: 3 } { prop: 6 }

a.prop++;
console.log(a, b, c); // { prop: 3 } { prop: 6 } { prop: 12 }

a.nothing = 123;
console.log(a, b, c); // { prop: 3, nothing: 123 } { prop: 6 } { prop: 12 }

a.input1 = 3;
a.input2 = 4;
console.log(a, b, c); // { input1: 3, input2: 4, prop: 12, nothing: 123 } { prop: 18 } { prop: 36 }

You can see that when a change does not affect a dependency, the watcher is not triggered. You can also see how we can chain together these effects between multiple objects. We could take this further by creating some utilities that would help with more complicated situations, like delaying updates, batching together changes or depending on different properties from multiple objects. I will leave the exploration of those ideas up to the reader, but I will share an example of creating a simplified spreadsheet interface that uses a very similar setup here.

To wrap up the series, we will look into how you can combine functional programming and proxies to emulate a language feature from Scala. Keep an eye out for the final installment! As a reminder, all code in this article series can be found on GitHub.