Treehouse JS
Overview
Treehouse is an opinionated small javascript framework for writing single-page web apps.
Its main concern is maintaining application state, and organising business logic into actions, that modify the state.
For the view/template layer, you can use your preferred library.
The basic flow as as follows:
-
ALL the state about the system is kept in one immutable state tree. The tree should be normalized (no duplicated data) and JSON serializable, i.e. contains only objects, arrays, strings and numbers.
-
EVERY single input enters the system via an "action". An "input" means:
- user interaction with the DOM, e.g. a click
- a message from a websocket
- a timer/interval callback
- a URL update
-
An action updates the tree in some way. As the tree is immutable, the whole tree needs to be changed. Treehouse provides "cursor" objects to make this extremely easy.
Usage with React
See Treehouse-React.
The treehouse app
const treehouse =
Requiring treehouse returns a singleton treehouse app, which ties together all the other components.
Initializing the state tree
treehouse
Cursors
Given that the tree should be immutable (which has great benefits when using with libraries like React), if we wanted to update the rating of "Dead Man's Shoes" to 84, then if we had a plain javascript object we'd need to update every branch up to the root, which would look something like this:
let newTree = Object
Yuk! Even with Javascript spread syntax it would be pretty bad, and what's more, error-prone.
Cursors hold a reference to the tree object internally, and update parent branches for us, so instead we can just do
treehouse // .at(...) returns a Cursor object
We can also update using a "reducer" function, which should always return a new object
treehouse
Furthermore, because this will be used so often, update
is aliased to $
.
Treehouse provides a few reducer functions in 'treehouse/reducers'
,
and typically the user will wish to define their own.
Any extra args sent to update
/$
are passed to the reducer.
treehouse
Furthermore, you can register reducers using
treehouse
and then use by name
treehouse
To get the raw data at cursor, use get
treehouse // 84
Actions
As described above, every single input that might change the state should enter the system via an "action".
Each action's main job is to update the state tree. Each registered function takes the state tree, and a single payload argument.
First register actions
treehouse
To call the action, we build it with
let action = treehouseaction'updateRating'
and call it when we need to
Alternatively, we can pass the payload in when building (effectively currying the payload argument)
let action = treehouseaction'updateRating' filmId: 'id2' rating: 84
and call it with
This works particularly well with libraries like React, where we can do things like
<a =>Update Rating</a>
Asynchronous actions
If you change the tree asynchronously in an action, you should call another action once the asynchronous event has happened. A third argument is provided for this
treehouse
Queries
Queries query the tree and return data. They are automatically cached, and only change when any parts of the tree it cares about are changed.
treehouse
Once registered, they can be accessed with
treehouse
The actual data can be accessed with get
treehouse // [{id: "id2", ...}, ...]
Any arguments are passed as a second argument to the registered get
function
treehouse let query = treehouse
Filters
Cursors, e.g. treehouse.at('some', 'path')
and queries, e.g. treehouse.query('someQuery', {some: 'args'})
can be streamed through a filter, e.g.
treehouse
Registering one is very easy - just give a function that takes data and returns the filtered data, e.g.
treehouse
TreeViews
Create a "TreeView" by selecting the items you care about
let treeView = treehouse
Get data
treeView // { // messages: [...], // unread: 7462964 // }
To watch for data changes at any of the specified paths
treeView
To unwatch
treeView
Setting through filters, queries and treeViews
Given a cursor treehouse.at('selectedUserID')
we can both get()
and set(value)
.
But what about something that's been filtered,
e.g. treehouse.at('users').filter('objectToArray')
,
or a query, e.g. treehouse.query('selectedUserName')
?
Treehouse doesn't let you set through filters, queries and treeViews, because you're encouraged to update any state within actions, using just cursors onto the tree.
However, you can retrieve a list of changes that need to happen by "putting back" values through filters or queries (or cursors).
Putting values back through cursors
This simply returns the changes that need to happen, e.g.
let changes = treehouse changes // [{path: ['some', 'path'], value: 4}]
Putting values back through filters
If a filter can be defined two-way, e.g. to filter between
"some words" <------> "SOME WORDS"
then we can register both a forward and reverse filter function
treehouse
Then putting back through the filter calls the reverse function on the way through.
let stream = treehouselet changes = streamchanges // [{path: ['words'], value: 'new words'}]
Setting through queries
We can add a change
option to the query declaration, which should return an object with changes to be made
treehouse
Then we get changes to be made with
let changes = treehousechanges // [{path: ['selectedUserID'], value: 63}]
Setting through a treeView
A treeview simply collates the changes made from each item
let treeView = treehouse let changes = treeView changes // [ path: ... value: ... ...
Obviously this only works if each item is defined correctly as two-way, as above.
Applying changes
We can apply changes to any cursor with apply
tree // tree here is a cursor, like the one yielded in actions
Building up changes like this is how the Treehouse Router works. It collects changes to be made when a url is changed, then this list of changes can be passed directly into the "url changed" action, and changes applied accordingly.