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’.
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.
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!