In their simplest form, bindings provide the illusion that two objects have the same property. Changing the property on one object causes the same change in the other. This is useful for coordinating state between views and models, among other entangled objects. For example, if you enter text into a text field, the same text might be added to the corresponding database record.
bind(object, "a.b", {"<->": "c.d"});
Functional Reactive Bindings go farther. They can gracefully bind long property paths and the contents of collections. They can also incrementally update the results of chains of queries including maps, flattened arrays, sums, and averages. They can also add and remove elements from sets based on the changes to a flag. FRB makes it easy to incrementally ensure consistent state.
bind(company, "payroll", {"<-": "departments.map{employees.sum{salary}}.sum()"});
bind(document, "body.classList.has('dark')", {"<-": "darkMode", source: viewModel});
FRB is built from a combination of powerful functional and generic building blocks, making it reliable, easy to extend, and easy to maintain.
frb
is a CommonJS package, with JavaScript modules suitable for use
with Node.js on the server side or Mr on the client side.
❯ npm install frb
In this example, we bind model.content
to document.body.innerHTML
.
var bind = require("frb/bind");
var model = {content: "Hello, World!"};
var cancelBinding = bind(document, "body.innerHTML", {
"<-": "content",
"source": model
});
When a source property is bound to a target property, the target gets reassigned to the source any time the source changes.
model.content = "Farewell.";
expect(document.body.innerHTML).toBe("Farewell.");
Bindings can be recursively detached from the objects they observe with the returned cancel function.
cancelBinding();
model.content = "Hello again!"; // doesn't take
expect(document.body.innerHTML).toBe("Farewell.");
Bindings can go one way or in both directions. Declare one-way
bindings with the <-
property, and two-way bindings with the
<->
property.
In this example, the "foo" and "bar" properties of an object will be inexorably intertwined.
var object = {};
var cancel = bind(object, "foo", {"<->": "bar"});
// <-
object.bar = 10;
expect(object.foo).toBe(10);
// ->
object.foo = 20;
expect(object.bar).toBe(20);
Note that even with a two-way binding, the right-to-left binding precedes the left-to-right. In this example, "foo" and "bar" are bound together, but both have initial values.
var object = {foo: 10, bar: 20};
var cancel = bind(object, "foo", {"<->": "bar"});
expect(object.foo).toBe(20);
expect(object.bar).toBe(20);
The right-to-left assignment of bar
to foo
happens first, so the
initial value of foo
gets lost.
Bindings can follow deeply nested chains, on both the left and the right side.
In this example, we have two object graphs, foo
, and bar
, with the
same structure and initial values. This binds bar.a.b
to foo.a.b
and also the other way around.
var foo = {a: {b: 10}};
var bar = {a: {b: 10}};
var cancel = bind(foo, "a.b", {
"<->": "a.b",
source: bar
});
// <-
bar.a.b = 20;
expect(foo.a.b).toBe(20);
// ->
foo.a.b = 30;
expect(bar.a.b).toBe(30);
Changes to the structure of either side of the binding are no matter. All of the orphaned event listeners will automatically be canceled, and the binders and observers will reattach to the new object graph.
Continuing from the previous example, we store and replace the a
object from one side of the binding. The old b
property is now
orphaned, and the old b
property adopted in its place.
var a = foo.a;
expect(a.b).toBe(30); // from before
foo.a = {}; // orphan a and replace
foo.a.b = 40;
// ->
expect(bar.a.b).toBe(40); // updated
bar.a.b = 50;
// <-
expect(foo.a.b).toBe(50); // new one updated
expect(a.b).toBe(30); // from before it was orphaned
String concatenation is straightforward.
var object = {name: "world"};
bind(object, "greeting", {"<-": "'hello ' + name + '!'"});
expect(object.greeting).toBe("hello world!");
Some advanced queries are possible with one-way bindings from collections. FRB updates sums incrementally. When values are added or removed from the array, the sum of only those values is taken and added or removed from the last known sum.
var object = {array: [1, 2, 3]};
bind(object, "sum", {"<-": "array.sum()"});
expect(object.sum).toEqual(6);
The arithmetic mean of a collection can be updated incrementally. Each time the array changes, the added and removed values adjust the last known sum and count of values in the array.
var object = {array: [1, 2, 3]};
bind(object, "average", {"<-": "array.average()"});
expect(object.average).toEqual(2);
The round
, floor
, and ceil
methods operate on numbers and return
the nearest integer, the nearest integer toward -infinity, and the
nearest integer toward infinity respectively.
var object = {number: -0.5};
Bindings.defineBindings(object, {
"round": {"<-": "number.round()"},
"floor": {"<-": "number.floor()"},
"ceil": {"<-": "number.ceil()"}
});
expect(object.round).toBe(0);
expect(object.floor).toBe(-1);
expect(object.ceil).toBe(0);
FRB provides an operator for watching the last value in an Array.
var array = [1, 2, 3];
var object = {array: array, last: null};
Bindings.defineBinding(object, "last", {"<-": "array.last()"});
expect(object.last).toBe(3);
array.push(4);
expect(object.last).toBe(4);
When the dust settles, array.last()
is equivalent to
array[array.length - 1]
, but the last
observer guarantees that it
will not jitter between the ultimate value and null or the penultimate
value of the collection. With array[array.length]
, the underlying may
not change its content and length atomically.
var changed = jasmine.createSpy();
PropertyChanges.addOwnPropertyChangeListener(object, "last", changed);
array.unshift(0);
array.splice(3, 0, 3.5);
expect(object.last).toBe(4);
expect(changed).not.toHaveBeenCalled();
array.pop();
expect(object.last).toBe(3);
array.clear();
expect(object.last).toBe(null);
FRB provides an only
operator, which can either observe or bind the
only element of a collection. The only
observer watches a collection
for when there is only one value in that collection and emits that
value.. If there are multiple values, it emits null.
var object = {array: [], only: null};
Bindings.defineBindings(object, {
only: {"<->": "array.only()"}
});
object.array = [1];
expect(object.only).toBe(1);
object.array.pop();
expect(object.only).toBe(undefined);
object.array = [1, 2, 3];
expect(object.only).toBe(undefined);
The only
binder watches a value. When the value is null, it does
nothing. Otherwise, it will update the bound collection such that it
only contains that value. If the collection was empty, it adds the
value. Otherwise, if the collection did not have the value, it replaces
the collection's content with the one value. Otherwise, it removes
everything but the value it already contains. Regardless of the means,
the end result is the same. If the value is non-null, it will be the
only value in the collection.
object.only = 2;
expect(object.array.slice()).toEqual([2]);
// Note that slice() is necessary only because the testing scaffold
// does not consider an observable array equivalent to a plain array
// with the same content
object.only = null;
object.array.push(3);
expect(object.array.slice()).toEqual([2, 3]);
Like the only
operator, there is also a one
operator. The one
operator will observe one value from a collection, whatever value is
easiest to obtain. For an array, it's the first value; for a sorted
set, it's whatever value was most recently found or added; for a heap,
it's whatever is on top. However, if the collection is null, undefined,
or empty, the result is undefined
.
var object = {array: [],