Redux Hero Part 2: Actions and Their Consequences (a Fun Introduction to redux-actions)

last updated: Jul 23rd, 2016

We're learning about Redux.js under the guise of making a mock RPG-style game.

Recall from Part 1: A Hero is Born (A Fun Introduction to Redux.js) that we started with the most basic version of the game to introduce some core concepts. Our hero has the ability to level up. But that's not a very exciting game.

At the end we added some complexity such as moving, gaining experience, taking damage and healing. But the code I gave you has some problems. Did you find any of them?

Let's take a look at the first issue with the code.

Boilerplate

Developers don't like to repeat themselves. Fewer keystrokes is better. Too much boilerplate code makes it difficult to see the real code.

Action Creators

Did you notice that the action creators seemed to be very verbose for not adding much value? These are simple functions that return a plain old Javascript object with a few structural constraints (which are really just guidelines anyways).

////  Action Creators//const gainXp = (xp) => ({  type: Actions.GAIN_XP,  payload: xp});const levelUp = () => ({  type: Actions.LEVEL_UP});const move = (x, y) => ({  type: Actions.MOVE,  payload: { x, y }});const drinkPotion = () => ({  type: Actions.DRINK_POTION});const takeDamage = (amount) => ({  type: Actions.TAKE_DAMAGE,  payload: amount});

There is a handy little library called redux-actions that simplifies this code for you. Compare the above snippet with this:

const gainXp = createAction(Actions.GAIN_XP);const levelUp = createAction(Actions.LEVEL_UP);const move = createAction(Actions.MOVE, (x, y) => ({ x, y }));const drinkPotion = createAction(Actions.DRINK_POTION);const takeDamage = createAction(Actions.TAKE_DAMAGE);

There are a few things to note here. First, notice that gainXp and takeDamage appear to have lost their parameters. Don't worry. We haven't lost any functionality.

What really happens is that createAction() returns another function and that function takes the parameters -- you just don't see it in your code.

And what's really really happening is that createAction actually takes a second parameter that maps from input parameters to an action payload. But for convenience, it defaults to an identity transformation. In other words, whatever parameter you pass becomes the action payload.

So calling gainXp(100) will give us this Javascript object, which is identical to the output from our previous gainXp action creator.

{  "type": "GAIN_XP",  "payload": 100}

Second, notice we specify the transform parameter for the move action creator because the transformation is more complex than the default identity transform.

Calling move(1, 0) gives us this action object:

{  "type": "MOVE",  "payload": {    x: 1,    y: 0  }}

Experiment with this if you like. You can add createAction to your JSFiddle code by including https://npmcdn.com/[email protected]/lib/createAction.js as an external resource. But note that in a real project you'd want to include redux-actions in your vendor bundle rather than pulling it from NPMCDN.

Reducers

Our reducers have a little bit of boilerplate too -- albeit not as much as if we were using ES5. In ES6 we can declare default parameter values, which saves us a few lines.

Recall from Part 1: A Hero is Born (A Fun Introduction to Redux.js) our level reducer looks like this:

const levelReducer = (state = 1, action) => {  switch (action.type) {    case Actions.LEVEL_UP:      return state + 1;  }  return state;};

With redux-actions, we can use a helper function called handleActions that would make our reducer look like this:

const levelReducer = handleActions({  [Actions.LEVEL_UP]: (state, action) => {      return state + 1;  },}, 1);

This example isn't nearly as compelling as the action creator example. You could simplify a little bit by shortening the lambda function to state => state + 1... But we're really splitting hairs here. There is, however, a subtle benefit in that the default case is included for us automatically (P.S. it's super important that you return the state from your reducers when the action went unhandled).

Time to Refactor

We didn't make much progress using redux-actions to simplify our reducers. There still seems to be too many lines of code and the reducers in their current state are mostly over-engineered assignment statements.

But that's partly due to the contrived example. Let's add to our application state model again and reorganize the reducers at the same time. But I'm not going to use handleActions because, unfortunately, it doesn't play well with our JSFiddle environment.

Here's the current state of the code after I grouped the current state under a new hero heading, added a stub for monster details, and reorganized the reducers. The significant changes are highlighted.

const { createStore, combineReducers } = Redux;const initialState = {  hero: {    xp: 0,    level: 1,    position: {      x: 0,      y: 0,    },    stats: {      health: 50,      maxHealth: 50,    },    inventory: {      potions: 1,    }  },  monster: {},};////  Actions//const Actions = {  GAIN_XP: 'GAIN_XP',  LEVEL_UP: 'LEVEL_UP',  MOVE: 'MOVE',  DRINK_POTION: 'DRINK_POTION',  TAKE_DAMAGE: 'TAKE_DAMAGE',};////  Action Creators//const gainXp = createAction(Actions.GAIN_XP);const levelUp = createAction(Actions.LEVEL_UP);const move = createAction(Actions.MOVE, (x, y) => ({ x, y }));const drinkPotion = createAction(Actions.DRINK_POTION);const takeDamage = createAction(Actions.TAKE_DAMAGE);////  Reducers//const heroReducer = (state = initialState.hero, action) => {  const { stats, inventory } = state;    switch (action.type) {    case Actions.GAIN_XP:      const xp = state.xp + action.payload;      return { ...state, xp };    case Actions.LEVEL_UP:      const level = state.level + 1;      return { ...state, level };    case Actions.MOVE:      let { position: { x, y } } = state;      x += action.payload.x;      y += action.payload.y;      return { ...state, position: { x, y } };    case Actions.DRINK_POTION:      return {        ...state,        stats: statsReducer(stats, action),        inventory: inventoryReducer(inventory, action)      };    case Actions.TAKE_DAMAGE:      return {        ...state,        stats: statsReducer(stats, action)      };  }  return state;};const statsReducer = (state = initialState.hero.stats, action) => {  let { health, maxHealth } = state;  switch (action.type) {    case Actions.DRINK_POTION:      health = Math.min(health + 20, maxHealth);      return { ...state, health, maxHealth };    case Actions.TAKE_DAMAGE:      health = Math.max(0, health - action.payload);      return { ...state, health };  }  return state;};const inventoryReducer = (state = initialState.hero.inventory, action) => {  let { potions } = state;  switch (action.type) {    case Actions.DRINK_POTION:      potions = Math.max(0, potions - 1);      return { ...state, potions };  }  return state;};const monsterReducer = (state = initialState.monster, action) => {  // Coming soon...  return state;};////  Bootstrapping//const reducer = combineReducers({  hero: heroReducer,  monster: monsterReducer,});const store = createStore(reducer);store.subscribe(() => {  console.log(JSON.stringify(store.getState()));});////  Run!//store.dispatch(move(1, 0));store.dispatch(move(0, 1));store.dispatch(takeDamage(13));store.dispatch(drinkPotion());store.dispatch(gainXp(100));store.dispatch(levelUp());

Next Steps

In this installment we looked at how to increase the signal-to-noise ratio in our code by using redux-actions to reduce boilerplate redux code.

Excessive boilerplate code is really only a superficial problem though. There is a deeper problem lurking on the horizon for our hero.

The problem is that leveling up is dependent upon gaining experience. But how can we make the level increase in response to gaining experience?

Find out in Part 3: Choose Wisely (a Fun Introduction to reselect.js)

Other installments:

Level up Your React + Redux + TypeScript

with articles, tutorials, sample code, and Q&A.