Should I read this post?
I think that you are more likely to find value in reading this post if you are:
- Trying to cut down on your Redux boilerplate; or
- Interested in improving your Redux architecture or file structure; or
- Trying to navigate Redux actions as 'commands' versus 'events'.
Key takeaways are at the foot of this post.
I recently watched a recording of a great talk by Yazan Alaboudi, 'Our Redux Anti Pattern: A guide to predictable scalability' (slides here). I really love hearing and reading about people's thoughts on Redux architecture, as something I've thought a lot about.
In the talk, Yazan makes an excellent case for two points:
- Writing Redux actions as commands1 is an anti-pattern; and
- A well-written Redux action should represent a business event.
In this particular post, I'm going to respond to the first of these points, with a view to discussing the second in a separate post.
Here, my core contention is this: Redux-Leaves solves most - and perhaps all - of Yazan's 'anti-pattern' criticisms of command actions.
I'll do this in two parts:
- Firstly, I'll outline Yazan's case against command actions; and
- Secondly, I'll demonstrate how Redux-Leaves solves those problems.
What is Yazan's case against command actions?
I recommend watching Yazan's own explanation, but below I will outline my interpretation of what he says.
Example code
Yazan provides some examples of command actions and their consequences:
Command Action Example (Redux setup)
// in scoreboardReducer.js
const INITIAL_STATE = {
home: 0,
away: 0
};
function scoreboardReducer(state = INITIAL_STATE, action) {
switch(action.type) {
case "INCREMENT_SCORE": {
const scoringSide = action.payload;
return { ...state, [scoringSide]: state[scoringSide] + 1};
}
default: return state;
}
}
//in crowdExcitmentReducer.js
const INITIAL_STATE = 0;
function crowdExcitementReducer(state = INITIAL_STATE, action) {
switch(action.type) {
case "INCREASE_CROWD_EXCITEMENT": return state + 1;
default: return state;
}
}
Command Action Consequences (Component dispatching)
// in GameComponent
class GameComponent extends React.Component {
scoreGoal() {
dispatch({ type: "INCREMENT_SCORE", scoringSide: "home"});
dispatch({ type: "INCREASE_CROWD_EXCITEMENT"});
// potentially more dispatches
}
render() {
//...
}
}
In a key slide, he then lays out some costs that he sees in these command-oriented examples:
Disadvantages of Command actions
- does not capture business semantics
- actions are coupled to reducers
- too many actions are firing
- unclear why state is changing
- leads to a lot of boilerplate
- does not scale
Here are my observations on each of these (with the exception of 'business semantics', which I'll tackle in a separate post):
Actions are coupled to reducers
I think that, when it comes to the example code provided by Yazan, it's extremely fair to note that actions are coupled to reducers. The "INCREMENT_SCORE"
action type looks entirely coupled to the scoreboardReducer
, and the "INCREASE_CROWD_EXCITEMENT"
looks entirely coupled to the crowdExcitementReducer
.
This is not a good pattern because it means we have extremely low code reusability. If I want to increment something else, like the stadium audience size, I need to use another action type, "INCREMENT_AUDIENCE_SIZE"
, even though the resulting change in state is going to be extremely similar.
Too many actions are firing
Again, when it comes to Yazan's example code, I think it's fair to note that more actions are being dispatched in the scoreGoal
function that feels necessary. If a goal has been scored, a single thing has happened, and yet we're triggering multiple actions.
This is not a good pattern because it will clog up your Redux DevTools with lots of noise, and potentially could cause some unnecessary re-renders with your Redux state updating multiple times instead of doing a single large update.
Unclear why state is changing
I'm not convinced that this is a big problem. For me, in Yazan's example code, it's not too difficult for me to make the link from scoreGoal
to "INCREASE_SCORE"
and "INCREASE_CROWD_EXCITEMENT"
.
To the extent that the 'why' is unclear, I think that this can be solved by better-commented code - which isn't a situation unique to command actions in Redux, but something that applies to all imperatively-flavoured code.
Leads to a lot of boilerplate / Does not scale
I think these two are both legitimate concerns (and, at core, the same concern): if, every time we decide we want to effect a new change in state, we have to decide on a new action type and implement some new reducer logic, we will quickly get a profileration of Redux-related code, and this means that it doesn't scale very well as an approach.
How does Redux-Leaves solve these problems?
First, let's look at some example code, equivalent to the previous examples:
Redux-Leaves setup
// store.js
import { createStore } from 'redux'
import reduxLeaves from 'redux-leaves'
const initialState = {
crowdExcitment: 0,
scoreboard: {
home: 0,
away: 0
}
}
const [reducer, actions] = reduxLeaves(initialState)
const store = createStore(reducer)
export { store, actions }
Component dispatching
// in GameComponent
import { bundle } from 'redux-leaves'
import { actions } from './path/to/store'
class GameComponent extends React.Component {
scoreGoal() {
// create and dispatch actions to increment both:
// * storeState.scoreboard.home
// * storeState.crowdExcitement
dispatch(bundle([
actions.scoreboard.home.create.increment(),
actions.crowdExcitement.create.increment()
// potentially more actions
]));
}
render() {
//...
}
}
Here's an interactive RunKit playground with similar code for you to test and experiment with.
Hopefully, in comparison to the example of more typical command actions given by Yazan, this Redux-Leaves setup speaks for itself:
- Only one initial state and reducer to handle
- No more writing reducers manually yourself
- No more manual case logic manually yourself
I'll also now cover how it addresses each of the specific problems articulated above:
- Actions are no longer coupled to reducers
- Too many actions? Bundle them into one
- Extreme clarity on what state is changing
- Incredibly minimal boilerplate
- Simple to scale
Actions are no longer coupled to reducers
Redux-Leaves gives you an increment
action creator out-of-the-box, that can be used at an arbitrary state path from actions
.
To increment... |
create and dispatch this action... |
---|---|
storeState.crowdExcitement |
actions.crowdExcitement.create.increment() |
storeState.scoreboard.away |
actions.scoreboard.away.create.increment() |
storeState.scoreboard.home |
actions.scoreboard.home.create.increment() |
You get a whole bunch of other default action creators too, which can all be effected at an arbitrary leaf of your state tree.
Too many actions? Bundle them into one
Redux-Leaves has a named bundle
export, which accepts an array of actions created by Redux-Leaves, and returns a single action that can effect all those changes in a single dispatch.
import { createStore } from 'redux'
import reduxLeaves, { bundle } from 'redux-leaves'
const initialState = {
crowdExcitment: 0,
scoreboard: {
home: 0,
away: 0
}
}
const [reducer, actions] = reduxLeaves(initialState)
const store = createStore(reducer)
store.getState()
/*
{
crowdExcitement: 0,
scoreboard: {
home: 0,
away: 0
}
}
*/
store.dispatch(bundle([
actions.scoreboard.home.create.increment(7),
actions.scoreboard.away.create.increment(),
actions.crowdExcitement.create.increment(9001)
]))
store.getState()
/*
{
crowdExcitement: 9001,
scoreboard: {
home: 7,
away: 1
}
}
*/
Extreme clarity on what state is changing
In Yazan's example of command actions, it's not obvious how the overall store state is going to be affected by the dispatches - which score is being incremented in which bit of state?
With Redux-Leaves, the actions
API means that you are extremely explicit in which state is being changed: you use a property path to the state you want to create an action at, just as you would if you were looking at your state tree and describing which bit of state that you wanted to effect.
(This isn't addressing quite the same point Yazan makes, which I think is asking, 'but why are we increasing crowd excitement?' - but, as I indicated in discussing that point, I think it's the responsibility of the developer to make the why of a command clear through a comment, if needed.)
Incredibly minimal boilerplate
Here's what we need to do to get our root reducer and action creators:
import reduxLeaves from 'redux-leaves'
const [reducer, actions] = reduxLeaves(initialState)
That's it. Two lines, one of which is an import. No faffing around with writing action constants, creators or reducer case
statements yourself.
Simple to scale
Suppose we want to introduce some state to keep track of team names.
All we need to do is to change our initial state...
import reduxLeaves from 'redux-leaves'
const initialState = {
crowdExcitement: 0,
scoreboard: {
home: 0,
away: 0
},
+ teams: {
+ home: 'Man Red',
+ away: 'Man Blue'
}
}
const [reducer, actions] = reduxLeaves(initialState)
const store = createStore(reducer)
... and then we can straight away start dispatching actions to update that state, without having to write any further reducers, action creators or action types:
store.dispatch(actions.teams.away.create.update('London Blue'))
store.getState().teams.away // => 'London Blue'
Takeaways
- You should watch Yazan's talk and look at his slides, both are really thoughtful
- Yazan makes a strong case against how Redux command actions are typically written
- I think that Redux-Leaves solves most of those problems, if not all
Endnotes
1 An Redux action can be considered a command if it expresses intent to do something. Examples given by Yazan are:
{ type: 'SEND_CONFIRMATION' }
{ type: 'START_BILLING' }
{ type: 'SEND_LETTER' }
{ type: 'INCREMENT_SCORE' }
{ type: 'INCREASE_CROWD_EXCITEMENT' }
Top comments (2)
I am really happy that this is becoming more of a discussion point. But with that, I think redux-leaves streamlines a problem and doesn't necessarily address it.
Few points to make here. I don't think it's fair to say the scoreboard and crowd should be captured in the same reducer. The main reason I say this is because there may be other reasons to why the crowd excitement may change (The kiss cam can go on, free popcorn, cheerleaders, etc) . If you put them in the same reducer you are falling victim to breaking the Cohesion Principle in software development.
Another point to make, by dispatching all the actions in the component (even if they are bundled), you would still be coupling your components to those reducers. If there is a point where you'd have another reducer that is interested in understanding when a goal is scored, you'd have to edit the component to include that action (edit the bundle). That itself is a sign of coupling.
Also, it is very beneficial not to know where your actions are going because the relationship between an action and reducer should be many to many:
the event of scoring a goal effects the scoreboard and the crowd simultaneously (1 action to many reducers)
the crowd gets excited when goal is scored, when popcorn is being served, when the kiss cam is on. ( many actions to 1 reducer).
Thanks for taking the time to read and respond - and I'm also really glad that we're having this discussion! Redux-Leaves came out of my experience of battling with a sprawling mess of files and reducers and how tedious it all felt. However, I was managing Redux in very much the command action way, and not the eventful action way, and so the library came together mostly as a means of streamlining problems there.
I'm now really interested in seeing whether the library can be made to work for those who would prefer eventful actions, so I really value your feedback on this. There are two questions I have there:
My first plan is to explore whether the existing API is sufficient (obviously), so I'm going to try to make the case that it is sufficient - not because I'm trying to 'win', but because I really want to battle-test the API. I hope that, if I am unsuccessful in defending it as it is, I'll learn what I need to do to make it work for your use cases - so thank you for your honesty and candour.
In my reading of your comment, you're articulating two points of contention with the Redux-Leaves I've given above: (1) by the Cohesion Principle,
scoreboard
andcrowdExcited
state shouldn't be in the same reducer; and (2) components should not be coupled to reducers.(I hope my reading is correct, as that's what I'm going to try to address.)
I'm going to respond to those in reverse order, my contentions being:
actions
in a way that mean components are no more meaningfully coupled to the root Redux-Leavesreducer
shape than would be true through an eventful action pattern that doesn't use Redux-Leaves; andscoreboardReducer
directly.Component should not be coupled to reducers
I think this can be solved through encapsulation:
Given the above setup:
now we just add one line to
actions.js
, e.g.which, in my opinion: (a) does not require any more changes to the
GoalComponent.js
file than under the eventful action pattern; and (b) is less boilerplate than adding an additionalcase
tocommentaryReducer
.Perhaps this encapsulation step is a way of implementing eventful actions through Redux-Leaves? I'd be interested in your thoughts on that.
scoreboard
andcrowdExcited
state should not be in the same reducerAs you'll know, with Redux, all your state is in the same root reducer - that might just be one created by repeated calls to
combineReducers
over some child reducers.As such, the way I'm interpreting your point is: even though
scoreboard
andcrowdExcited
will ultimately end up in the same reducer, there is still a net benefit to creating child reducers for the two.The extent to which I agree with that depends on whether Redux-Leaves is used or not.
Without Redux-Leaves
I think that is very plausible to say that there is a net benefit to creating those child reducers in a situation when the alternative is writing and manually maintaining a single reducer that holds both parts of state.
Benefits of child reducers
scoreboard
related changes are grouped together.Costs of child reducers
To elaborate on the second: I think it makes it harder to get that overall bird's-eye view of your state tree as you're writing your code, because it's not all in one place, but split up into little chunks. This might not be a big cost, because you might very infrequently want the bird's-eye view of state, but I think there are occasions when I do want it (e.g. when writing selector functions), and so I think it's a non-zero cost.
But, to reiterate, I think it is very plausible that, in spite of the costs, there is a net benefit to creating child reducers to manage
scoreboard
andcrowdExcited
state respectively in a situation when the alternative is writing and maintaining a single reducer.With Redux-Leaves
My claim is that Redux-Leaves eliminates the first two benefits of creating child reducers, as articulated above:
const [reducer, actions] = reduxLeaves(initialTreeState)
; andreducer
since the Redux-Leaves library has documentation and tests.I accept that there is a benefit that is then lost, in a sense - now, all
scoreboard
related changes are not grouped together.I contend that this is an acceptable trade-off considering that, with Redux-Leaves, you have:
initialTreeState
which responds really predictably to dispatchedactions
.Summary
So, in summary, I claim that:
actions
in a way that mean components are no more meaningfully coupled to the root Redux-Leavesreducer
shape than would be true through an eventful action pattern that doesn't use Redux-Leaves; andscoreboardReducer
directly.If you think that I am wrong, I'd love to know where, as this will all help me to improve the library!