redux-action-tree (BETA)
Inspired by the Cerebral 1.x signals, a behaviour tree like implementation of actions for complex state changes.
Note about Cerebral 2
In Cerebral 2 action-tree has evolved into
function-tree
which contains now a Redux Provider.
For more information take a look at the article
The case for function-tree.
What are these Cerebral signals conceptually?
With Redux you typically think of actions and action creators. Action creators are like commands, they tell your app what to do. For example when your application mounts you would trigger an action creator saying: "getInitialData". With signals you do not let your UI (or other events) command your application logic, they only tell your application what happened. This also aligns with the concept of keeping your UI as dumb as possible.
A good analogy for signals is how your body works. If you burn your finger the finger does not command your arm to pull away. Your finger just sends a signal to your brain about it being burned and the brain changes your "state of mind", which the arm will react to. With this analogy you would not name your signal "getInitialData", but "appMounted", because that is what happened. Your signal then defines what is actually going to happen... which in this case is getting the initial data.
A signal uses an action-tree tree to define its behaviour. Think of this as a behaviour tree, like in games. It makes you able to declaratively describe what is going to happen in your app when a signal triggers.
How do I create a signal?
import {signal, dispatch} from 'redux-action-tree';
import {APP_LOADING} from 'constants';
export default signal([
dispatch(APP_LOADING)
]);
You use the signal
factory to define your signal. A signal is an array which executes one action at a time. In the example above we use the dispatch
factory to create an action that dispatches the type "APP_LOADING"
. That means you do not create traditional redux actions, you create signals composing actions... and even other signals.
To use a signal you connect
it just like you connect traditional actions:
import appMounted from 'signals/appMounted';
import buttonClicked from 'signals/buttonClicked';
...
connect(state => state.app, {appMounted, buttonClicked})(App);
Passing a payload to a signal
When you trigger a signal you can pass it a payload:
class App extends React.Component {
render() {
return (
<div>
<button onClick={() => this.props.buttonClicked({foo: 'bar'})}>Click me</button>
</div>
);
}
}
This payload has to be an object and it will be passed into your signal. This payload is available to the actions in the signal.
How do I create actions?
Let us first look at the signature. An action is just a function that receives a context
holding some properties. Think of the action as the thunk
middleware function, but it is part of a flow:
function myAction({input, output, getState, dispatch}) {
input
output
getState
dispatch
}
Passing a payload
So lets take this step by step. When you trigger a signal, passing a payload, that will be available on the input
.
this.props.somethingHappened({foo: 'bar'});
function myAction({input, output, getState, dispatch}) {
input
}
Adding to the payload
Typically you want more than one thing to happen when a signal triggers. You can add multiple actions to the action tree:
import {signal, dispatch} from 'redux-action-tree';
import myAction from 'actions/myAction';
import myOtherAction from 'actions/myOtherAction';
export default signal([
myAction,
myOtherAction
]);
This signal will first trigger myAction
and then myOtherAction
. We can make myAction
output some new data to the payload:
function myAction({input, output, getState, dispatch}) {
output({some: 'data'});
}
Which means that myOtherAction
will now look like this:
function myOtherAction({input, output, getState, dispatch}) {
input
}
Outputting to paths
Much like a behaviour tree you can output to paths. Lets look at an example:
export default signal([
getUser, {
success: [
dispatch(USER_LOADED)
],
error: [
dispatch(USER_LOADED_ERROR)
]
}
]);
Typically you use paths to handle async actions, which we will look at shortly, but you can use them for anything:
export default signal([
withUserRole, {
admin: [],
superuser: [],
user: [],
other: []
}
]);
To define what outputs an action can make you give it a property outputs
:
function myAction({input, output, getState, dispatch}) {
if (someCondition) {
output.success();
} else {
output.error();
}
}
myAction.outputs = ['success', 'error'];
Going async
So signals are pretty powerful when it comes to asynchronicity. To define an asynchronous action you simply:
function myAction({input, output, getState}) {
setTimeout(() => output(), 1000);
}
myAction.async = true;
async function myAction({input, output, getState}) {
setTimeout(() => output(), 1000);
}
When this action is put into the signal:
export default signal([
someAsyncAction,
someSyncAction
]);
it will wait for one second before the next action runs. It is also possible to group async actions using an array, making them run in parallel:
export default signal([
[
someAsyncAction,
someAsyncAction2
],
someSyncAction
]);
You can even add paths to each of these async actions:
export default signal([
[
someAsyncAction, {
success: [],
error: []
},
someAsyncAction2, {
success: [],
error: []
}
],
someSyncAction
]);
This structure of parallel and nested actions can go as deep as you want.
Grabbing state inside an action
Any action can grab the existing state of the app:
function myAction({input, output, getState}) {
getState()
}
Dispatching to the reducers
Only synchronous actions can dispatch to the reducers. Technically it does not have to be like this, but it is a forced convention for two reasons:
- An asynchronous action will most surely have some work to do not related to dispatching. By not allowing dispatching we ensure that the action does its ONE thing and offputs any state changes to the next action. For example the action
getUser
should only get the user, not also set it - Not allowing async actions to make changes to the state also makes it easier to implement debugging tools
To dispatch you do it the same way as you are used to:
function myAction({input, output, getState, dispatch}) {
dispatch({type: SOME_TYPE, foo: 'bar'})
}
Factories
I have mentioned factories a couple of times. A factory is general term, but in this context it is "a function that returns an action". Lets look at how the included dispatch
factory works:
function dispatch(actionType) {
function action({input, dispatch}) {
dispatch({type: actionType, payload: input});
}
return action;
}
That is it! A function returning an action.
Factories can be used for many different things. For example creating an HTTP service for your app:
export default signal([
get('/api/items'), {
success: [],
error: []
}
])
Which would look something like:
import axios from 'axios';
function get(url) {
function action({input, output}) {
axios.get(url)
.then(response => output.success({result: response.result}))
.catch(error => output.error({error: error.message}));
}
action.async = true;
action.outputs = ['success', 'error'];
return action;
}
Composing chains
There is a new awesome feature in ES2015 called the spread operator. It is a great tool for composing signals with each other. One example could be that you want to grab some todos when you fire up the app, but you also want to grab them when the user clicks an "update" button or whatever.
export default signal([
dispatch(APP_LOADING),
getUser, {
success: [
dispatch(USER_LOADED),
...getTodos
],
error: []
},
dispatch(APP_LOADED)
]);
export default signal([
dispatch(TODOS_LOADING),
...getTodos
dispatch(TODOS_LOADED)
]);
Summary
Signals is at the core of the Cerebral framework and has proven itself a powerful concept to build complex applications as it helps you keep a mental image of the complex flows in your app.