DEV Community

Samuel Rouse
Samuel Rouse

Posted on

Reactive Data With JavaScript Proxies

Stale data is one of the major headaches in modern web development. Especially in React, where hooks require you to manage dependencies manually. Other libraries adopted a variety of techniques to do this automatically.

Libraries that automatically update values when their dependencies change are often referred to as reactive.

JavaScript Proxies provide a lot of interesting capabilities, so today let's take a look at one way we might leverage Proxies to identify and maintain reactive data. We aren't building out an entire framework, but digging into things out ourselves can help us better understand the language and tools we use.

Scope

I want to walk through building a basic data layer with Proxies. I encourage you to take that base and make changes beyond the scope of the article. I use RunJS to write and test the code in this article. It can be a great tool for trying out coding concepts like this, and I recommend it if you want to follow along with each step and try out the code.

Foundation

I'm starting very roughly from a concept I liked from the moment I first saw the Vue library: computed properties. Providing a clear storage distinction between static and computed data, and handling caching and invalidation of these computed values was something that interested me, especially because the consumer doesn't need to know that one is a property and one is a function under the hood.

Proxies are a great tool for this; they can abstract away some of that complexity in the data layer. In fact, Vue 3 uses proxies for reactivity.

I'm not just copying from Vue's design, though. I want to make something of my own to try to understand what is necessary.

Let's dig into some requirements.

Requirements

  • Create a Proxy object.
  • When properties are set, separate functions into "computed" and everything else into "static"
  • Return values when properties are accessed, executing the functions in "computed" through normal property access (no user function calls).
  • Allow computed functions access to the proxy object so it can consume other values on the object.
  • Cache function calls so we don't repeat computed work if not necessary.
  • Track dependencies accessed by computed properties from the proxy.
  • When properties are updated, use the dependencies information to clear any cached values.

There are more requirements necessary to create a robust, production-ready system, but this is a good starting point.

The Data Underneath

From our requirements, we can gather that we'll need some objects:

  • static - Static values
  • computed - Functions for computed properties
  • cache - Stored results of computed functions
  • deps - Some way of storing the relationships

I went with deps over dependencies mostly for width considerations in our code. Let's create that starting object.

There are lots of ways we could arrange this data structure, but lets start here.

const source = {
  static: {},
  computed: {},
  cache: {},
  deps: {},
};
Enter fullscreen mode Exit fullscreen mode

Create a Proxy

Proxies accept a target object (or function!) and a handler; an object which defines how the Proxy works. Handlers contain zero or more traps - a term that dates back at least to 1955 which was used to interrupt the usual execution of a program, typically for debugging. These traps will prevent the normal execution of property access to do all the interesting things we want from our Proxy. There are a large number of different traps available to us – the object internal methods - but we're going to focus on get and set.

const data = new Proxy(source, {
  get(target, property, receiver) {
    // Temporarily do the default
    return Reflect.get(target, property);
  },
  set(target, property, value, receiver) {
    // Temporarily do the default
    return Reflect.set(target, property, value);
  },
});
Enter fullscreen mode Exit fullscreen mode

Some notes on these function arguments:

  • target - The target object specified when we created the proxy. This avoids any need for a closure or external reference to access the original object.
  • property - The name of the property being accessed on the proxy.
  • value - For set we need to pass the actual value to be set.
  • receiver - In some cases we will need to refer back to the object that received the property access request. This – our proxy – is the receiver.

Now we have a proxy object data that will access the target source. It has traps for get and set, but they don't do anything special yet. We've temporarily inserted Reflect calls to provide the original ability of an object getter and setter. At this point the proxy is just a pass-through for normal operations.

// Set a property on the proxy
data.foo = 2;

// The same thing happens on the source
source.foo; // 2
Enter fullscreen mode Exit fullscreen mode

Segmented Set

We specified we want to separate computed functions from static. We could let the user set these directly, but I want to abstract the knowledge of the source object where possible. So let's update the set trap.

const data = new Proxy(source, {
  get(target, property, receiver) {
    // Temporarily do the default
    return Reflect.get(target, property);
  },
  set(target, property, value, receiver) {
    if (typeof value === 'function') {
      target.computed[property] = value;
      return value;
    }
    target.static[property] = value;
    return value;
  },
});
Enter fullscreen mode Exit fullscreen mode

Now it will save values and functions to different places.

// Set a property on the proxy
data.foo = 2;
data.bar = () => 3;

// This goes into values on the source
source.static.foo; // 2

// But we haven't updated our getter
data.foo; // undefined
Enter fullscreen mode Exit fullscreen mode

However, our getter needs work.

Return Values

We have two different places data can live. At first I was tempted to use nullish coalescing to check for static or computed...

get(target, property, receiver) {
  return target.static[property]
    ?? target.computed[property]();
},
Enter fullscreen mode Exit fullscreen mode

But this would cause incorrect behavior for intentional nullish values. We need to use in or, better yet, Object.hasOwn to get values that exist on the correct object, even if they are null, undefined, or falsy.

Return Code

const data = new Proxy(source, {
  get(target, property, receiver) {
    if (Object.hasOwn(target.static, property)) {
      return target.static[property];
    }
    if (Object.hasOwn(target.computed, property)) {
      return target.computed[property]();
    }
    // Default is undefined, just like normal.
  },
  set(target, property, value, receiver) {
    if (typeof value === 'function') {
      target.computed[property] = value;
      return value;
    }
    target.static[property] = value;
    return value;
  },
});
Enter fullscreen mode Exit fullscreen mode

Return Validation

You'll notice we executed the computed entry rather than returning the function. This lets us avoid knowing if a value is computed or not.

data.foo = 2;
data.bar = () => 3;

data.foo; // 2
data.bar; // 3
Enter fullscreen mode Exit fullscreen mode

Computed Data Access

Computed values are not very useful if they can't access the other data to perform work, though, so we need to update our get trap to support that change. There are a couple ways we could do that.

We can pass the data proxy to the computed functions as the first argument. This gives them full access to static and computed values. This is where the receiver argument becomes useful.

Computed Code

const data = new Proxy(source, {
  get(target, property, receiver) {
    if (Object.hasOwn(target.static, property)) {
      return target.static[property];
    }
    if (Object.hasOwn(target.computed, property)) {
      // Log the computation for demonstration
      console.log(`Computing ${property}`);
      return target.computed[property](receiver);
    }
    // Default is undefined, just like normal.
  },
  set(target, property, value, receiver) {
    if (typeof value === 'function') {
      target.computed[property] = value;
      return value;
    }
    target.static[property] = value;
    return value;
  },
});
Enter fullscreen mode Exit fullscreen mode

Computed Validation

Now, we can use static and computed properties as values.

// Some static values;
data.radius = 3;
data.base = 10;
data.height = 5;

// Some computed values.
data.areaCircle = (data) => data.radius ** 2 * Math.PI;
data.areaRect = (data) => data.base * data.height;
data.areaTriangle = (data) => data.areaRect / 2;

data.radius;       // 3

data.areaCircle;   // 28.274333882308138
'Computing areaCircle'

data.areaRect;     // 50
'Computing areaRect'

data.areaTriangle; // 25
'Computing areaTriangle'
'Computing areaRect'
Enter fullscreen mode Exit fullscreen mode

Note that areaTriangle uses areaRect. Because we passed the proxy and not the "source" object, we can consume computed values inside computed values. But this means we're computing areaRect twice.

Cache Functions

While our examples are pretty trivial, computed values might end be expensive operations, so caching the output will prevent us from doing the same work repeatedly. Adding caching is very straightforward. We can check for a cached value first, and if one is not found perform the work and add it to the cache before returning.

Caching Code

const data = new Proxy(source, {
  get(target, property, receiver) {
    if (Object.hasOwn(target.static, property)) {
      return target.static[property];
    }
    if (Object.hasOwn(target.cache, property)) {
      return target.cache[property];
    }
    if (Object.hasOwn(target.computed, property)) {
      // Log the computation for demonstration
      console.log(`Computing ${property}`);

      const output = target.computed[property](receiver);
      target.cache[property] = output;
      return output;
    }
    // Default is undefined, just like normal.
  },
  set(target, property, value, receiver) {
    if (typeof value === 'function') {
      target.computed[property] = value;
      return value;
    }
    target.static[property] = value;
    return value;
  },
});
Enter fullscreen mode Exit fullscreen mode

Cache Validation

Now we'll only process areaRect once. The second time it is accessed, we use the cache.

data.radius;        // 3

data.areaCircle;   // 28.274333882308138
'Computing areaCircle'

data.areaRect;     // 50
'Computing areaRect'

data.areaTriangle; // 25
'Computing areaTriangle'
Enter fullscreen mode Exit fullscreen mode

Track Dependencies

Now we have computed properties and caching, but if we change something our cache can get out of date. A naive cache strategy would be to wipe the whole cache whenever any value is set, but that's not very helpful. What we need is to track our dependencies. And proxies are well-suited to solve this problem.

We've already seen that we can intercept property access with a get trap. So we can see each property that is accessed. We can use a second proxy specific to computed properties to solve this.

Dependency Proxy

const tracker = new Set();
const depsTracker = new Proxy(receiver, {
  get(depTarget, depProp) {
    tracker.add(depProp);
    return depTarget[depProp];
  }
});
Enter fullscreen mode Exit fullscreen mode

This get-only proxy will record each property name accessed using a Set to avoid duplicates. This adds some overhead to using a computed value, but that's why we have caching!

Once we have the tracked dependencies, we can add them to the deps object. Because we're concerned about the dependencies changing, we want the keys inside deps to be the properties we depend on. We can take the tracker and populate deps from it.

tracker.forEach(dependency => {
  target.deps[dependency] ??= new Set();
  target.deps[dependency].add(property);
});
Enter fullscreen mode Exit fullscreen mode
// Something very roughly like this
const deps = {
 radius: ['areaCircle'],
};
Enter fullscreen mode Exit fullscreen mode

Dependency Code

Let's put that all together. I've also eliminated the console.log in the computed logic. Adding log statements can help you understand the order of operations, but you can add your own if you are interested.

const data = new Proxy(source, {
  get(target, property, receiver) {
    if (Object.hasOwn(target.static, property)) {
      return target.static[property];
    }
    if (Object.hasOwn(target.cache, property)) {
      return target.cache[property];
    }
    if (Object.hasOwn(target.computed, property)) {
      const tracker = new Set();
      const depsTracker = new Proxy(receiver, {
        get(depTarget, depProp) {
          tracker.add(depProp);
          return depTarget[depProp];
        }
      });

      const output = target.computed[property](depsTracker);
      target.cache[property] = output;

      tracker.forEach(dependency => {
        target.deps[dependency] ??= new Set();
        target.deps[dependency].add(property);
      });

      return output;
    }
    // Default is undefined, just like normal.
  },
  set(target, property, value, receiver) {
    if (typeof value === 'function') {
      target.computed[property] = value;
      return value;
    }
    target.static[property] = value;
    return value;
  },
});
Enter fullscreen mode Exit fullscreen mode

Validated Dependencies

Now, let's run the same code:

// Some static values;
data.radius = 3;
data.base = 10;
data.height = 5;

// Some computed values.
data.areaCircle = (data) => data.radius ** 2 * Math.PI;
data.areaRect = (data) => data.base * data.height;
data.areaTriangle = (data) => data.areaRect / 2;

data.radius;       // 3
data.areaCircle;   // 28.274333882308138
data.areaRect;     // 50
data.areaTriangle; // 25
Enter fullscreen mode Exit fullscreen mode

And check our deps object:

source.deps;

{
  radius: Set(1) {
    'areaCircle',
  },
  base: Set(1) {
    'areaRect',
  },
  height: Set(1) {
    'areaRect',
  },
  areaRect: Set(1) {
    'areaTriangle',
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We can see that we tracked each direct dependency. We aren't using that information, yet, just collecting it, so we'll need to look at our set trap for that.

Clear Cache

When we call set we know the property that is changing, so it's a perfect time to check the deps to see if we need to remove anything from the cache.

set(target, property, value, receiver) {
  // Make a function for recursion
  const depClean = (prop) => {
    target.deps[prop]?.forEach(dep => {
      delete target.cache[dep];
      depClean(dep);
    });
  };

  depClean(property);

  if (typeof value === 'function') {
    target.computed[property] = value;
    return value;
  }
  target.static[property] = value;
  return value;
},
Enter fullscreen mode Exit fullscreen mode

We created a small recursive function to ensure any nested dependencies – like areaTriangle – are cleared. We didn't bother with any "exists" checks beforehand. A property should have no dependencies to check if it is being set for the first time.

Invalidation Code

const data = new Proxy(source, {
  get(target, property, receiver) {
    if (Object.hasOwn(target.static, property)) {
      return target.static[property];
    }
    if (Object.hasOwn(target.cache, property)) {
      return target.cache[property];
    }
    if (Object.hasOwn(target.computed, property)) {
      const tracker = new Set();
      const depsTracker = new Proxy(receiver, {
        get(depTarget, depProp) {
          tracker.add(depProp);
          return depTarget[depProp];
        }
      });

      const output = target.computed[property](depsTracker);
      target.cache[property] = output;

      tracker.forEach(dependency => {
        target.deps[dependency] ??= new Set();
        target.deps[dependency].add(property);
      });

      return output;
    }
    // Default is undefined, just like normal.
  },
  set(target, property, value, receiver) {
    // Make a function for recursion
    const depClean = (prop) => {
      target.deps[prop]?.forEach(dep => {
        delete target.cache[dep];
        depClean(dep);
      });
    };

    depClean(property);

    if (typeof value === 'function') {
      target.computed[property] = value;
      return value;
    }
    target.static[property] = value;
    return value;
  },
});
Enter fullscreen mode Exit fullscreen mode

Invalidation…Validation

Let's perform some operations to populate the cache, and then prove that the cache is cleared base on dependencies.

// Access properties to create cache entries
data.radius;       // 3
data.areaCircle;   // 28.274333882308138
data.areaRect;     // 50
data.areaTriangle; // 25

// View the cache
source.cache;

{
  areaCircle: 28.274333882308138,
  areaRect: 50,
  areaTriangle: 25
}

// Change a property with dependencies
data.base = 8;

// View the updated cache
source.cache;

{
  areaCircle: 28.274333882308138
}
Enter fullscreen mode Exit fullscreen mode

We can see that changing base cleared both areaRect and areaTriangle, so our recursive dependency check worked.

Now, if we call those computed properties again, their values should change.

data.areaRect;     // 40
data.areaTriangle; // 20
Enter fullscreen mode Exit fullscreen mode

Result

We've now worked through each of our requirements, and we have a basic reactive data model. We can support static and computed properties. We can access computed properties without knowing there are functions underneath. We cache those computed values, and create a dependency tree that lets us invalidate that cache when properties are updated.

Let's take a look at the completed code.

const source = {
  static: {},
  computed: {},
  cache: {},
  deps: {},
};

const data = new Proxy(source, {
  get(target, property, receiver) {
    // Check for static properties first
    if (Object.hasOwn(target.static, property)) {
      return target.static[property];
    }
    // Check the cache for saved computed properties
    if (Object.hasOwn(target.cache, property)) {
      return target.cache[property];
    }
    if (Object.hasOwn(target.computed, property)) {
      // Build the dependencies and cache the compute value
      const tracker = new Set();
      const depsTracker = new Proxy(receiver, {
        get(depTarget, depProp) {
          tracker.add(depProp);
          return depTarget[depProp];
        }
      });

      const output = target.computed[property](depsTracker);
      target.cache[property] = output;

      tracker.forEach(dependency => {
        target.deps[dependency] ??= new Set();
        target.deps[dependency].add(property);
      });

      return output;
    }
    // Default is undefined, just like normal.
  },
  set(target, property, value, receiver) {
    // Use recursion to clean nested dependencies
    const depClean = (prop) => {
      target.deps[prop]?.forEach(dep => {
        delete target.cache[dep];
        depClean(dep);
      });
    };

    depClean(property);

    if (typeof value === 'function') {
      target.computed[property] = value;
      return value;
    }
    target.static[property] = value;
    return value;
  },
});
Enter fullscreen mode Exit fullscreen mode

Proxy Proof-of-Concept

All this in under 60 lines of code! While it doesn't do everything, proxies provided us the tools to build a system to support automatic dependency detection and caching quickly and relatively easily.

Your Turn?

There are plenty of things to add and improve in this design. Try your hand at adding onto the code and see where it takes you.

  • Add support for in and Object.keys from the proxy.
  • Add support for deleting properties.
  • Clean up dependencies when a property is deleted.
  • Only calculate the dependencies when the computed property changes.
  • Support changing properties from static to computed or vice versa.
  • Avoid dependency checks on initial set?
  • Should we re-compute values when dependencies change, or wait for them to be accessed?

Conclusion

Proxies provide nearly unlimited flexibility in JavaScript. Starting from a rough idea we built up a reactive data layer with caching and dependency tracking.

I hope you found this article interesting. Are there better ways to create this functionality? Is part of this confusing and needs better explanation? Let me know!

Top comments (0)