⚡ Write your own reactivity function in 10 lines of Javascript by Tobias Quante

⚡ Write your own reactivity function in 10 lines of Javascript

Tobias Quante

Tobias Quante

Have you ever wondered how frameworks like React and Vue handle reactive data? This article aims to bring clarity. It presents Javascript Proxies, Proxy traps, and how they are used to create a simple reactivity system.

Table of contents

Reactivity is a core feature of modern Javascript frameworks. It is like putting a tracker on your data. In this article, you will learn one approach of implementing reactivity using the Observer Pattern. You will also code your own, simple reactivity function in less than 10 lines of Javascript.

The Observer Pattern

The Observer Pattern is a design principle in software development. It requires the implementation of a Subject to which Subscribers (alias Observers) can register. Whenever the Subject changes, each Subscriber is notified. If you take the terms literally, it's like subscribing to an email newsletter.

This image displays our implementation of the Observer Pattern using functional programming

The use case

Let's assume I would like to write the frontend for a webshop. Currently, the customer requests a shopping cart feature. It must update the total checkout price whenever a new item is added or removed. Our dev team decides the best way is to implement an Observer Pattern with the shopping cart items used as its subject.

The functional approach

The most straightforward implementation way seems to be to:

  1. Create a subject
  2. Create a (set of) handler functions
  3. Register the handler functions to be called whenever the subject changes

So let's do just that.

Create the Subject

The following will be our shopping cart Subject:

// Define the subject
const shoppingCart = {
  items: [], 
  checkoutPrice: 0
}

Let's also agree on what our item must look like:

const shoppingCartItem = {
  price: 399.99,
  quantity: 1,
  name: 'Playstation 5'
}

Create the handler function(s)

For our use case, we need only a single function. It must update the total cart price whenever the items property in our shopping cart changes.

// Define the handler function
function calculateCheckoutPrice() {
  let sum = 0;
  shoppingCart.items.forEach(item => sum += item.price)
  shoppingCart.checkoutPrice = sum;
}

The first attempt without reactivity

Let's try it out manually - add the item to the shopping cart and call calculateCheckoutPrice

// Define the subject
const shoppingCart = {
  items: [], 
  checkoutPrice: 0
}

const shoppingCartItem = {
  price: 399.99,
  name: 'Playstation 5'
}

// Define the handler function
function calculateCheckoutPrice() {
  let sum = 0;
  shoppingCart.items.forEach(item => sum += item.price)
  shoppingCart.checkoutPrice = sum;
}

shoppingCart.items.push(shoppingCartItem)

calculateCheckoutPrice()
console.log(shoppingCart.checkoutPrice) // Output: 399.99

Add a register function

We won't want to call this function every time after a new item has been added. This is where the observer pattern comes into play. The two features we must implement are:

  • Our register function must bind the handler functions to our subject.
  • Whenever the subject changes, all handler functions must be called.

Fortunately, there is a browser API to the rescue. I'm talking about Proxies.

The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.

Source: MDN on Javascript Proxy objects

Intercepting sounds promising. This should give us a way to get to know whenever our proxied (let's call it observed for the rest of the article) subject changes.

Let's add the following function to our codebase:

/** 
 * @param subject {any}
 * @param subscribers {function[]}
 */
function register(subject, subscribers) {
  const proxyHandler = {
    set: (target, property, value) => {
      target[property] = value;
      subscribers.forEach(subscriber => subscriber());
      return true;
    }
  }
  return new Proxy(subject, proxyHandler);
}

The above code introduces a Proxy trap named proxyHandler. It must be passed into the Proxy constructor alongside the subject.

The trap is what handles interceptions. In this case, it redefines what happens whenever the subject's value changes (when set is called). set accepts three arguments:

  1. The target is our subject.
  2. The property is our subject's value key.
  3. The value is the new value to be assigned.

So by writing target[property] = value;, we do nothing else but the standard assignation operation. What comes next is custom.

subscribers.forEach(subscriber => subscriber()); calls all of our handler functions. It makes sure that, whatever function we pass, will be executed once the subject changes.

Make it reactive

All that's left to do is to enhance the default items property of our shopping cart with the register function. Let's also create a second shopping cart item that the customer adds to the array, just to be sure we got everything right.

// Define the subject
const shoppingCart = {
  // register the handler function here
  items: register([], [calculateCheckoutPrice]),
  checkoutPrice: 0
}

// Define the two shopping cart items
const shoppingCartItemOne = {
  price: 399.99,
  name: 'Playstation 5'
}

const shoppingCartItemTwo = {
  price: 899.99,
  name: 'Nvidia Geforce RTX 3080'
}

// Define the handler function
function calculateCheckoutPrice() {
  let sum = 0;
  shoppingCart.items.forEach(item => sum += item.price)
  shoppingCart.checkoutPrice = sum;
}

/** 
 * Define the register function
 * @param subject {any}
 * @param subscribers {function[]}
 */
function register(subject, subscribers) {
  const proxyHandler = {
    set: (target, property, value) => {
      target[property] = value;
      subscribers.forEach(subscriber => subscriber())
      return true;
    }
  }
  return new Proxy(subject, proxyHandler);
}

// add the first item
shoppingCart.items.push(shoppingCartItemOne)

// Add the second item
shoppingCart.items.push(shoppingCartItemTwo)

console.log(shoppingCart.checkoutPrice) // Prints 1299.98

Now try and remove an item. The checkout price will adjust.

// Remove the first item from the shopping cart
shoppingCart.items.splice(0, 1)
console.log(shoppingCart.checkoutPrice) // Prints 899.99

Drawbacks of this approach

There are a few caveats against this implementation type.

  • Register does not consider nested properties
// Assignation of whole objects will not work: 
const shoppingCart = register(
  {
    items: [],
    checkoutPrice: 0,
  },
  [calculateCheckoutPrice]
);
  • It does not consider the context.
    => You cannot register a class method as a handler function.
  • If you register to arrays, all handler functions will be called twice.
    => In arrays, not only the array's value but also its .length changes.

While you could go ahead and patch these problems up, we're starting to reach a point in which it would probably be better to encapsulate everything into a class. Or at least a closure.

If you'd like to read more about a more specific implementation (more than 10 lines), please do let me know.

Share this article