React to JavaScript object updates with observable-entities-js

I just published my first TypeScript library — observable-entities.  It contains base classes that notify observers when properties are updated and when objects are added or removed from collections.

The code for observable-entitites-js can be found here: https://github.com/TrackableEntities/observable-entities-js.

Node sample app that uses observable-entities for reacting to entity updates and when objects are added or removed from set and map collections: https://github.com/tonysneed/hello-observable-entities

Angular sample app that uses observable-entities for reactive binding: https://github.com/TrackableEntities/observable-entities-js-sample.

Getting Started

To get started using observable-entities-js simply add it using NPM as a runtime dependency to your JavaScript application or library.

npm i --save observable-entities

Note: observable-entities uses features of ES2015 that are not compatible with older browsers such as Internet Explorer.

To use observable-entities all you have to do is derive your model classes from ObservableEntity and add a constructor that returns a call to super.proxify.

import { ObservableEntity } from 'observable-entities';

class Product extends ObservableEntity {
  productName: string;
  constructor(productName: string) {
    super();
    this.productName = productName;
    return super.proxify(this);
  }
}

This allows you to create a listener that can subscribe to notifications that take place when any property in the entity is modified.

import { INotifyInfo } from 'observable-entities';
import { Subject } from 'rxjs/Subject';

// Create listener that logs to console when entity is updated
const modifyListener = new Subject[INotifyInfo]();
modifyListener.subscribe(info => {
  console.log(`Entity Update - key: ${info.key} origValue: ${info.origValue} currentValue: ${info.currentValue}`)
});

Then add the listener to the modifyListeners property of the entity.

// Create product and add listener
const product = new Product('Chai');
product.modifyListeners.push(modifyListener);

When a property is set on the entity, the listener will get notified of the property change.

// Set productName property
product.productName = 'Chang';

// Expected output:
// Entity Update - key: productName origValue: Chai currentValue: Chang

If you were to debug this code, you could set a breakpoint on the line of code that logs to the console, and you would hit the breakpoint as soon as you set productName to ‘Chang’.

obs-entities-debug

You can clone the Node demo app I wrote for this post and try debugging it yourself.

Observable Proxies

You may have noticed that modifyListener is of type Subject[INotifyInfo], which is a class that comes from RxJS (Reactive Extensions for JavaScript), a library that represents a style of programming combining the observer design pattern with functional programming.  While it’s possible to design a class that can notify listeners of property changes, it would involve a lot of repetitive boilerplate code as in, for example, this version of the Product class.

Note: To render TypeScript generics in WordPress, it is necessary to use square brackets [] instead of angle brackets <>.

class Product {

  private _productName: string;
  readonly modifyListeners: Subject[INotifyInfo][] = [];

  get productName() {
    return this._productName;
  }

  set productName(value: string) {
    // Notify listeners of property updates
    const notifyInfo: INotifyInfo = { key: 'productName', origValue: this._productName, currentValue: value };
    this.modifyListeners.forEach(listener => listener.next(notifyInfo))
    this._productName = value;
  }

  constructor(productName: string) {
    this._productName = productName;
  }
}

While you could encapsulate the notification code in a protected notify method placed in a base class, you would still need to call the method from the property setter on each property, which is similar to how INotifyPropertyChanged is usually implemented in C# to support two-way data binding.

A cleaner solution would be to intercept calls to property setters in an entity so that you could notify listeners of changes in a generic fashion.  This is why ES2015 proxies were created.  The way it works is that you create a handler that has a trap for the kind of operation you want to intercept, set for example. Then you can do whatever you want when a property is set, such as notify listeners of property changes.

The way observbable-entities does this is by providing a protected proxify method in the ObservableEntity base class, which returns a proxy of the entity.  (Keep in mind that proxies are an ES2015 feature that is not supported by downlevel browsers, such as Internet Explorer — therefore, you’ll need to target ES2015 in apps or libraries that use observable-entities.

protected proxify[TEntity extends object](item: TEntity): TEntity {
  if (!item) { return item; }
  const modifyListeners = this._modifyListeners;
  const excludedProps = this._excludedProperties;
  const setHandler: ProxyHandler[TEntity] = {
    set: (target, property, value) => {
      const key = property.toString();
      if (!excludedProps.has(key)) {
        const notifyInfo: INotifyInfo = { key: key, origValue: (target as any)[property], currentValue: value };
        modifyListeners.forEach(listener => listener.next(notifyInfo));
      }
      (target as any)[property] = value;
      return true;
    }
  };
  return new Proxy[TEntity](item, setHandler);
}

If the constructor of the subclass returns a call to super.proxify, then consumers get a proxy whenever they new up the entity, without the need to create the proxy manually.  (There a static factory method is provided if you have a need for this.)

Observable Sets and Maps

Besides notifications of property updates, observable-entities allows you to receive notifications when entities are added or removed from Set and Map collections. That’s the purpose of ObservableSet and ObservableMap classes, which extend Set and Map by overriding add and delete methods to notify listeners.

You can, for example, create listeners that write to the console when entities are added or removed from an ObservableSet.

// Observe adds and deletes to a Set
const productSet = new ObservableSet(product);

// Create listener that writes to console when entities are added
const addListener = new Subject[INotifyInfo]();
addListener.subscribe(info => {
  console.log(`Set Add - ${(info.currentValue as Product).productName}`)
});
productSet.addListeners.push(addListener);

// Create listener that writes to console when entities are removed
const removeListener = new Subject[INotifyInfo]();
removeListener.subscribe(info => {
  console.log(`Set Remove - ${(info.currentValue as Product).productName}`)
});
productSet.removeListeners.push(removeListener);

Those listeners will then be notified whenever entities are added or deleted from the Set.

// Add entity
const newProduct = new Product('Aniseed Syrup');
productSet.add(newProduct);

// Expected output:
// Set Add - Aniseed Syrup

// Remove entity
productSet.delete(newProduct);

// Expected output:
// Set Remove - Aniseed Syrup

ObservableMap works the same way as ObservableSet, but with key-value pairs.  Note that INotifyInfo also provides the entity key when the listener is notified.

// Observe adds and deletes to a Map
const productMap = new ObservableMap([product.productName, product]);

// Add listener for when entities are added
const addListenerMap = new Subject[INotifyInfo]();
addListenerMap.subscribe(info => {
  console.log(`Map Add - ${info.key} (key): ${(info.currentValue as Product).productName} (value)`)
});
productMap.addListeners.push(addListenerMap);

// Add listener for when entities are removed
const removeListenerMap = new Subject[INotifyInfo]();
removeListenerMap.subscribe(info => {
  console.log(`Map Remove - ${info.key} (key): ${(info.currentValue as Product).productName} (value)`)
});
productMap.removeListeners.push(removeListenerMap);

// Add entity
productMap.add(newProduct.productName, newProduct);

// Expected output:
// Map Add - Aniseed Syrup (key): Aniseed Syrup (value)

// Remove entity
productMap.delete(newProduct.productName);

// Expected output:
// Map Remove - Aniseed Syrup (key): Aniseed Syrup (value)

Angular Data Binding with Observables

One possible application of observable-entities is to use them with Angular’s OnPush change detection strategy so that you can control when change detection cycles take place when binding components to templates.  This could result in faster data binding because you’ll call markForCheck on an injected ChangeDetectorRef so that Angular will perform change detection on that component while skipping others.

Instead of writing to the console, add and modify listeners will call markForCheck.

// Trigger data binding when item is added
this.addListener = new Subject[INotifyInfo]();
this.addListener.subscribe(info => {
  this.cd.markForCheck();
});

// Add listener to products
this.products.addListeners.push(this.addListener);

// Trigger binding when item is updated
this.modifyListener = new Subject[INotifyInfo]();
this.modifyListener.subscribe(info => {
  this.cd.markForCheck();
});

// Add listener to each product
this.products.forEach(product => {
  product.modifyListeners.push(this.modifyListener);
});

To see data binding with observable entities in action you can clone the Angular demo app I wrote for this post.

obs-entities-angular

There are many possible uses for observable entities, sets and maps, and I hope you find my observable-entities-js library useful and interesting.

Enjoy!

About Tony Sneed

Sr. Software Solutions Architect, Hilti Global Application Software
This entry was posted in Technical and tagged , , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.