Baobab
Baobab is a JavaScript persistent and optionally immutable data tree supporting cursors and enabling developers to easily navigate and monitor nested data.
It is mainly inspired by functional zippers such as Clojure's ones and by Om's cursors.
It aims at providing a centralized model holding an application's state and can be paired with React easily through mixins, higher order components, wrapper components or decorators (available there).
For a concise introduction about the library and how it can be used in a React/Flux application, you can head toward @christianalfoni's article on the subject.
Fun fact: A Baobab, or Adansonia digitata, is a very big and magnificient African tree.
Summary
Example
var Baobab = require('baobab');
var tree = new Baobab({
palette: {
colors: ['yellow', 'purple'],
name: 'Glorious colors'
}
});
var colorsCursor = tree.select('palette', 'colors');
colorsCursor.on('update', function() {
console.log('Selected colors have updated:', colorsCursor.get());
});
colorsCursor.push('orange');
Installation
If you want to use Baobab with node.js/io.js or browserify/webpack etc., you can use npm.
npm install baobab
npm install git+https://github.com/Yomguithereal/baobab.git
If you want to use it in the browser, just include the minified script located here.
<script src="baobab.min.js"></script>
Or install with bower:
bower install baobab
The library (as a standalone) currently weights ~20ko minified and ~6ko gzipped.
Usage
Basics
Instantiation
Creating a tree is as simple as instantiating Baobab with an initial data set.
var Baobab = require('baobab');
var tree = new Baobab({hello: 'world'});
tree.get();
>>> {hello: 'world'}
Cursors
Then you can create cursors to easily access nested data in your tree and be able to listen to changes concerning the part of the tree you selected.
var tree = new Baobab({
palette: {
name: 'fancy',
colors: ['blue', 'yellow', 'green']
}
});
var paletteCursor = tree.select('palette');
paletteCursor.get();
>>> {name: 'fancy', colors: ['blue', 'yellow', 'green']}
var colorsCursor = tree.select('palette', 'colors');
colorsCursor.get();
>>> ['blue', 'yellow', 'green']
var thirdColorCursor = tree.select('palette', 'colors', 2);
thirdColorCursor.get();
>>> 'green'
var colorCursor = paletteCursor.select('colors');
Updates
A baobab tree can obviously be updated. However, one has to understand that he won't do it, at least by default, synchronously.
Rather, the tree will stack and merge every update order you give it and will only commit them later on (note that you remain free to force a synchronous update of the tree through tree.commit
or by tweaking the tree's options).
This enables the tree to perform efficient mutations and to be able to notify any relevant cursors that the data they are watching over has changed.
Important: Note that the tree will shift the references of the objects it stores in order to enable immutabley comparisons between one version of the state and another (this is especially useful when using things as such as React's PureRenderMixin).
Example
var tree = new Baobab({hello: 'world'});
var initialState = tree.get();
tree.set('hello', 'monde'});
assert(initialState !== tree.get());
Tree level
Setting a key
tree.set('hello', 'world');
Unsetting a key
tree.unset('hello');
Cursor level
Replacing data at cursor
cursor.set({hello: 'world'});
Setting a key
cursor.set('hello', 'world');
cursor.set(['one', 'two'], 'world');
Removing data at cursor
cursor.unset();
Unsetting a key
cursor.unset('hello');
cursor.unset(['one', 'two']);
Pushing values
Obviously this will fail if the value at cursor is not an array.
cursor.push('purple');
cursor.push(['purple', 'orange']);
cursor.push('list', 'orange')
cursor.push(['one', 'two'], 'orange');
Unshifting values
Obviously this will fail if the value at cursor is not an array.
cursor.unshift('purple');
cursor.unshift(['purple', 'orange']);
cursor.unshift('list', 'orange')
cursor.unshift(['one', 'two'], 'orange');
Splicing an array
Obviously this will fail if the value at cursor is not an array.
cursor.splice([1, 1]);
cursor.splice([[1, 2], [3, 2, 'hello']]);
cursor.splice('list', [1, 1])
cursor.splice(['one', 'two'], [1, 1]);
Applying a function
var inc = function(currentData) {
return currentData + 1;
};
cursor.apply(inc);
cursor.apply('number', inc)
cursor.apply(['one', 'two'], 'orange');
Chaining functions through composition
For more details about this particular point, check this.
var inc = function(currentData) {
return currentData + 1;
};
cursor.chain(inc);
cursor.chain('number', inc)
cursor.chain(['one', 'two'], 'orange');
Shallowly merging objects
Obviously this will fail if the value at cursor is not an object.
cursor.merge({hello: 'world'});
cursor.merge('object', {hello: 'world'})
cursor.apply(['one', 'two'], {hello: 'world'});
Events
Whenever an update is committed, events are fired to notify relevant parts of the tree that data was changed so that bound elements, React components, for instance, can update.
Note however that only relevant cursors will be notified of a change.
Events can be bound to either the tree or cursors using the on
method.
Example
var tree = new Baobab({
users: {
john: {
firstname: 'John',
lastname: 'Silver'
},
jack: {
firstname: 'Jack',
lastname: 'Gold'
}
}
});
var usersCursor = tree.select('users'),
johnCursor = usersCursor.select('john'),
jackCursor = usersCursor.select('jack');
johnCursor.set('firstname', 'John the third');
jackCursor.set('firstname', 'Jack the second');
johnCursor.set('firstname', 'John the third');
Tree level
update
Will fire if the tree is updated.
tree.on('update', function(e) {
console.log('Update log', e.data.log);
console.log('Previous data', e.data.previousData);
});
invalid
Will fire if the validate
function (see options) returned an error for the current update.
tree.on('invalid', function(e) {
console.log('Error:', e.data.error);
});
get
Will fire whenever data is accessed in the tree.
tree.on('get', function(e) {
console.log('Path:', e.data.path);
console.log('Target data:', e.data.data);
});
Cursor level
update
Will fire if data watched over by the cursor has updated.
cursor.on('update', fn);
irrelevant
Will fire if the cursor has become irrelevant and does not watch over any data anymore.
cursor.on('irrelevant', fn);
relevant
Will fire if the cursor was irrelevant but becomes relevant again.
cursor.on('relevant', fn);
N.B.
For more information concerning Baobab's event emitting, see the emmett library.
Advanced
Polymorphisms
If you ever need to, know that they are many ways to select and retrieve data within a baobab.
var tree = new Baobab({
palette: {
name: 'fancy',
colors: ['blue', 'yellow', 'green'],
currentColor: 1,
items: [{id: 'one', value: 'Hey'}, {id: 'two', value: 'Ho'}]
}
});
var colorsCursor = tree.select('palette', 'colors');
var colorsCursor = tree.select(['palette', 'colors']);
var colorsCursor = tree.select('palette').select('colors');
colorsCursor.get(1);
>>> 'yellow'
paletteCursor.get('colors', 2);
>>> 'green'
tree.get('palette', 'colors');
tree.get(['palette', 'colors']);
>>> ['blue', 'yellow', 'green']
var complexCursor = tree.select('palette', 'colors', function(color) {
return color === 'green';
});
tree.get('palette', 'colors', function(color) {
return color === 'green';
});
>>> 'green'
var complexCursor = tree.select('items', {id: 'one'}, 'value');
tree.get('items', {id: 'one'}, 'value');
>>> 'Hey'
var currentColorCursor = tree.select('colors', {$cursor: ['currentColor']});
var currentColor = tree.get('colors', {$cursor: ['currentColor']});
var blankTree = new Baobab();
var tree = Baobab();
Note: when using a function or a descriptor object in a path, you are not filtering but rather selecting the first matching element. (It's actually the same as using something like lodash's _.find
).
Traversal
Going up in the tree
var tree = new Baobab({first: {second: 'yeah'}})
secondCursor = tree.select('first', 'second');
var firstCursor = secondCursor.up();
Going left/right/down in lists
var tree = new Baobab({
list: [[1, 2], [3, 4]],
longList: ['one', 'two', 'three', 'four']
});
var listCursor = tree.select('list'),
twoCursor = tree.select('longList', 1);
listCursor.down().right().get();
>>> [3, 4]
listCursor.select(1).down().left().get();
>>> 3
twoCursor.leftmost().get();
>>> 'one'
twoCursor.rightmost().get();
>>> 'four'
Getting root cursor
var tree = new Baobab({first: {second: 'yeah'}}),
cursor = tree.select('first');
var rootCursor = tree.root;
var rootCursor = cursor.root();
Checking information about the cursor's location in the tree
cursor.isRoot();
cursor.isBranch();
cursor.isLeaf();
Options
You can pass those options at instantiation.
var baobab = new Baobab(
{
palette: {
name: 'fancy',
colors: ['blue', 'green']
}
},
{
autoCommit: false
}
)
- autoCommit boolean [
true
]: should the tree auto commit updates or should it let the user do so through the commit
method? - asynchronous boolean [
true
]: should the tree delay the update to the next frame or fire them synchronously? - facets object: a collection of facets to register when the tree is istantiated. For more information, see facets.
- immutable boolean [
false
]: should the tree's data be immutable? Note that immutability is performed through Object.freeze
. - syncwrite boolean [
false
]: when in syncwrite mode, all writes will apply to the tree synchronously, so you can easily read your writes, while keeping update events asynchronous. - validate function: a function in charge of validating the tree whenever it updates. See below for an example of such function.
- validationBehavior string [
rollback
]: validation behavior of the tree. If rollback
, the tree won't apply the current update and fire an invalid
event while notify
will only emit the event and let the tree enter the invalid state anyway.
Validation function
function validationFunction(previousState, newState, affectedPaths) {
if (!valid)
return new Error('Invalid tree because of reasons.');
}
var tree = new Baobab({...}, {validate: validationFunction});
Facets
Facets can be considered as a "view" on the data of your tree (a filtered version of an array stored within your tree, for instance).
They watch over some paths of your tree and will update their cached data only when needed. As for cursors, you can also listen to their updates.
Facets can be defined at the tree's instantiation likewise:
var tree = new Baobab(
{
projects: [
{
id: 1,
name: 'Tezcatlipoca',
user: 'John'
},
{
id: 2,
name: 'Huitzilopochtli',
user: 'John'
},
{
id: 3,
name: 'Tlaloc',
user: 'Jack'
}
],
currentProjectId: 1
},
{
facets: {
currentProject: {
cursors: {
id: ['currentProjectId'],
projects: ['projects']
},
get: function(data) {
return _.find(data.projects, {id: data.id});
}
},
filteredProjects: {
cursors: {
projects: ['projects']
},
get: function(data) {
return data.projects.filter(function(p) {
return p.user === 'John';
});
}
},
}
}
)
You can then access facets' data and listen to their changes thusly:
var facet = tree.facets.currentProject;
facet.get();
facet.on('update', function() {
console.log('New value:', facet.get());
});
History
Baobab lets you record the state of any cursor so you can seamlessly implement undo/redo features.
Example
var baobab = new Baobab({colors: ['blue']}, {asynchronous: false}),
cursor = baobab.select('colors');
cursor.startRecording(10);
cursor.push('yellow');
cursor.push('purple');
cursor.push('orange');
cursor.get();
>>> ['blue', 'yellow', 'purple', 'orange']
cursor.undo();
cursor.get();
>>> ['blue', 'yellow', 'purple']
cursor.undo(2);
cursor.get();
>>> ['blue']
Starting recording
Default max number of records is 5.
cursor.startRecording(maxNbOfRecords);
Stoping recording
cursor.stopRecording();
Undoing
cursor.undo();
cursor.undo(nbOfSteps);
Clearing history
cursor.clearHistory();
Checking if the cursor has an history
cursor.hasHistory();
Checking whether the cursor is currently recording
cursor.recording;
Retrieving the cursor's history
cursor.getHistory();
Update specifications
If you ever need to specify complex updates without replacing the whole subtree you are acting on, for readability or performance reasons, you remain free to use Baobab's internal update specifications.
Those are widely inspired by React's immutable helpers and can be used through tree.update
or cursor.update
.
Specifications
Those specifications are described by a JavaScript object that follows the nested structure you are trying to update and applying dollar-prefixed commands at leaf level.
The available commands are the following and are basically the same as the cursor's updating methods:
$set
$apply
$chain
$push
$unshift
$splice
$merge
$unset
Example
var tree = new Baobab({
users: {
john: {
firstname: 'John',
lastname: 'Silver'
},
jack: {
firstname: 'Jack',
lastname: 'Gold'
}
}
});
tree.update({
users: {
john: {
firstname: {
$set: 'John the 3rd'
}
},
jack: {
firstname: {
$apply: function(firstname) {
return firstname + ' the 2nd';
}
}
}
}
});
var cursor = tree.select('users', 'john');
cursor.update({
firstname: {
$set: 'Little Johnsie'
}
})
Chaining mutations
Because updates will be committed later, update orders are merged when given and the new order will sometimes override older ones, especially if you set the same key twice to different values.
This is problematic when what you want is to increment a counter for instance. In those cases, you need to chain functions that will be assembled through composition when the update orders are merged.
var inc = function(i) {
return i + 1;
};
cursor.apply(inc);
cursor.apply(inc);
cursor.chain(inc);
cursor.chain(inc);
Common pitfalls
Immutable behaviour
TL;DR: Don't mutate things in your baobab tree. Let the tree handle its own mutations.
For performance and size reasons baobab does not (yet?) use an immutable data structure. However, because it aims at producing a one-way data flow for your application state (like React would at component level), it must be used like an immutable data structure.
For this reason, don't be surprised if you mutate things and break your tree.
var users = tree.get('users');
users[0].name = 'Jonathan';
var o = {hello: 'world'};
tree.set('key', o);
o.hello = 'other world';
Note that, if you want the tree to be immutable, you can now enable it through the immutable
option.
Releasing
In most complex use cases, you might need to release the manipulated objects, i.e. kill their event emitters and wipe their associated data.
Thus, any Baobab object can be cleared from memory by using the release
method. This applies to trees, cursors and facets.
tree.release();
cursor.release();
facet.release();
Note also that releasing a tree will consequently and automatically release every of its cursors and facets.
Philosophy
UIs as pure functions
UIs should be, as far as possible, considered as pure functions. Baobab is just a way to provide the needed arguments, i.e. the data representing your app's state, to such a function.
Considering your UIs like pure functions comes along with collateral advantages like easy undo/redo features, state storing (just save your tree in the localStorage
and here you go) and easy isomorphism.
Only data should enter the tree
You shouldn't try to shove anything else than raw data into the tree. The tree hasn't been conceived to hold classes or fancy indexes with many circular references and cannot perform its magic on it. But, probably such magic is not desirable for those kind of abstractions anyway.
That is to say the data you insert into the tree should logically be JSON-serializable else you might be missing the point.
Migration
From v0.4.x to 1.0.0
A lot of changes occurred between 0.4.x
and 1.0.0
. Most notable changes being the following ones:
- The tree now shift references by default.
- React integration has improved and is now handled by baobab-react.
cursor.edit
and cursor.remove
have been replaced by cursor.set
and cursor.unset
single argument polymorphisms.- A lot of options (now unnecessary) have been dropped.
- Validation is no longer handled by
typology
so you can choose you own validation system and so the library can remain lighter. - Some new features such as:
$splice
, facets and so on...
For more information, see the changelog.
Contribution
Contributions are obviously welcome. This project is nothing but experimental and I would cherish some feedback and advice about the library.
Be sure to add unit tests if relevant and pass them all before submitting your pull request.
git clone git@github.com:Yomguithereal/baobab.git
cd baobab
npm install
npm test
npm run lint
npm run build
License
MIT