When people ask me how I build user interfaces these days they often stare back at me with wide-eyed shock when I tell them I manage all my state in a single tree using Flux to populate it.
Wait a minute, you store everything in a single tree? Like one giant variable?
Yup. And it's awesome.
Really?
Totes McGoats
In order for me to make my audacious claims of why I think this approach is awesome, let me first explain the approach. It's really quite simple, all of your application's state is managed in a single tree. The tree is populated using a concept called "actions".
Actions are serialized representations of events that get dispatched your "store", your stores job is to manage that single state tree. Some examples of actions:
The results of an HTTP request:
{
type: 'FETCH_COMMENTS_SUCCESS',
payload: {
comments: [...]
}
}
A user interaction:
{
type: "SAVE_COMMENT";
}
Or any other type of state changing mechanism.
Your store receives each action and figures out what state to derive from it. For example, the system make a request to get the latest comments, it then dispatches an action, the store then says:
Ahhh,
FETCH_COMMENTS_SUCCESS
, I know precisely what to do with you, I save yourcomments
into cache and notify my observers that state has changed.
So what does this process look like for a typical "Download and Show Data" requirement? Well first we have what we call an "action creator" which is really just a function that creates an action object, or a serialized representation of a state change, and dispatches it to the store. So it all starts with an action creator:
function getComments(dispatch) {
$.ajax({
url: "/post/single-state-tree/comments",
}).then((comments) => {
var action = {
type: "FETCH_COMMENTS_SUCCESS",
payload: {
comments,
},
};
dispatch(action);
});
}
We then have a store with a dispatch
function that manages all state changes.
function dispatch(action) {
if (action.type === 'FETCH_COMMENTS_SUCCESS') {
// Aww yes my good sir, I know precisely what to do with you.
// Set next state...
state = Object.assign({}, state, { comments: action.payload.comments}};
}
}
These two are then combined accordingly to dispatch the action.
getComments(dispatch);
Typically dispatch is associated with a store, I'll get into that in just a bit. Anyways, subscribers to state changes are then notified, "Hey Miss, your state looks different!"
A single state tree is a single tree that represents your state. This approach is different than traditional flux in that you have a singular store which manages all of your states in one mega tree. First, though let's recap some of the benefits of Flux.
Decouples actions from state changes. This means you can have one action make multiple state changes. For example, HTTP responses are decoupled from state changes. This is useful for endpoints that contain nested entities. For example, say you download all of the comments and they come back with nested user objects, with flux you can simply populate the user section of the tree, next time someone asks for that user, you can skip the request because you know you have the sufficient state.
The questions, "What is my state?" & "When does my state change?" is answered simply instead of being littered throughout your application code.
Ok, that all sounds great but at what cost! I hear you gasping...
But, but, but this approach seems unwieldy and infeasible.
Does it, dear friend? It's not. There are tools to rope in the hard parts. Here is an example of my tool of choice for leveraging this technique, called Redux.
Redux has a remarkably simple interface. And its job is solving precisely what
this article is all about, a single state tree managed by actions. Let's start
out by making a store, in redux this function is called createStore
.
import { createStore } from "redux";
let store = createStore(handler);
When we call createStore
we give it our action handler or our reducer. The
reducers job is to take the current state and the action and return the next
state. Following our example above let's write a simple reducer.
function reducer(state = {}, action) {
if (action.type === "FETCH_COMMENTS_SUCCESS") {
return Object.assign({}, state, {
comments: action.payload.comments,
});
}
}
let store = createStore(reducer);
Writing a reducer like this can get a little unwieldy because you are managing the structure of your tree yourself, thankfully Redux offers a little utility function called combineReducers, its job is to take multiple reducers and manage the tree structure for you. It can be as nested as you like but we'll demonstrate a flat tree below.
let reducerTree = {
comments: (state = {}, action) {
if (action.type === 'FETCH_COMMENTS_SUCCESS') {
return action.payload.comments;
}
},
users: ...,
posts: ...
};
let store = createStore(combineReducers(reducerTree))
Now our comments reducer is just the reducer for comments, our user reducer is just the reducer for users etc.. Our state tree would look like this:
{
comments: {},
users: {},
posts: {}
}
Notice how it matches the reducerTree we provided to combineReducers?
Since we have our reducer wired up when we call dispatch
on the store with
that particular action, the store's state tree will change to a copied state
tree with the comments included. But how do we access this state tree? Well its
pretty simple really, we call getState
.
let state = store.getState();
This can be called at any point in time to retrieve your applications current
state but you typically call it onSubscribe
. Which lets you listen for changes
to the state and do something accordingly, you know, like render your interface
again.
store.subscribe(() => {
let state = store.getState();
render(ui, state);
});
This gives you simple, synchronous renders, where each render is not a mix of changes over time but a singular snapshot of state at a given point in time.