July 10, 2018

Automating Redux with Rematch & Default Reducers

I just released rematch-default-reducers to npm! If you're tired of Redux boilerplate, you should checkout rematch and give rematch-default-reducers a try.

Automating Redux with Rematch & Default Reducers

Laziness is one of the three virtues of a great programmer, or so I've heard. I'd like to be a great programmer, so I decided to nurture my lazy side and created an open source Node package called rematch-default-reducers(currently v1.1.0) that will enable me to automate some repetetive code I'm tired of writing. Maybe you're tired of writing it too.

Have you heard of redux? It's known for popularizing an approach to front-end state management that makes its really, really easy for you to reason about and understand the state of your application, regardless of how big and complex your UI features are. It's pretty cool...until it's not.

By the time you write the code to manage that third or fourth piece of state following "best practices", you'll notice your code begin to emit a familiar odor and wonder what can be done about it. What I mean is that redux requires a rather inordinate amount of repetetive boilerplate when done in the canonical fashion. Just ask anyone who has built or supported a sizable redux app in production. It's tedious code to write, which is much better than confusing code, but still. Not fun. You've got constants, action creators, reducers, and (probably) thunks/sagas to write for every bit of state. It gets tiresome.

For example, pretend your app only has this bit of state to manage:

// src/models/potatoes/reducer.js
const INITIAL_STATE = {
    potatoes: [],
}

Now, let's say we want a way to add potato objects to our state. You're going to need a constant:

// src/models/potatoes/constants.js
export const ADD_POTATOES = "ADD_POTATOES"

And an "action" creator:

// src/models/potatoes/actions.js
import {ADD_POTATOES} from './constants'

export const addPotatoes = (potatoes) => ({
    type: ADD_POTATOES,
    payload: potatoes
})

And some mechanism for synchronizing state updates across back-end and front-end, like a "thunk" (enabled by redux-thunk):

// src/models/potatoes/thunks.js
import {addPotatoes} from './actions'

export const addPotatoesThunk = (potatoes) => (dispatch, getState) => {
    return api.post({ 
        endpoint: `/user/${getState().user.id}/potatoes`,
        payload: potatoes,
    })
    .then(res => dispatch(addPotatoes(res.data))
    .catch(console.error)
}

And a "reducer" that can match incoming actions to state-updating logic:

// src/models/potatoes/reducer.js
import {ADD_POTATOES} from './constants'

const actionHandlers = {
    [ADD_POTATOES]: (state, payload) => ([
        ...state,
        ...payload
    ])
}

export default function potatoesReducer(currentState, action) {
    return actionHandlers[action.type]
        ? actionHandlers[action.type](currentState, action.payload)
        : state
}

After this ceremony, you are then qualified to add potatoes to your state. Now, guess what you have to do in order to remove potatoes:
.
.
.
Yep. The same thing. All over again. With just a couple words changed. Want to add and remove carrots in your redux state? Might as well copy/paste the potatoes code and just find/replace potatoes with carrots.

This doesn't even include the initial boilerplate of importing and initializing all the redux infrastructure and creating the "store" or, even worse, having to import action creators all over your view component files.

The idea behind redux was to make applications easier to understand and maintain by "decoupling", or separating, the purely visual aspects of the user interface from all the data fetching and business logic required to manage the state your application needs in order to present something useful to the user. It does that really well...if you use it consistently. However, it doesn't take long for the tedium of managing state in redux to drive developers back to their old ways of polluting view layer components with API calls and (often duplicated) state management logic.

The idea is so good, but there has to be a better way.

Introducing @rematch/core

This is why rematch was made. rematch is simply a "wrapper" around redux. It uses redux internally and exposes a small, pluggable API for defining your app's state and privileged methods that can update state in a predictable and functionally pure manner, much like the reducer functions you usually write for redux.

rematch abstracts away much of the redux boilerplate demonstrated above while retaining all of the benefits. You can even define effects, which are just regular or async functions that are dedicated to performing side effects, entirely removing the need to pull in something like redux-thunk or redux-saga.

With rematch, the redux example above is reduced to this:

// src/models/potatoes
const potatoes = {
    state: [],
    reducers: {
        add: (state, payload) => [...state, ...payload]
    },
    effects: (dispatch) => ({
        postPotatos: async (payload, rootState) => {
            const {data} = await api.post({ 
                endpoint: `/user/${rootState.user.id}/potatoes`,
                payload: potatoes,
            })
            
            return dispatch.potatoes.add(data)
        },
    })
}

Nice, right? All your constants, action creators, reducers, and thunks/sagas get condensed into state, reducers, and effects. To remove potatoes, we just add a remove reducer and a deletePotatoes effect.

Then all you have to do to initialize the store is this:

// src/models/index.js
import {init} from '@rematch/core`
import {potatoes} from './models/potatoes'

export const store = init({
    models: {
        potatoes,
    }
})

And like that, you have a redux store setup and you can use your preferred redux bindings to connect your view to the store.

Another nice thing about rematch is that you don't need to import action creators all over your files. All actions are made available on store.dispatch, namespaced under their model name. If you're using react-redux (what I'm familiar with), then you don't even need to bother with mapDispatchToProps any more. Leave it null and let connect from react-redux just inject dispatch into your components and call whatever actions/reducers you like.

rematch is a big win when it comes to reducing the boilerplate associated with building scalable and idiomatic front-end state management systems. However, I've already begun to find some residual boilerplate in rematch and this is where my laziness kicked in.

Introducing rematch-default-reducers

There are some common reducers you write for just about every model you define in rematch –things like set and reset. Or if you're maintaining lists of things in your state, then you probably write plenty of actions like append and remove.

If you usually set your models' state to a {} object with a bunch of attributes, then you may have a setAttribute for every key in that model, and append and remove for any Arrays. And the logic is always the same:

const user = {
    state: {
        name: '',
        phoneNumber: 0,
        friends: [],
    },
    reducers: {
        setName: (state, payload) => {...state, name: payload},
        setPhoneNumber: (state, payload) => {...state, phoneNumber: payload},
        setFriends: (state, payload) => {...state, friends: payload},
        appendFriend: (state, payload) => {...state, friends: [...state.friends, payload],
        removeFriend: (state, payload) => {...state, friends: state.friends.filter(fr => fr.id === payload.id)}
    },
}

These reducers are not interesting. They're idiomatic, conventional, redux boilerplate. They follow the same basic pattern for every model you write. Why not automate these repetetive parts too?

That's what I've tried to do with rematch-default-reducers. For every model you define, rematch-default-reducers will look at each model's initial state and generate "default reducers" with conventional names based on the model name and (if applicable) the state's property name. And, in case you were wondering, user-defined reducers are never overwritten by default reducers. If you've already defined a reducer with the same name as a default reducer, then your version wins out and the default one evaporates into the ether.

Setting Up rematch-default-reducers

Getting free reducers couldn't be easier. This is literally all you need to do:

import {init} from '@rematch/core'
import {withDefaultReducers} from 'rematch-default-reducers'
import {tonsOfModelsWithTonsOfState} from '../models'
 
export default init({
  models: withDefaultReducers(tonsOfModelsWithTonsOfState),
})

With that simple call to withDefaultReducers, you get loads of code written for you.

What Reducers Do You Get?

Say we initialize rematch like this:

// src/models/index
import {init} from '@rematch/core'
import {withDefaultReducers} from 'rematch-default-reducers'
 
export default init({
  models: withDefaultReducers({
      user: { hobbies: [] },
  }),
})

With just that bit of code, we can do all this:

import {dispatch} from 'src/models'

// store-wide actions
dispatch.rootState.reset()
// model-wide actions
dispatch.user.set({hobbies: ['being awesome']})
dispatch.user.reset()
// attribute-specific actions
// generic to all attributes:
dispatch.user.setHobbies(['baseball', 'football'])
dispatch.user.resetHobbies()
// specific to array attributes
dispatch.user.concatHobbies(['paper machet', 'cooking'])
dispatch.user.concatHobbiesTo(['nascar', 'video games'])
dispatch.user.filterHobbies({where: (hob, idx) => hob + idx === 'yus'})
dispatch.user.insertHobby({where: (hob, idx) => idx === 4, payload})
dispatch.user.insertHobbies({where: (hob, idx) => idx === 4, payload})
dispatch.user.mapHobbies((hob) => hob.toUpperCase())
dispatch.user.popHobbies(1)
dispatch.user.pushHobby('bird watching')
dispatch.user.removeHobby('baseball')
dispatch.user.removeHobby({where: (thing, idx) => thing === idx})
dispatch.user.removeHobbies({where: (thing, idx) => thing === idx})
dispatch.user.replaceHobby({where: (thing, idx) => thing === idx}, payload)
dispatch.user.shiftHobbies(1)
dispatch.user.unshiftHobby('kitties')

Make sure you take note of the fact that some array reducers are singular and some are plural. This is simply to make the actions more semantically accurate. For instance, doing removeHobby({where: ...}) removes the first hobby that passes the test, whereas removeHobbies({where: ...}) removes all hobbies that pass the test.

Other Features

In addition to all the free reducers, there are a couple other features worth mentioning. By default, rematch-default-reducers enforces a "no nil" policy on initial state and performs type checking on all set-related actions to ensure that you aren't setting values that change the interface of your application's state.

The no nil policy is there for a couple of reasons.

  1. If you initialize parts of your state as null or undefined, then you end up having to do some pretty ridiculous null checks all over your application, and that's a real pain. Or do you really enjoy reading thing && thing.stuff && thing.stuff.whatever every day? Be nice to yourself and initialize your state with the exact interface/types your application expects.
  2. If you initialize a model's state or some property of state as null, withDefaultReducers won't generate a default reducer for that part of your redux store. You don't wan't to lose money on this, do you?

But of course, you can opt out of this policy by passing an option to withDefaultReducers.

The type checking on actions is to help you make sure whatever you're setting on state is compliant with the interface you initialized your app with. Similar to the reason for "no nil", why would you want to waste time and keystrokes checking that some piece of your state is the data type you expect? However, if you insist on being willy-nilly with the interface of your app's state, you can easily disable type checking for all models by passing an option to withDefaultReducers or by passing a meta argument to the action you dispatch.

My hope is that these defaults will help promote better conventions among developers when designing data models for front-end state management. But rematch-default-reducers has not been rigorously tested by the community (although it has plenty of tests!), so depending on feedback, these defaults could disappear in later versions.

Wrapping Up

If you're sick of redux but haven't tried out @rematch/core, please, go check it out. The core concepts of redux are absolutely vital to scalable, maintainable front end applications. It would be a shame for people to get turned off to its philosophy just because of a poor API. I believe rematch improves upon redux greatly and is worth looking into and supporting.

If you're using rematch and find writing reducers boring and tedious, please give rematch-default-reducers a try and let me know how it goes for you and how it could be better. This is my first open-source library contribution, so I'm sure it's not perfect, but I love making things better. If you want to interact, then @ me on Twitter or hit me up in the comments below. Or open an issue on GitHub!