May 4th, 2020
If you are a JavaScript developer, especially a React developer, you have probably heard of something called the "reducer pattern". While the idea isn't incredibly new it has become increasingly popular, not only because of Redux and similar libraries, but beacuse React itself solidified the pattern in their library with the new Hooks API (useReducer
).
If you are unfamiliar with reducers they are essentially definitions of how an application's state changes in response to "actions". "actions" are just predefined changes your state can undergo. So all you need is some state object, a collection of "actions", and your actual reducer function that enacts the actions changes on the state. Here's an example of what that typically looks like:
const state = {
count: 0
}
const ACTIONS = {
COUNT_UP: 'COUNT_UP',
COUNT_DOWN: 'COUNT_DOWN
}
function reducer(state, action) {
switch(action.type) {
case ACTIONS.COUNT_UP:
return { ...state, count: state.count + 1 };
case ACTIONS.COUNT_DOWN:
return { ...state, count: state.count - 1 };
default:
return state;
}
}
There are thousands and thousands of code bases out there that have some slight variation of this reducer pattern. So obviously it works, people seem to like it, but it has always rubbed me the wrong way.
I have always been partial to switch statements. I don't think they read very well, you either have to return or manage awkward break
statements. However, what is really worse is that each case isn't it's own block. This means any variables defined aren't scoped to the case but to the entire reducer function. Here's an simple example:
function reducer(state, action) {
switch (action.type) {
case ACTIONS.REMOVE_FROM_LIST:
const i = state.list.find(action.item);
return {
...state,
list: [
...state.list.slice(0, i),
...state.list.slice(i + 1, state.list.length),
],
};
case ACTIONS.REMOVE_FROM_LIST_B:
// This line will throw an error
const i = state.listB.find(action.item);
return {
...state,
list: [
...state.listB.slice(0, i),
...state.listB.slice(i + 1, state.listB.length),
],
};
// ...
}
}
While this example may seem relatively benign, imagine you are working in a large codebase with dozens of actions. You can easily lose track of what variables are being used or defined and doing something as simple as adding a new case can be frustrating especially to a new developer. You could solve this by replacing the switch statement with a large if-else chain, but then your cases become harder to scan since the syntax of if-else obscures the case more than a switch.
So how can we use the reducer pattern without long chains of if/else's or big switch statements? That's where the "inverse reducer" comes into play. Instead of defining our types and then writing their logic inside the reducer, we are going to write them together.
const ACTIONS = {
COUNT_UP: (state, action) => ({
...state,
count: state.count + 1,
}),
COUNT_DOWN: (state, action) => ({
...state,
count: state.count - 1,
}),
};
function reducer(state, action) {
return action.type(state, action);
}
Look how simple our reducer becomes, and how easy it is to find out what each action actually does! We also gain block scoping in each action so we don't have to worry about defining our variables at the top of a giant switch block and mutating them later. Let's look at that list example again:
const ACTIONS = {
REMOVE_FROM_LIST: (state, action) => {
const i = state.list.find(action.item);
return {
...state,
list: [
...state.list.slice(0, i),
...state.list.slice(i + 1, state.list.length),
],
};
},
REMOVE_FROM_LIST_B: (state, action) => {
const i = state.listB.find(action.item);
return {
...state,
list: [
...state.listB.slice(0, i),
...state.listB.slice(i + 1, state.listB.length),
],
};
},
};
function reducer(state, action) {
return action.type(state, action);
}
All we are doing is instead of having a single massive function that handles all of the reducing logic, we create many tiny reducing functions. This inversion of control better shows the separation of concerns and improves readability.
Something I know people will say is, "Now you are passing around functions instead of strings for types, won't that cause problems?". The easy answer is no because JS passes everything except for primitive values by reference. Now when you say ACTIONS.REMOVE_FROM_LIST
instead of a string you are getting a reference to the actions reducing function. References are 8 bytes in JS so passing it around instead of a string is likely taking less memory and since JS's strict comparison checks the identity it will compare the reference when doing any equality checks. This could be improved even further if JS has a native concept of enums, but comparing the function references isn't that bad.
What are some flaws I missed? How could this make your project simpler? Let me know your thoughts on this pattern. I haven't found any real examples of this being used in JS projects, so I'm curious if you have seen this before, thanks for reading!