Should I read this post?
I think that you are more likely to find value in reading this post if you:
- Are interested in trying to cut down your Redux boilerplate; or
- Enjoy it when conventional coding patterns are challenged; or
- 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.
- Firstly, I'm going to introduce a fictional project scenario;
- Secondly, I'm going to look at what Redux boilerplate might typically be used;
- 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.
- Vanilla
- Immer
-
ReduxToolkit
-
createAction
withcreateReducer
createSlice
-
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)
Personally I like easy-peasy.now.sh/ better. Seems less complicated.
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:
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:
state.second.path.to.counter
, you useactions.second.path.to.counter
actions.second.path.to.counter.create
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?