Introduction
As discussed in Component Based Web Application, functional programming on top of imperative DOM API is preferable in component development. After selecting React as our component model library, we also found Reactive Extensions RxJS offers great expressiveness and conciseness for intra-component state management. A concrete example is discussed in Reactive Autonomous States. This is the 3rd article in our pursuit of effective component based web application, we’ll focus on inter-component unidirectional data flow implementation.
Intra-component state is autonomous, while inter-component data flow is unidirectional. This design not only matches React rendering mechanism natively, but also greatly simplifies state management logic. When inter-component data only flow in one direction, no need to worry about the disadvantages of two way data bindings, large app becomes more performant, easier to debug with cleaner and maintainable code. With the async and non-blocking nature of RxJS, coupled with immutable data structure, the implementation of better and simpler Flux become much easier.
Flux generalizes the interaction approach for large applications and small components. Composability and unidirectional data flow makes the essence of architecture, codebase is also easier to read, debug and maintain. Most importantly, state management are greatly simplified, evolving application features and scale development productivity becomes much more effective comparing to other unnecessary complicated solutions.
There are two ‘flux’s in the title, the first one refers to flux architecture, while the later refers to the flux library. We love Flux’s architectural idea but not a big fan of its implementation. To avoid some disadvantages of default flux library, we utilize RxJS to implement Flux, the result is React component becomes more reactive, data flow is easier to manage and debug, code is cleaner, shorter and more extensible.
More specifically, here are some shortcomings we’d like to overcome:
- Convoluted global dispatcher: too many callbacks, too long switch statements
- One-to-many relationships between action and store: when one action triggers more stores’ updates, maintainability goes down
- No easy and clear way to compose or transform different kinds of data between store and views
Conceptually, here is a simplified Flux library data flow diagram:
RxJS offers observable/observer that can replace global dispatcher elegantly, its abundant operators helps to expressively transform data between Store and View (#3). Although #2 is more an application architectural practice rather than Flux’s shortcoming, we found limiting the number of stores that responses to the same action help to keep the application logic easier to maintain.
Let’s take a deeper look how RxJS helps to create a better Flux.
Overview
The idea to implement Flux by Rx is not new, here are some efforts already in GitHub:
- Rx-Flux: no central dispatcher, store is a RxJS Observable, action is a function and also a RxJS Observable, store subscribes to Action to update store data
- Reactive-Flux: React components can subscribe to many Stores, A Store can subscribe to many Actions, each with its own handler
- Thundercats: in addition to RxJS Observable for store and action, it singles itself out by using stampit! for Stores, Actions and Cats (a bag where registering store and actions factories), and not use ES6 (2015) classes, because the “associated danger”.
- Flurx: A Store can subscribe to many Actions, each with a handler; A handler receives the call parameters of the action and produces a new value of the store.
- RR: A super tiny but purely reactive (based on RxJS) implementation of Flux architecture. React-Native Supported
If any of above is a good fit to what you are looking for, please stop reading and go to download, each one of them has its own specialties and advantages. However, none of above fit my need quite well, given what I’m really looking for:
- Managed RxJS decencies: RxJS is a big library: complete, main, lite, core, etc., we’d like to manage the dependencies to the bare minimum. In the meantime, the dependency list can grow if the application uses more Rx features
- Avoid waitFor in Action while make Action type extensible
- Enable external handler to subscribe to Action through Store: this is to enable Store to play well with legacy code, like a scoped function within Angular controller
- Pair an Action with a Store by default, while still enable Store to subscribe to multiple Actions
- Eliminating global dispatcher and singleton, keep the base implementation practical and simple
- Promote immutable data structure by build-in redo/undo functionality in Store, but not enforce it, concrete instance of Store can opt-in for undo/redo support
- No addition concepts and constructs beyond Flux, keep everything simple
What above seems quite a lot to cover, but the final codebase turns out to be very concise. Here are some general technical considerations for our implementation:
- Store data object is private, use React Immutable Helper to manipulate and keep it immutable, undo/redo can opt-in or opt-out any moment
- Action “types” will be ready only properties and only customizable when instantiate
- Both Store and Action are backed by a BehaviorSubject, it’ll makes Store and Action are both observable and observer, and also makes sure the observer will always get the initial or last data regardless the timing to subscribe
- Subscribe and dispose invocation can be reentrant
- All other Flux architectural aspects stay, including Store can subscribe to multiple Action, view can subscribe to multiple stores, etc., although the default behavior prefers one to one relationship for simplicity
Here is a generalized Rx implementation of unidirectional data flow:
Let’s see how Rx implements Flux with some code.
Rx Library Setup
As the first step to apply our minimalism design principle, we can create a managed Rx dependency list, it only pulls in the libraries that support basic functionalities (rx.include.js):
'use strict';
let Rx = require('rx/dist/rx');
require('rx/dist/rx.aggregates');
require('rx/dist/rx.async');
require('rx/dist/rx.binding');
require('rx/dist/rx.time');
Rx.config.useNativeEvents = true;
module.exports = Rx;
The list above covers the most common Rx constructs (observable, observer, subject, etc.) and operators. (map, filter, find, concat, merge, mergeAll, zip, combineLatest, debounce, deplay, timeout, etc.) If the project needs other operators, just add the library in the list, browserify will take care of the importing and bundling. The purpose here is to provide a managed list to minimize the dependencies.
Action
Base on the original Action type concept, each dispatched Action has ‘type’ and ‘data’ in its payload. The ’type’ field is the action’s identifier, also used when an observer (store or other handler) subscribes to it: only the specified Action type will be “dispatched” to the observer:
'use strict';
import Rx from './rx.include';
function ActionBase(action_types) {
this._actionSubject = new Rx.BehaviorSubject({type:"", data:null});
for (let name in action_types) {
if (action_types.hasOwnProperty(name)) {
Object.defineProperty(this, name, {
enumerable: true, configurable: true, get: () => action_types[name]
});
}
}
}
ActionBase.prototype = {
constructor: ActionBase,
dispatch(actType, actData) {
if (!actType || !actData) {
console.error("ActionBase: missing arguments", actType, actData);
}
else {
this._actionSubject.onNext({
type: actType,
data: actData
});
}
},
subscribe(actType, fn, context) {
if (!actType || !this.hasOwnProperty(actType)) {
console.error(`action type of ${actType} is not defined.`);
return null;
}
return this._actionSubject.filter( (payload) => payload.type === this[actType] )
.subscribe( (payload) => {
fn.apply(context, [payload]);
});
},
dispose(subscription) {
if (subscription) {
subscription.dispose();
}
else {
this._actionSubject.dispose();
}
}
};
module.exports = ActionBase;
Rx.BehaviorSubject is the key Rx construct that actually backs the essence of Action, it ‘dispatches’ Action with required payload without a central dispatcher.
The constructor of Action expects an array of strings indicating supported action types, those types can only be customized when instantiating an Action instance. Once created, all action types are read-only. Combining with the subscription time check, it enforces action types must be one of the supported ones.
Store
Rx.BehaviorSubject also plays a critical role in Store, it performs all reactive related operations. The subscribe / dispose pattern is the same as what Action has. There are two primary special features in our Store implementation: it requires a default Action to pair with and also has build-in undo/redo support:
'use strict';
import Rx from './rx.include';
function StoreBase(storeData, action) {
this._storeSubject = new Rx.BehaviorSubject(storeData);
this._storeState = storeData;
this._storeHistory = [];
this._historyIndex = -1;
this.action = action;
}
StoreBase.prototype = {
constructor: StoreBase,
init() {
this.streamChange();
},
subscribe(fn, context) {
return this._storeSubject.subscribe( (payload) => {
fn.apply(context, [payload]);
});
},
dispose(subscription) {
if (subscription) {
subscription.dispose();
}
else {
this._storeSubject.dispose();
}
},
streamChange() {
if (this.undoRedoSub) {
this._historyIndex++;
this._storeHistory.push(this._storeState);
}
this._storeSubject.onNext(this._storeState);
},
bindAction(actType, actFn, actContext) {
return this.action.subscribe(actType, actFn, actContext);
},
unBindAction(actSubs) {
if (!actSubs) {
console.log("StoreBase: missing subscription argument.");
}
else {
this.action.dispose(actSubs);
}
},
enableUndoRedo(toEnable) {
if (toEnable) {
if (!this.undoRedoSub) {
this.undoRedoSub = this.bindAction("UNDO_REDO", this.onUndoRedo, this);
}
}
else {
if (this.undoRedoSub) {
this.unBindAction(this.undoRedoSub);
this.undoRedoSub = null;
}
}
},
onUndoRedo(payload) {
if (payload.data === "--") {
if (this._historyIndex > 0) {
this._historyIndex--;
}
}
else if (payload.data === "++") {
if (this._historyIndex < this._storeHistory.length - 1) {
this._historyIndex++;
}
}
if (this._storeState != this._storeHistory[this._historyIndex]) {
this._storeState = this._storeHistory[this._historyIndex];
this._storeSubject.onNext(this._storeState);
}
}
};
module.exports = StoreBase;
The reason for having a default Action instance passing in Store’s constructor is, most of use cases Store needs an Action to be fully functioning, it’s cleaner to keep the repetitive boilerplate code in the base class. (In an extremely rare case that the default Action is not desired, the constructor will take an undefined or null parameter, but bindAction/unBindAction will throw exception when invoked.)
The intention for bindAction/unBindAction is to enable external modules to register a function when a particular type of Action is dispatched. The external module does not need to couple with the action type, instead, it only interacts with Store instance. An example of this use case is: an Angular controller can instantiate a Store instance to response to certain Action from React component in a legacy Angular based application shell.
The undo / redo functionality needs to opt-in by calling enableUndoRedo(true). The assumption is the client really knows what’s he is doing: the default paired Action instance can not be null, it must support at least one Action type named ‘UNDO_REDO’, and most importantly, the storeState
needs to be an immutable data structure.
Either immutable.js or React immutable helpers can help to keep storeState
immutable, whenever Store responses Action to update storeState, a new reference to is returned. Immutable data structure really makes undo/redo logic very simple. Under the hood, immutable data algorithm uses DAG (Directed Acyclic Graph) for structure sharing, although any update will return new data, internal data structural sharing significantly reduces memory usage and GC sharashing.
What above are all Rx based Flux. Next, we’ll show some code example on how to use them, and how React immutable helpers make update simple.
Examples
The code example from Reactive Autonomous States is about how dropdown menu manages its open/collapse and highlighting states within itself. Now let’s look at how clicking on a menu item will end up executing a pre-defined command.
With unidirectional data flow, the click event handler will dispatch an “Action”, and a legacy Angular controller bind a function to execute the command. This approach enables us to slice in new React components while still leveraging existing application logic to enable a phased technology migration.
First, in legacy Angular controller:
"use strict";
let menuBarMod = require('./_module.ds.menus');
let React = require('react');
let ReactDOM = require('react-dom');
let ReactMenuBar = require('./react.menubar');
let menuBarStore = require('./reactive.menubar.store');
menuBarMod.controller('MenuBar', function($scope, $translate, cmdExecutor) {
menuBarStore.init($translate.instant);
menuBarStore.bindAction("ACTION", cmdExecutor.onExecCommand, cmdExecutor);
ReactDOM.render(<ReactMenuBar />, document.getElementById("title-menu"));
});
Second, here is the event handler when menu item clicked (react component for dropdown menu):
onMenuItemAction(idx, evt) {
evt && evt.preventDefault();
let itemData = this.props.menuData.items[idx];
if (!itemData.disabled) {
let actName = itemData.action;
menuBarAction.dispatch(menuBarAction.ACTION, actName);
}
},
That is all the plumbing code. And, here is how our menubarAction instance is created:
'use strict';
import RxAction from '../util/rx.flux.action';
let MenuBarAction = new RxAction({
ACTION: "MENUBAR_ACTICON", UNDO_REDO: " UNDO_REDO"
});
module.exports = MenuBarAction;
As for the Store, here is how default storeState and action is used to instantiate a new instance:
'use strict';
import reactUpdate from 'react/lib/update';
import RxStore from '../util/rx.flux.store';
import menuBarAction from './reactive.menubar.action';
let MenuBarStore = new RxStore({
projectInfo: {
name: "",
version: "",
branch: ""
},
menus: [
{
label: "File",
items: [
{label: "Create New", icon: "icon-add", action: "modal-createNew"},
{role: "separator"},
{label: "Publish", icon: "icon-upload", action: "modal-publish"},
{label: "Close", icon: "icon-close", action: " close"}
]
},
{
label: "Source Control",
items: [
{label: "Commit Changes", icon: "icon-upload", action: "vcs-commit", disabled: true},
{label: "Switch Branch", icon: "icon-step-over", action: "vcs-switch-branch", disabled: true},
{role: "separator"},
{label: "Import from Source Control", icon: "icon-download", action: "vcs-import"},
{role: "separator"},
{label: "Create Branch", icon: "icon-ellipsis", action: "vcs-create-branch", disabled: true}
]
},
{
label: "Search",
items: [
{label: "Go To", icon: "icon-search", action: "modal-goto"},
{label: "Code Search", icon: "icon-cncn-advanced-search", action: "modal-search"}
]
}
]
}, menuBarAction);
And when a new projectInfo object is retrieved from API, here is how to update the store in an immutable fashion:
MenuBarStore.onProjectInfo = function(prjInfo) {
this._storeState = reactUpdate(this._storeState, {
projectInfo: {
$merge: prjInfo
}
});
this.streamChange();
};
Another immutable operation example: when the project’s VCS is disabled, we need to remove the ‘Source Control’ dropdown menu:
MenuBarStore.onVCSInfo = function(vcsEnabled) {
if (!vcsEnabled) {
if (this._storeState.menus.length === 3) {
this._storeState = reactUpdate(this._storeState, {
menus: { $splice: [[1, 1]] }
});
this.streamChange();
}
};
module.exports = MenuBarStore;
Wrap up
RxJS provides an optimized and expressive way to handle asynchronous events and data, it naturally matches up with data driven React components. With Rx’s powerful while concise constructs and operators, Flux implementation becomes cleaner and simpler. Further more, flexible data transforms and compositions between store and view are conveniently enabled. When combining with immutable data structure, Reactive Extensions and RxJS implemented Store and Action enable more extendable, composable and flexible unidirectional data flow without Flux.