Richard Ng
Redux command actions that scale without boilerplate
29/11/20199 Min Read — In Redux
Should I read this post?

I think that you are more likely to find value in reading this post if you are:

  1. Trying to cut down on your Redux boilerplate; or
  2. Interested in improving your Redux architecture or file structure; or
  3. 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:

  1. Writing Redux actions as commands1 is an anti-pattern; and
  2. 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:

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

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

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.

Example
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



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' }

MAIN

Comments, questions or suggestions? Discuss on DEV.to