DEV Community

Richard Ng
Richard Ng

Posted on • Originally published at richard.ng

Why you can stop writing all that Redux boilerplate

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

  1. Are interested in trying to cut down your Redux boilerplate; or
  2. Enjoy it when conventional coding patterns are challenged; or
  3. Like investigating shiny new libraries for state management!

I have a provocative opinion. I contend that a lot of your Redux boilerplate can be entirely eliminated.

Not all of it - I've not completely lost my mind. Just a great deal of it.

Here's how I'm going to make my case.

  1. Firstly, I'm going to introduce a fictional project scenario;
  2. Secondly, I'm going to look at what Redux boilerplate might typically be used;
  3. Thirdly, I'm going to demonstrate how this boilerplate can be eliminated.

Project Scenario

Situation: a web development agency and a client

Let's suppose that we have a web development agency, Devs2U, working on a project with a new client, MegaCorp.

It's an important project, both for MegaCorp and Devs2U - currently, neither are profitable, but if this project works out then it could turn things around for both of them.

Given the importance of the project, Devs2U's CTO, Maisy, has staffed herself on the project and is currently planning out who else to staff, and what exactly they'll be doing.

// initialState.js

export const initialState = {
  project: {
    agency: {
      name: 'Devs2U',
      revenue: 50000,
      costs: 80000
    },
    client: {
      name: 'MegaCorp',
      revenue: 1500000,
      costs: 7400000
    },
    budgeted: {
      days: 2,
      salaries: 10000
    },
    stagesCompleted: {
      discover: false,
      design: false,
      develop: false,
      test: false
    },
    technologies: {
      languages: ['javascript'],
      libraries: ['react'] // look, ma, no Redux! (... yet)
    }
  },
  persons: [
    {
      name: 'Maisy Ware',
      title: 'CTO',
      employedBy: 'agency',
      status: 'determined'
    },
    {
      name: 'Maddie Swanson',
      title: 'CTO',
      employedBy: 'client',
      status: 'anxious'
    },
    {
      name: 'Kian Bernard',
      title: 'Junior Developer',
      employedBy: 'agency',
      status: 'eager'
    }
  ]
}

Complication: The developer team don't love Redux state management

As she's planning and scoping out the project, Maisy realises that, despite her initial plan to not use Redux, it's going to make the state management significantly easier if she does.

However, although Maisy loves Redux, some of her team don't - they've complained to her that it can be tedious to set up, difficult to learn and painful to maintain.

As such, Maisy decides to take responsibility for architecting the project's Redux code in a way that is quick to setup, easy to learn and simple to scale.

Question: How can we set up Redux with minimal boilerplate?

Let's model this situation using a Redux store.

// store.js
import { createStore } from 'redux'
import { initialState } from './path/to/initialState'

const store = createStore(/* our root reducer */)
store.dispatch(/* some 'LIBRARY_ADDED'-ish action */)
store.getState().project.technologies.libraries // desired: ['react', 'redux']

So, how can we get our root reducer and this action to add Redux to the list of libraries used?

Typical approaches

Here, I'll show three approaches that might be used, and discuss and compare them.

It's probably worth noting that in all these cases, it would be more common to break up the root reducer into child reducers and then make a call to Redux's combineReducers - but this is more set up work to do, and we're interested here in handling our 'LIBRARY_ADDED' case as quickly and straightforwardly as possible, so we'll exclude that from our examples.

Vanilla

A 'vanilla' approach might look like this:

// actions.js
export const addLibrary = (library) => ({
  type: 'LIBRARY_ADDED',
  payload: library
})

// reducer.js
export const rootReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'LIBRARY_ADDED':
      return {
        ...state,
        project: {
          ...state.project,
          technologies: {
          ...state.project.technologies,
          libraries: [...state.project.technologies.libraries, action.payload]
        }
        }
      }

    default: return state
  }
}

// store.js
const store = createStore(rootReducer)
store.dispatch(addLibrary('redux'))
store.getState().project.technologies.libraries // => ['react', 'redux']

Immer

immer is a cool library that lets you write immutable updates in a way that feels mutable:

// actions.js
export const addLibrary = (library) => ({
  type: 'LIBRARY_ADDED',
  payload: library
})

// reducer.js
import produce from 'immer'

export const rootReducer = (state = initialState, action) => (
  produce(baseState, draftState => {
    case 'LIBRARY_ADDED':
      // not actually mutating state below, but modifying a draft
      // which immer uses to return the new state
      draftState.project.technologies.libraries.push(action.payload)
  })
)

// store.js
const store = createStore(rootReducer)
store.dispatch(addLibrary('redux'))
store.getState().project.technologies.libraries // => ['react', 'redux']

Redux Toolkit

Redux Toolkit is the new and officially recomended way to write Redux, a library written by the Redux maintainers.

Here are two examples of how we might use the library to handle this specific case of adding a library.

a) createAction with createReducer

// actions.js
import { createAction } from '@reduxjs/toolkit'

export const addLibrary = createAction('LIBRARY_ADDED')

// reducer.js
import { createReducer } from '@reduxjs/toolkit'

export const rootReducer = createReducer(initialState, {
  [addLibrary]: (state, action) => {
    // action.payload will be the argument passed to addLibrary
    // RTK uses immer under-the-hood for the same mutative 'feel'
    state.project.technologies.libraries.push(action.payload)
  }
})

// store.js
const store = createStore(rootReducer)
store.dispatch(addLibrary('redux'))
store.getState().project.technologies.libraries // => ['react', 'redux']

b) createSlice

// reducer.js
import { createSlice } from '@reduxjs/toolkit'

export const root = createSlice({
  name: 'root',
  reducers: {
    addLibrary:(state, action) => {
      state.project.technologies.libraries.push(action.payload)
    }
  },
  initialState
})

// store.js
const store = createStore(root.reducer)
store.dispatch(root.actions.addLibrary('redux'))
store.getState().project.technologies.libraries // => ['react', 'redux']

Discussion

Strong direction of travel

I think there's clearly a good direction of travel throughout these examples. In particular, I know that Mark Erikson (maintainer of Redux) has put a great deal of work into Redux Toolkit, and I think that shows: createSlice is, imo, a big improvement on having to manually write your action creators and reducer logic separately.

All are painful to scale...

I believe that there's a core problem not being addressed, though - they're all going to be painful to scale.

In the different approaches, handling a single case / action type is being optimised - but as your application grows, you'll still need to handle a whole bunch of different cases.

This either means that your root reducer grows into a tremendously large beast, or (more likely) you split it up into reducers handling different slices of state, which leads to a great proliferation of files that you need to maintain.

One of these is certainly the lesser of two evils, but both are additional developer work for you to do.

Redux-Leaves: write once, reduce anywhere

This is why I wrote Redux-Leaves: to make Redux quicker to setup and simpler to scale.

Boilerplate? What boilerplate?

// store.js

import reduxLeaves from 'redux-leaves'

const [reducer, actions] = reduxLeaves(initialState)
const store = createStore(reducer)

store.dispatch(actions.project.technologies.libraries.create.push('redux'))
store.getState().project.technologies.libraries // => ['react', 'redux']

Here's the key difference: unlike the typical approaches, with Redux-Leaves you're not having to manually set up specific cases for trivial things like pushing to an array. Redux-Leaves gives you a bunch of sensible default action creators out-of-the-box, that can be used at an arbitrary leaf of your state tree.

Simple usage: describe the change you want to see

If you can describe the state change you want to see, you can dispatch the correct action.

You can play around with these simple examples on RunKit.

Pushing 'redux' to the libraries array

1. Where do we want state to change?

storeState.project.technologies.libraries

2. What change do we want to see?

We want to push the string 'redux' into the array

3. What action should I create for dispatching?

actions.project.technologies.libraries.create.push('redux'):

  • actions.projects.technologies.libraries accesses the relevant path
  • .create opens up action creators at that particular path
  • .push('redux') means we create a 'push' action for the payload 'redux'

Budgeting more days and salaries

// At storeState.project.budgeted.days, I want to create an increment action
store.dispatch(actions.project.budgeted.days.create.increment())
store.getState().project.budgeted.days // => 3

// Similar for storeState.project.budgeted.salaries, but I want to increment by 5000
store.dispatch(actions.project.budgeted.salaries.create.increment(5000))
store.getState().project.budgeted.salaries // => 15000

Updating inside an array

// At storeState.persons, I want to update the status property of the 1st element to excited
store.dispatch(actions.persons[1].status.create.update('excited'))
store.getState().persons[1]
/*
  {
    name: 'Maddie Swanson',
    title: 'CTO',
    employedBy: 'client',
    status: 'excited'
  }
*/

Do a bunch of things together

import { bundle } from reduxLeaves

store.dispatch(bundle([
  actions.project.client.name.create.concat(' (definitely not evil)'),
  actions.project.stagesCompleted.discover.create.toggle(),
  actions.persons[0].create.set('lovesRedux', 'you bet!')
]))

store.getState().project.client.name // => 'MegaCorp (definitely not evil)'
store.getState().project.stagesCompleted.discover // => true
store.getState().persons[0].lovesRedux // => 'you bet!'

Advanced usage: write once, reduce anywhere

Sometimes you'll have some logic that is more bespoke.

With Redux-Leaves, you can write this custom logic once, and then use it at any arbitrary leaf of state.

You can play around with this advanced usage on RunKit.

import reduxLeaves from 'redux-leaves'

// break-even at arbitrary leaf state
const breakEven = leafState => {
  return {
    ...leafState,
    revenue: leafState.costs // set revenue property equal to the costs property
  }
}

// set all properties at arbitrary leaf state
//   payload received will be the value to set
const setAll = (leafState, action) => {
  const leafKeys = Object.keys(leafState)
  const newEntries = leafKeys.map(key => [key, action.payload])
  return Object.keys(newEntries)
}

// set some property for all elements of an array
const setEach = {
  reducer: (leafState, { payload: { prop, val } }) => {
    return leafState.map(element => ({
      ...element,
      [prop]: val
    }))
  },
  argsToPayload: (prop, val) => ({ prop, val })
}

const customReducers = { breakEven, setAll, setEach }
const [reducer, actions] = reduxLeaves(initialState, customReducers)


const store = createStore(reducer)

// make both agency and client breakeven
store.dispatch(actions.project.agency.create.breakEven())
store.dispatch(actions.project.client.create.breakEven())

// mark all stages complete
store.dispatch(actions.project.stagesCompleted.create.setAll(true))

// give each person a happy status
store.dispatch(actions.persons.create.setEach('status', 'happy'))

What next?

Summary

In this post, I argued that a lot of your Redux boilerplate can be entirely eliminated by using Redux-Leaves.

The typical approaches streamline handling specific reducer cases, action types and action creators, but there's still a scaling problem. Choose between:

  • very large reducer files; or
  • very many reducer files.

With Redux-Leaves, you can avoid choosing either: it's two lines of setup, one of which is an import.

Discussion points

Some advocate an eventful model of Redux actions. If you have opinions on that, I'd love to hear from you!

(In a previous post and discussion thread, I've outlined how I think this might: (a) not be necessary, since Redux-Leaves solves typical command action problems: and (b) how Redux-Leaves might be able to accommodate eventful action modelling. Please leave a comment!)

Read The Docs

Please read the docs and let me know any feedback you have about the library or its documentation - I'm on Twitter, or you can file an issue on GitHub!

Top comments (2)

Collapse
 
cadams profile image
Chad Adams • Edited

Personally I like easy-peasy.now.sh/ better. Seems less complicated.

Collapse
 
richardcrng profile image
Richard Ng

Thanks for reading and responding - I'd not come across Easy Peasy, it's really interesting!

Simplifying Redux-Leaves

It's useful feedback to hear that you find Redux-Leaves complicated, as it's not intentionally so. This might be to do with how I presented it - the advanced usage is meant to be that, and simple usage covers the majority of cases.

In terms of the simple usage:

const initialState = {
  first: {
    arbitrarily: {
      nested: {
        counter: 10
      }
    }
  },
  second: {
    path: {
      to: {
        counter: 5
      }
    }
  }
}

// redux-leaves setup
const [reducer, actions] = reduxLeaves(initialState)

// vanilla redux store setup
const store = createStore(initialState)

// use redux-leaves actions
store.dispatch(actions.first.arbitrarily.nested.counter.create.increment())
store.getState().first.arbitrarily.nested.counter // => 11

store.dispatch(actions.second.path.to.counter.create.increment(100))
store.getState().second.path.to.counter // => 105

Is it the actions path that is the most complicated for you to understand?

It's meant to mimic how you'd describe the change happening:

  • at state.second.path.to.counter, you use actions.second.path.to.counter
  • then create an action, so actions.second.path.to.counter.create
  • and create an increment specifically, actions.second.path.to.counter.create.increment()

Does that make it clearer or does it still seem complicated?

Easy Peasy

It's got a similar API to Redux Toolkit, by the looks of it - which is really interesting and clearly works for a lot of people.

At a glance, it's got the same frustration I found with other approaches that I outlined - it's still premised on having to add specific cases to make updates to state, even for simple and repetitive things (like pushing to an array or incrementing a counter) - which you don't have to do with Redux-Leaves, since you: (a) get a bunch of defaults out of the box which you use at an arbitrary leaf of your state tree; and (b) if you want to use some custom logic, you can write it once and then use it anywhere in your state tree.

(With most other approaches, including Easy Peasy, you'd have to teach every child reducer [or 'model'] to do this.)

What do you think about that?