Skip to main content Accessibility Feedback

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;

Here’s a demo.