Building Scalable Angular Apps Using Flux Architecture
January 7, 2016
Front-End Engineering has evolved significantly over the past couple of years. We have seen the rise and fall of JavaScript frameworks — and new frameworks are getting introduced every day. Among the contenders, Angular framework has become one of the most popular frameworks. With their $scope double-bind, you do not have to worry about syncing your model with the view. Angular also provides a lot of functionality out-of-the-box, such as rendering list with ngRepeat and formatting text with filters. You can also create your own custom directive — Angular’s way of marking and manipulating the DOM.
The best practice for Angular apps is to make sure that your controllers are thin and have your business logic reside in Angular Services, which you inject (Dependency Injection) to your controllers or other services. Angular, however, does not have a pattern for data management. This can create a challenge when creating an application with a lot of interdependencies.
Background Problem
Take, for example, the Cask Hydrator user interface. There are a lot of interdependent components in this page:
- the left panel content is dependent on the type of pipeline
- the size of the canvas is dependent upon the state of left panel and bottom panel
- the bottom panel is dependent on the actions you perform on the canvas’ nodes, bottom panel tabs and top panel.
Data flow between the many controllers, directives and services become unmanageable with lack of pattern.
We find hope in Flux Architecture…
Enter Flux
Flux Architecture comes from Facebook, and it is a very popular architecture to develop using the React framework. In the following sections we will talk about how we incorporated the design patterns from Flux in the Cask Hydrator Angular app.
Flux consists of three main components: Actions, Dispatcher and Store. The main mental model in Flux is to have unidirectional data flow.
Actions
An action is the beginning trigger of the data flow. This trigger can be a user action (e.g user clicks on a button) or it can also be an update from the server (e.g status update). An action can include a payload of data, and this data will get passed to the dispatcher.
Dispatcher
The dispatcher is the manager of data flow. It is essentially a publisher/subscriber model that keeps a registry of callback functions. The dispatcher distributes data (from Actions) or simply triggers an update to the various stores that subscribe to the particular trigger.
Stores
The Stores are where the application state resides. The store register itself to the dispatcher with a callback of the store’s internal method to update the state. This store becomes the single source of truth for data that will be displayed on the view.
How Do We Use Flux
All of the Flux components (Actions, Dispatcher, Stores) are implemented as Angular Services. There are 4 primary components in the Cask Hydrator view:
- Left Panel
- Bottom Panel
- Canvas
- Configuration Store
The purpose of the Cask Hydrator Studio UI is to generate a set of configuration that will be sent to the backend on publish. This Configuration Store needs to get updated by the different components:
- Clicking on a plugin on the Left Panel will add the plugin to the configuration (ConfigAction.addPlugin()).
- Dragging the nodes on the canvas will update the position in the configuration (ConfigAction.editNodePosition()).
- Connecting two nodes together will add a new connection in the configuration (ConfigAction.addConnection()).
- Modifying the configuration on the bottom panel will update the node’s properties in the configuration (ConfigAction.editNodeProperties()).
With multiple source of data update, Flux really simplifies things because all these interaction will call the corresponding methods through our Config Action. Check out this code snippet below:
var app = angular.module('Hydrator', []);
app.service('ConfigStore', function (ConfigDispatcher) {
var dispatcher = ConfigDispatcher.getDispatcher();
this.state = {};
this.changeListeners = [];
this.registerOnChangeListener = function (callback) {
this.changeListeners.push(callback);
};
function emitChange() {
this.changeListeners.forEach(function (callback) {
callback();
});
}
this.setDefaults = function () {
this.state = {
nodes = [],
connections = [],
...
};
};
this.addPlugin = function (plugin) {
this.state.nodes.push(plugin);
emitChange();
};
this.getPlugins = function () {
return this.state.nodes;
};
this.addConnection = function (connection) {
this.state.connections.push(connection);
emitChange();
};
this.editNodeProperties = function (nodeId, config) {
var node = this.state.nodes.filter(function (node) { return node.id === nodeId; });
if (node.length) {
node[0].properties = config;
}
};
this.editNodePosition = function (nodeId, position) {
var node = this.state.nodes.filter(function (node) { return node.id === nodeId; });
if (node.length) {
node[0].position = position;
}
};
dispatcher.register('onPluginAdd', this.addPlugin.bind(this));
dispatcher.register('onConnectionAdd', this.addConnection.bind(this));
dispatcher.register('onEditNodeProperties', this.editNodeProperties.bind(this));
dispatcher.register('onEditNodePosition', this.editNodePosition.bind(this));
});
app.service('ConfigAction', function (ConfigDispatcher) {
var dispatcher = ConfigDispatcher.getDispatcher();
this.addPlugin = function (plugin) {
dispatcher.dispatch('onPluginAdd', plugin);
};
this.addConnection = function (connection) {
dispatcher.dispatch('onConnectionAdd', connection);
};
this.editNodeProperties = function (nodeId, config) {
dispatcher.dispatch('onEditNodePropeties', nodeId, config);
};
this.editNodePosition = function (nodeId, position) {
dispatcher.dispatch('onEditNodePosition', nodeId, position);
};
});
app.controller('LeftPanelCtrl', function (ConfigAction) {
this.onPluginClick = function (plugin) {
ConfigAction.addPlugin(plugin);
};
...
});
app.controller('BottomPanelConfigCtrl', function (ConfigAction) {
this.onPropertyEdit = function (nodeId, config) {
ConfigAction.editNodeProperties(nodeId, config);
};
...
});
app.controller('CanvasCtrl', function (ConfigAction) {
function onStopDrag (element) {
ConfigAction.editNodePosition(element.id, element.position);
}
...
});
Benefits of using Flux
Flux provides a structure to the flow of data. When there is an error, the source of the problem is much more evident. It is easier to reason out why something happened. A single action can trigger multiple stores to update their state through the dispatcher, so the management of interdepencies become simple.
Using Flux also reduces development time. Developers can just follow the Flux pattern when designing a new feature or view. They can start with the Store to figure out what data they require for the view, then figure out what Actions will trigger a change to the store, and register itself with the Dispatcher.
Since the state of the application resides in the Stores, the controllers in your application become super thin. The controllers need to do two things: retrieve data from Stores to pass to the view, and expose Actions that the user can trigger.
Limitations
Integrating Flux with Angular does have limitation. A single update in the store will trigger an update of the entire state. Angular does not have a render method that does a diff on the DOM and checks which part needs to be updated. Instead, with an update to the store, Angular will re-render the entire component based on that digest cycle. Compare this with React and their use of virtual DOM, which will update the current DOM based on the state change, instead of re-rendering the entire view.
Another limitation of using Flux in an Angular application is that the Stores are implemented as an Angular Service. Angular Service is a singleton, so when the application changes state, the data in the Stores persist. We have to manually clean up or reset the Stores back to their default value when the user exits the state.
We drew a lot of benefits by incorporating Flux into our Hydrator angular app. You too can get started using Flux with minimal additional code — you just need to design the data flow with Flux pattern. You can get more information about Flux here: https://facebook.github.io/flux/docs/overview.html
If you are interested in working with such exciting Front-End challenges, join us! Also, you can check out the CDAP UI code on github. We are looking for talented Software Engineers to work on exciting open source projects.