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.
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:
- Create a subject
- Create a (set of) handler functions
- 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
.
TheProxy
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:
- The
target
is our subject. - The
property
is our subject's value key. - 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.