Creating a vanilla JavaScript signal() with Proxies
Yesterday, we looked at vanilla JS Proxies.
Today, I wanted to show you how you can use them to create reactive signals. Let’s dig in!
An example
Let’s imagine you have a cart object ({}).
let cart = {};
Whenever it’s updated, you have code in multiple different places that needs to know it’s been changed, know what changed, and run some actions in response.
// When this happens, some other code should automatically run in response
cart.shirt = {
size: 'medium',
quantity: 1
};
Proxies are perfect for this!
This is actually how libraries like Vue work under-the-hood. If you want to learn more about that, I do a deep-dive into state-based UI over at the Lean Web Club.
Creating a signal() function using Proxies
First, let’s create a signal() function that accepts a data value to create a Proxy from.
We’ll use a plain object ({}) as the default value if none is provided. We also want each signal object to have a unique name or identifier. We’ll pass in a name variable for that.
function signal (data = {}, name = '') {
// ...
}
Next, let’s create a handler() function that returns our handler object. We’ll do this to handle nested arrays and objects.
function signal (data = {}, name = '') {
/**
* Create a Proxy handler object
* @param {Object} data The data object
* @param {String} name The signal name
* @return {Object} The handler object
*/
function handler (data, name) {
return {
get (obj, prop) {
if (key === '_isProxy') return true;
let nested = ['[object Object]', '[object Array]'];
let type = Object.prototype.toString.call(obj[key]);
if (nested.includes(type) && !obj[key]._isProxy) {
obj[prop] = new Proxy(obj[prop], handler(name, data));
}
return obj[prop];
},
set (obj, prop, value) {
if (obj[prop] === value) return true;
obj[prop] = value;
return true;
},
deleteProperty (obj, prop) {
delete obj[prop];
return true;
}
};
}
}
Now, we can create and return a new Proxy().
function signal (data = {}, name = '') {
// ...
// Create a new Proxy
return new Proxy(data, handler(data, name));
}
Emitting a custom event
Let’s add an emit() function to our signal() function that emits a custom event.
Quick aside: you can find this and lots of other helper functions like it over at the Lean Web Club.
We’ll pass in the signal name, as well as a detail object with details about what changed.
/**
* Emit a custom event
* @param {String} name The unique name for the signal
* @param {*} detail Any details to pass along with the event
*/
function emit (name, detail = {}) {
// Create a new event
let event = new CustomEvent(`signal:${name}`, {
bubbles: true,
detail: detail
});
// Dispatch the event
return document.dispatchEvent(event);
}
Now, in our handler(), we can emit() events when data is set or deleted.
For details, we’ll include the prop that was changed, it’s value, and an action indicating how it changed.
/**
* Create a Proxy handler object
* @param {Object} data The data object
* @param {String} name The signal name
* @return {Object} The handler object
*/
function handler (data, name) {
return {
get (obj, prop) {
if (key === '_isProxy') return true;
let nested = ['[object Object]', '[object Array]'];
let type = Object.prototype.toString.call(obj[key]);
if (nested.includes(type) && !obj[key]._isProxy) {
obj[prop] = new Proxy(obj[prop], handler(name, data));
}
return obj[prop];
},
set (obj, prop, value) {
if (obj[prop] === value) return true;
obj[prop] = value;
emit(name, {prop, value, action: 'set'});
return true;
},
deleteProperty (obj, prop) {
delete obj[prop];
emit(name, {prop, value: obj[prop], action: 'delete'});
return true;
}
};
}
Using a signal()
Now, we can create a cart object as a signal(), like this…
let cart = signal({}, 'cart');
We can listen for changes it to it like this…
document.addEventListener('signal:cart', function (event) {
console.log(event.detail);
});
And whenever we update our cart, an event will fire off.
cart.shirt = {
size: 'medium',
quantity: 1
};
cart.pants = {
size: 32,
quantity: 2
};
delete cart.pants;