I now have a new shiny blog. Read this article with the latest updates there https://blog.goncharov.page/reducer-organization-taking-a-step-further
If you're interested in working around too much boilerplate in Redux / NGRX you might want to check this article out.
If you're already familiar with selecting a reducer from a map technique consider jumping right to class-based reducers.
What are we gonna cover here?
We're going to overview evolution of reducers in my Redux/NGRX apps that took place over the last two years. Starting from vanilla switch-case
, going to selecting a reducer from an object by key, finally settling with class-based reducers. We're not going to talk only about how, but also about why.
Vanilla switch-case
So let's take a look at an everyday task of creating an entity on the server asynchronously. This time I suggest we describe how we could create a new jedi.
const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'
const reducerJediInitialState = {
loading: false,
// List of our jedi
data: [],
error: undefined,
}
const reducerJedi = (state = reducerJediInitialState, action) => {
switch (action.type) {
case actionTypeJediCreateInit:
return {
...state,
loading: true,
}
case actionTypeJediCreateSuccess:
return {
loading: false,
data: [...state.data, action.payload],
error: undefined,
}
case actionTypeJediCreateError:
return {
...state,
loading: false,
error: action.payload,
}
default:
return state
}
}
Let me be honest, I've never used this kind of reducers in production. My reasoning is threefold:
-
switch-case
introduces some points of tension, leaky pipes, which we might forget to patch up in time at some point. We could always forget to put inbreak
if do not do immediatereturn
, we could always forget to adddefault
, which we have to add to every reducer. -
switch-case
has some boilerplate code itself which doesn't add any context. -
switch-case
is O(n), kind of. It is not a solid argument by itself because Redux is not very performant anyway, but it makes my inner perfectionist mad.
The logical next step which Redux's official documentation suggests to take is to pick a reducer from an object by key.
Selecting a reducer from an object by key
The idea is simple. Each state transformation is a function from state and action and has a corresponding action type. Considering that each action type is a string we could create an object, where each key is an action type and each value is a function that transforms state (a reducer). Then we could pick a required reducer from that object by key, which is O(1), when we receive a new action.
const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'
const reducerJediInitialState = {
loading: false,
data: [],
error: undefined,
}
const reducerJediMap = {
[actionTypeJediCreateInit]: (state) => ({
...state,
loading: true,
}),
[actionTypeJediCreateSuccess]: (state, action) => ({
loading: false,
data: [...state.data, action.payload],
error: undefined,
}),
[actionTypeJediCreateError]: (state, action) => ({
...state,
loading: false,
error: action.payload,
}),
}
const reducerJedi = (state = reducerJediInitialState, action) => {
// Pick a reducer by action type
const reducer = reducerJediMap[action.type]
if (!reducer) {
// Return state unchanged if we did not find a suitable reducer
return state
}
// Run suitable reducer if found one
return reducer(state, action)
}
The cool thing here is that logic inside reducerJedi
stays the same for any reducer, which means we can re-use it. There's even a small library, called redux-create-reducer, which does exactly that. It makes the code looks like this:
import { createReducer } from 'redux-create-reducer'
const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'
const reducerJediInitialState = {
loading: false,
data: [],
error: undefined,
}
const reducerJedi = createReducer(reducerJediInitialState, {
[actionTypeJediCreateInit]: (state) => ({
...state,
loading: true,
}),
[actionTypeJediCreateSuccess]: (state, action) => ({
loading: false,
data: [...state.data, action.payload],
error: undefined,
}),
[actionTypeJediCreateError]: (state, action) => ({
...state,
loading: false,
error: action.payload,
}),
})
Nice and pretty, huh? Though this pretty still has a few caveats:
- In case of complex reducers we have to leave lots of comments describing what this reducer does and why.
- Huge reducer maps are hard to read.
- Each reducer has only one corresponding action type. What if I want to run the same reducer for several actions?
Class-based reducer became my shed of light in the kingdom of the night.
Class-based reducers
This time let me start with whys of this approach:
- Class' methods will be our reducers and methods have names, which is a useful meta-information, and we could abandon comments in 90% of cases.
- Class' methods could be decorated which is an easy-to-read declarative way to match actions and reducers.
- We still could use a map of actions under the hood to have O(1) complexity.
If that's sounds like a reasonable list of reasons for you, let's dig in!
First of all, I would like to define what we want to get as a result.
const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'
class ReducerJedi {
// Take a look at "Class field delcaratrions" proposal, which is now at Stage 3.
// https://github.com/tc39/proposal-class-fields
initialState = {
loading: false,
data: [],
error: undefined,
}
@Action(actionTypeJediCreateInit)
startLoading(state) {
return {
...state,
loading: true,
}
}
@Action(actionTypeJediCreateSuccess)
addNewJedi(state, action) {
return {
loading: false,
data: [...state.data, action.payload],
error: undefined,
}
}
@Action(actionTypeJediCreateError)
error(state, action) {
return {
...state,
loading: false,
error: action.payload,
}
}
}
Now as we see where we want to get we could do it step by step.
Step 1. @Action decorator.
What we want to do here is to accept any number of action types and store them as meta-information for a class' method to use later. To do that we could utilize reflect-metadata polyfill, which brings meta-data functionality to Reflect object. After that this decorator would just attach its arguments (action types) to a method as meta-data.
const METADATA_KEY_ACTION = 'reducer-class-action-metadata'
export const Action = (...actionTypes) => (target, propertyKey, descriptor) => {
Reflect.defineMetadata(METADATA_KEY_ACTION, actionTypes, target, propertyKey)
}
Step 2. Creating a reducer function out of a reducer class
As we know each reducer is a pure function that accepts a state and an action and returns a new state. Well, class is a function as well, but ES6 classes can not be invoked without new
and we have to make an actual reducer out of a class with a few methods anyway. So we need to somehow transform it.
We need a function that would take our class, walk though each method, collect metadata with action types, build an reducer map and create a final reducer out of that reducer map.
Here's how we could examine each method of a class.
const getReducerClassMethodsWthActionTypes = (instance) => {
// Get method names from class' prototype
const proto = Object.getPrototypeOf(instance)
const methodNames = Object.getOwnPropertyNames(proto).filter(
(name) => name !== 'constructor',
)
// We want to get back a collection with action types and corresponding reducers
const res = []
methodNames.forEach((methodName) => {
const actionTypes = Reflect.getMetadata(
METADATA_KEY_ACTION,
instance,
methodName,
)
// We want to bind each method to class' instance not to lose `this` context
const method = instance[methodName].bind(instance)
// We might have many action types associated with a reducer
actionTypes.forEach((actionType) =>
res.push({
actionType,
method,
}),
)
})
return res
}
Now we want to process the received collection into a reducer map.
const getReducerMap = (methodsWithActionTypes) =>
methodsWithActionTypes.reduce((reducerMap, { method, actionType }) => {
reducerMap[actionType] = method
return reducerMap
}, {})
So the final function could look something like this.
import { createReducer } from 'redux-create-reducer'
const createClassReducer = (ReducerClass) => {
const reducerClass = new ReducerClass()
const methodsWithActionTypes = getReducerClassMethodsWthActionTypes(
reducerClass,
)
const reducerMap = getReducerMap(methodsWithActionTypes)
const initialState = reducerClass.initialState
const reducer = createReducer(initialState, reducerMap)
return reducer
}
And we could apply it to our ReducerJedi
class like this.
const reducerJedi = createClassReducer(ReducerJedi)
Step 3. Merging it all together.
// We move that generic code to a dedicated module
import { Action, createClassReducer } from 'utils/reducer-class'
const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'
class ReducerJedi {
// Take a look at "Class field delcaratrions" proposal, which is now at Stage 3.
// https://github.com/tc39/proposal-class-fields
initialState = {
loading: false,
data: [],
error: undefined,
}
@Action(actionTypeJediCreateInit)
startLoading(state) {
return {
...state,
loading: true,
}
}
@Action(actionTypeJediCreateSuccess)
addNewJedi(state, action) {
return {
loading: false,
data: [...state.data, action.payload],
error: undefined,
}
}
@Action(actionTypeJediCreateError)
error(state, action) {
return {
...state,
loading: false,
error: action.payload,
}
}
}
export const reducerJedi = createClassReducer(ReducerJedi)
Next steps
Here's what we missed:
- What if the same action corresponds to several methods? Current logic doesn't handle this.
- Could we add immer?
- What if I use class-based actions? How could I pass an action creator, not an action type?
All of it with additional code samples and examples is covered with reducer-class.
I must say using classes for reducers is not an original thought. @amcdnl came up with awesome ngrx-actions quite a while ago, but it seems like he's now focused on NGXS, not to mention I wanted more strict typing and decoupling from Angular-specific logic. Here's a list of key differences between reducer-class and ngrx-actions.
If you like the idea of using classes for your reducers, you might like to do the same for your action creators. Take a look at flux-action-class.
Hopefully, you found something useful for your project. Feel free to communicate your feedback to me! I most certainly appreciate any criticism and questions.
Top comments (0)