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: {},
};
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);
},
});
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
- Forset
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
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;
},
});
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
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]();
},
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;
},
});
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
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;
},
});
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'
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;
},
});
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'
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];
}
});
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);
});
// Something very roughly like this
const deps = {
radius: ['areaCircle'],
};
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;
},
});
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
And check our deps object:
source.deps;
{
radius: Set(1) {
'areaCircle',
},
base: Set(1) {
'areaRect',
},
height: Set(1) {
'areaRect',
},
areaRect: Set(1) {
'areaTriangle',
}
}
}
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;
},
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;
},
});
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
}
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
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;
},
});
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
andObject.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)