Baobab
Baobab is a JavaScript persistent and immutable (at least by default) data tree supporting cursors and enabling developers to easily navigate and monitor nested data through events.
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(e) {
var eventData = e.data;
console.log('Selected colors have updated:', eventData.data);
});
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 ~25ko minified and ~7ko 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 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, even if you can write the tree synchronously, update
events won't be, at least by default, fired until next frame.
If you really need to fire an update synchronously (typically if you store a form's state within your app's state, for instance), your remain free to use the tree.commit()
method or tweak the tree's options to fit your needs.
Important: Note that the tree, being a persistent data structure, 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 strategies as such as React's pure rendering).
Example
var tree = new Baobab({hello: 'world'});
var initialState = tree.get();
tree.set('hello', 'monde');
assert(initialState !== tree.get());
Cursor level
Since Baobab is immutable by default, note that all the methods below will return the data of the updated node for convenience and so you don't have to use .get
afterwards to continue what you were doing.
Replacing data at cursor
var newData = cursor.set({hello: 'world'});
Setting a key
var newData = cursor.set('hello', 'world');
var newData = cursor.set(['one', 'two'], 'world');
var newData = cursor.select('one', 'two').set('world');
var newData = cursor.select('one').set('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.
var newArray = cursor.push('purple');
var newArray = cursor.push('list', 'orange')
var newArray = cursor.push(['one', 'two'], 'orange');
Unshifting values
Obviously this will fail if the value at cursor is not an array.
var newArray = cursor.unshift('purple');
var newArray = cursor.unshift('list', 'orange')
var newArray = cursor.unshift(['one', 'two'], 'orange');
Concatenating
Obviously this will fail if the value at cursor is not an array.
var newArray = cursor.concat(['purple', 'yellow']);
var newArray = cursor.concat('list', ['purple', 'yellow'])
var newArray = cursor.concat(['one', 'two'], ['purple', 'yellow']);
Splicing an array
Obviously this will fail if the value at cursor is not an array.
var newArray = cursor.splice([1, 1]);
var newArray = cursor.splice([[1, 2], [3, 2, 'hello']]);
var newArray = cursor.splice('list', [1, 1])
var newArray = cursor.splice(['one', 'two'], [1, 1]);
Applying a function
var inc = function(currentData) {
return currentData + 1;
};
var newData = cursor.apply(inc);
var newData = cursor.apply('number', inc)
var newData = cursor.apply(['one', 'two'], inc);
Shallowly merging objects
Obviously this will fail if the value at cursor is not an object.
var newObject = cursor.merge({hello: 'world'});
var newObject = cursor.merge('object', {hello: 'world'})
var newObject = cursor.merge(['one', 'two'], {hello: 'world'});
Tree level
Note that you can use any of the above methods on the tree itself for convenience:
Example
tree.set({hello: 'world'});
tree.set('hello', 'world');
tree.set(['message', 'hello'], 'world');
tree.set
tree.unset
tree.apply
tree.push
tree.unshift
tree.splice
tree.concat
tree.merge
Events
Whenever an update is committed, events are fired to notify relevant parts of the tree that data was changed so that bound elements, UI components, for instance, may 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 (this concerns the asynchronous updates of the tree).
tree.on('update', function(e) {
var eventData = e.data;
console.log('Current data:', eventData.currentData);
console.log('Previous data:', eventData.previousData);
console.log('Transaction details:', eventData.transaction);
console.log('Affected paths', eventData.paths);
});
write
Will fire whenever the tree is written (synchronously, unlike the update
event).
tree.on('write', function(e) {
console.log('Affected path:', e.data.path);
});
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('Solved path:', e.data.solvedPath);
console.log('Target data:', e.data.data);
});
select
Will fire whenever a path is selected in the tree.
tree.on('select', function(e) {
console.log('Path:', e.data.path);
console.log('Resultant cursor:', e.data.cursor);
});
Cursor level
update
Will fire if data watched over by the cursor has updated.
cursor.on('update', function(e) {
console.log('Current data:', eventData.currentData);
console.log('Previous data:', eventData.previousData);
});
N.B.
For more information concerning Baobab's event emitting, see the emmett library.
Advanced
Polymorphisms
If you ever need to, know that there 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');
var paletteCursor = tree.select('palette');
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 blankTree = new 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
).
Computed data (facets)
For convenience, Baobab allows you to store computed data within the tree.
Computed data node can be considered as a "view" or "facet" over some parts of the data stored within your tree (a filtered version of an array, for instance).
Those specific nodes must have, by convention, a key starting with $
and can define dependencies to some paths within the tree.
Example
var tree = new Baobab({
user: {
name: 'John',
surname: 'Smith',
$fullname: {
cursors: {
name: ['user', 'name'],
surname: ['user', 'surname']
},
get: function(data) {
return data.name + ' ' + data.surname;
}
}
},
data: {
messages: [
{from: 'John', txt: 'Hey'},
{from: 'Jack', txt: 'Ho'}
],
$fromJohn: {
cursors: {
messages: ['data', 'messages'],
},
get: function(data) {
return data.messages.filter(function(m) {
return m.from === 'John';
});
}
}
}
});
var tree = new Baobab({
user: {
name: 'John',
surname: 'Smith',
$fullname: [
['user', 'name'],
['user', 'surname'],
function(name, surname) {
return name + ' ' + surname;
}
]
},
data: {
messages: [
{from: 'John', txt: 'Hey'},
{from: 'Jack', txt: 'Ho'}
],
$fromJohn: [
['data', 'messages'],
function(messages) {
return messages.filter(function(m) {
return m.from === 'John';
});
}
]
}
});
tree.get('user', '$fullname');
>>> 'John Smith'
tree.get('data', '$fromJohn');
>>> [{from: 'John', txt: 'Hey'}]
tree.get('data', '$fromJohn', 'txt');
>>> 'Hey'
tree.set(['data', '$fromJohn', 'txt'], 'Yay');
>>> Error!
The computed data node will of course automatically update whenever at least one of the watched paths is updated.
It is not possible, at the time being, to modify facets' definition at runtime. It may however be allowed in further versions.
Specialized getters
tree/cursor.exists
Check whether a specific path exists within the tree (won't fire a get
event).
tree.exists();
cursor.exists();
tree.exists('hello');
tree.exists('hello', 'message');
tree.exists(['hello', 'message']);
tree/cursor.serialize
Retrieve only raw data (therefore avoiding computed data) from the tree or a cursor.
This is useful when you want to serialize your tree into JSON, for instance.
tree.serialize();
cursor.serialize();
tree.serialize('hello');
tree.serialize('hello', 'message');
tree.serialize(['hello', 'message']);
tree.watch
Create a watcher that will fire an update
event if any of the given paths is affected by a transaction.
This is useful to create modules binding a state tree to UI components.
var tree = new Baobab({
one: {
name: 'John'
},
two: {
surname: 'Smith'
}
});
var watcher = tree.watch({
name: ['one', 'name'],
surname: ['two', 'surname']
});
watcher.on('update', function(e) {
});
tree/cursor.project
Retrieve data from several parts of the tree by following the given projection:
var tree = new Baobab({
one: {
name: 'John'
},
two: {
surname: 'Smith'
}
});
tree.project({
name: ['one', 'name'],
surname: ['two', 'surname']
});
>>> {name: 'John', surname: 'Smith'}
tree.project([
['one', 'name'],
['two', 'surname']
]);
>>> ['John', 'Smith']
Traversal
Getting root cursor
var tree = new Baobab({first: {second: 'yeah'}}),
cursor = tree.select('first');
var rootCursor = tree.root;
var rootCursor = cursor.root();
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().right().get();
>>> 4
listCursor.select(1).down().right().left().get();
>>> 3
twoCursor.leftmost().get();
>>> 'one'
twoCursor.rightmost().get();
>>> 'four'
Getting 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? - immutable boolean [
true
]: should the tree's data be immutable? Note that immutability is performed through Object.freeze
and should be disabled in production for performance reasons. - persistent boolean [
true
]: should the tree be persistent. Know that disabling this option, while bringing a significant performance boost on heavy data, will make you lose the benefits of your tree's history and O(1)
comparisons of objects. - 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});
History
Baobab lets you record the successive states 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
If you do not provide a maximum number of records, will record everything without any limit.
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();
Retrieving the cursor's history
cursor.getHistory();
Common pitfalls
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 tree or cursor object can be cleared from memory by using the release
method.
tree.release();
cursor.release();
Note also that releasing a tree will consequently and automatically release every of its cursors and computed data nodes.
Philosophy
User interfaces as pure functions
User interfacess 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 usage in both client & server.
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 v1 to v2
- The tree is now immutable by default (but you can shunt this behavior through a dedicated option).
- Writing to the tree is now synchronous for convenience. Updates remain asynchronous for obvious performance reasons.
- You cannot chain update methods now since those will return the affected node's data to better tackle immutability.
- The strange concat-like behavior of the
push
and unshift
method was dropped in favor of the concat
method. - Facets are now full-fledged computed data node sitting within the tree itself.
- The weird
$cursor
sugar has now been dropped. - The update specifications have been dropped.
From v0.4.x to v1
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