Writing functional applications with Functional Reactive Programming and Bacon.js
Update: on March 29 I updated the code for this app to make it clearer and more readable.
In my previous post I discussed the concepts behind functional programming. This blog will demonstrate how we can use these concepts to build applications with the help of Bacon.js — a Functional Reactive Programming library by Juha Paananen.
Themes of functional programming
It’s important to remember the key themes of functional programming:
- Computation as the application of functions
- Statelessness
- Avoiding side effects
Bacon.js provides a set of abstractions that allow us to program in a way that matches these themes.
What is Functional Reactive Programming?
Functional Reactive Programming (FRP) focuses on values that change over time. Since programming in a functional style means we have to avoid mutation and side effects this can make things difficult in environments where things are constantly changing. User interfaces are a good example of this since a UI will often change depending on user interaction. FRP provides an abstraction that allows us to program functionally but still be able to deal with constantly changing values.
Conal Elliott gave a very good answer to the question “What is functional reactive programming?” on Stack Overflow. These are the key points:
- Dynamic values are “first class” — they can be combined and passed in and out of functions
- Events are a discrete data type where each occurrence has an associated time and value
Bacon.js
Bacon.js is an FRP library for Javascript. It consists of event streams which can listen to typical browser and Node events. These streams can be transformed and mixed together in a variety of ways corresponding to the principles of FRP described above. Bacon features a strong publish/subscribe pattern so that multiple streams can subscribe to an object which are then alerted when the object publishes a value.
Bacon also has “properties” which is similar to an event stream but has a “current value”. As Juha explains on Bacon’s Github repo “things that change and have a current state are Properties, while things that consist of discrete events are EventStreams. You could think mouse clicks as an EventStream and mouse position as a Property.”
Building an application using Bacon.js
To demonstrate how we can use Bacon and FRP to write functional applications we will create a car grade and engine selector. This allows a user to choose a grade of a car and then a filtered list of engines available for that grade or vice versa. The application will have the following characteristics:
- The first list where the user makes a selection becomes the “primary” list. This means choosing an item in the secondary list does not filter the primary list
- On selection of a grade or engine the secondary list will be filtered according to the items available for that grade or engine
- The user should have a visual indication of which list is primary
- When the user chooses an item the total price is updated
- The user can reset all the choices they’ve made
In building this application I took a lot of inspiration from Juha’s implementation of the common TodoMVC app by Addy Osmani using Bacon.js. Juha’s app follows an MVC pattern and so did I for the grade and engine selector. You can view the application here.
Defining the model
The model uses a standard constructor which defines various properties on instantiation:
var SelectorModel = function(model) {
this.name = model.name;
this.items = model.items;
this.type = model.type;
this.price = model.price;
this.hide = new Bacon.Bus();
};
You’ll notice that one of these properties creates a new instance of the Bacon.Bus
class. This is a custom event stream which other streams can subscribe to (using this.hide.plug(stream)
) and we can push new values into the stream (using this.hide.push(value)
).
Defining the collection
Similarly to collections in Backbone.js I have defined a collection object that contains all models of a similar type:
var SelectorCollection = function(models) {
this.models = models.map(function(model) {
return new SelectorModel(model);
});
};
Defining the views
The application has two views: a List View and an Item View.
List View
This view is created with an associated DOM element and a collection. On instantiation it creates child views and appends these new views to itself:
var ListView = function(el, View, collection) {
this.el = el;
this.bus = new Bacon.Bus();
this.collection = collection;
collection.models.forEach(function(model) {
var view = new ItemView(model);
this.el.append(view.el);
// Subscribes click events on the child view's input button to this parent
// view's 'bus' stream
this.bus.plug(view.filter);
}, this);
};
ListView.prototype.showPrimary = function(v) {
var primaryEl = this.el.find('.primary');
if (v) {
primaryEl.removeClass('hidden');
} else {
primaryEl.addClass('hidden');
}
return this;
};
ListView.prototype.resetSelection = function() {
this.el.find('input').prop('checked', false);
return this;
};
Once again we’ve used the Bacon.Bus
class to allow us to publish and subscribe values to this view. When the child views are created we subscribe the child’s view.filter
event stream (see below) to the list view’s bus
stream. We’ll see how this all ties together later on.
Item View
The item view is created with an associated DOM element and a model:
var ItemView = function(model) {
var that = this;
this.el = $(tpl(model));
this.input = this.el.find('input');
this.filter = this.input.asEventStream('click').map(model);
// When the view's model receives a new value we show or hide the element
model.hide.onValue(function(v) {
if (v) {
that.hide();
} else {
that.show();
}
});
};
ItemView.prototype.hide = function() {
this.el.addClass('hidden');
};
ItemView.prototype.show = function() {
this.el.removeClass('hidden');
};
A new Bacon event stream is added as a property to the view when the user clicks the input control. You can transform event streams using Bacon’s map
method. In this particular case when a click occurs the view’s model is the value that is actually published.
In the SelectorModel
we created a property called hide
which is a custom event stream. When this stream receives a value we can call a function using onValue
which in this view’s case either calls its hide
or show
method.
Tying it all together
There is an initialise
function which creates two collections:
var initialise = function() {
var gradeCollection = new SelectorCollection(data.grades);
var engineCollection = new SelectorCollection(data.engines);
var gradeView = new ListView($('#grades'), gradeCollection);
var engineView = new ListView($('#engines'), engineCollection);
var reset = $('#reset').asEventStream('click');
// Merges the two parent views' "bus" streams & the reset button's stream
var gradeEngineList = gradeView.bus.merge(engineView.bus).merge(reset);
// Creates a property from the "gradeEngineList" stream
// This property receives either a "SelectorModel" object or an event object
var gradeEngineListProp = gradeEngineList.scan({
primary: 'none',
gradePrice: 0,
enginePrice: 0,
obj: {}
}, determinePrimaryView);
gradeEngineListProp.onValue(filter.bind(null, gradeView, engineView));
// We set the price
gradeEngineListProp.onValue(updatePrice);
};
This function handles the core logic of the app and is where we really see Bacon and FRP’s power come into play.
We start by creating two list views and adding an event stream to click events on the reset button. We then merge three streams together using Bacon’s merge
method: the gradeView.bus
, the engineView.bus
and reset
which is stored in the gradeEngineList
variable. This is a very good example of how Bacon represents these dynamic values as “first class” data types. The three streams are stored as a value and can be mixed and passed around as we see fit.
Another useful Bacon method is scan
. This creates a Bacon property which has an initial value and on each event applies a function to the current value that returns a new value which becomes the property’s “current value”. In our case the gradeEngineListProp
variable uses scan with an initial object literal value and the determinePrimaryView
function:
var determinePrimaryView = function(v, obj) {
var type = obj.type;
var isGrade = type === 'grade';
var isEngine = type === 'engine';
// If the "obj" argument has a type of "click" then we know the reset button
// has been clicked and we reset the "primary" view
// Otherwise we set the "primary" view according to the object's type and set // the appropriate price
// The 'or' operator is used so that if the the user has selected a new item in // the "primary" view we can set the "secondary" price to 0
// If a "primary" view has already been set we retain this state
if (type === 'click') {
return {
primary: 'none',
gradePrice: 0,
enginePrice: 0,
obj: {}
};
} else if (v.primary === 'none' || type === v.primary) {
return {
primary: type,
gradePrice: isGrade ? obj.price : 0,
enginePrice: isEngine ? obj.price : 0,
model: obj
};
} else {
return {
primary: v.primary,
gradePrice: isGrade ? obj.price : v.gradePrice,
enginePrice: isEngine ? obj.price : v.enginePrice,
model: obj
};
}
};
This function receives the current value of the property as the first parameter. The second parameter is the value received by the gradeEngineList
stream so this is either the event object from the reset button being clicked or a SelectorModel
object. This function determines whether there is currently a “primary” list or not.
To filter a list we use the onValue
method of the gradeEngineListProp
property to determine which items need to be hidden and shown:
var filter = function(gradeView, engineView, obj) {
// If no primary view has been set then we reset the views
// Otherwise if a selection occurs in the "secondary" view then do nothing
// Otherwise we filter the "secondary" view and show the primary indicator
// on the "primary" view
if (obj.primary === 'none') {
resetViews([gradeView, engineView]);
} else if (obj.model.type !== obj.primary) {
return false;
} else if (obj.primary === 'grade') {
filterSecondaryView(engineView, obj);
setPrimary.call(gradeView, engineView);
} else {
filterSecondaryView(gradeView, obj);
setPrimary.call(engineView, gradeView);
}
};
var resetViews = function(views) {
views.forEach(function(view) {
view.showPrimary(false)
.resetSelection()
.collection.models.forEach(function(model) {
model.hide.push(false);
});
});
};
var setPrimary = function(otherView) {
this.showPrimary(true);
otherView.showPrimary(false);
otherView.resetSelection();
};
// If an item in a view is selected then we filter the other view
var filterSecondaryView = function(secondaryView, obj) {
// Are the items in the "secondary view" applicable to the model selected
var isApplicable = function(item) {
return item.items.some(function(itemName) {
return itemName === obj.model.name;
});
};
secondaryView.collection.models.forEach(function(item) {
var hide = isApplicable(item);
if (hide) {
item.hide.push(false);
} else {
item.hide.push(true);
}
});
};
Once again the full application is available here.
Conclusion
You will notice that in building this application we have followed the principles of functional programming:
- Computation as the application of functions
- Statelessness
- Avoiding side effects.
Using Bacon and FRP you can create apps that are simpler to write, referentially transparent and easier to test.