Immer vs Ramda - two approaches towards writing Redux reducers
Reducers - a core element of Redux‘s philosophy that tightly grabs mutations of a given state in one place. In theory, the pure nature of reducers should lead to great scalability, readability, and make us all fortunate children of Redux god. But even the brightest idea can be dimmed if thrown on the one most pediculous soil…
Yes. I speak about JavaScript. Writing complex pure functions in vanilla JavaScript is harsh. Avoiding mutations is extraordinarily hard. Matching against actions? There are no Variants/Enums in JS, you have to use strings instead. And you land with a poor switch statement taken straight from the hell. Regardless, Redux is the most popular state manager for React applications
The path to purity
Consider the two ways to make your life easier, the first one will be the Immer - Immer is a package that lets you deliver the next state by “mutating” the draft of the previous state:
import produce from 'immer' const replace = produce((draft, key, element) => { draft[key] = element }) const list = ['⚾', '🏀', '🏉'] const newList = replace(list, 1, '⚽')
The replace
function is pure, despite the explicitly written assignment of property. It does not change the original object. So with little help of the produce
function, you can write mutating logic inside your reducer.
The second way is to use the Ramda library. Ramda is a set of utility functions that perform basic operations on data and functions. And all of them are pure!
import { update } from 'ramda' const list = ['⚾', '🏀', '🏉'] const newList = update(1, '⚽', list)
The Immer Way
Let’s get to work and write a simple “todo” reducer with Immer:
Warning drastic content!
const todosRedcuer = produce((state, action) => { const isTodo = todo => todo.id === action.todo?.id const remove = (index, arr) => arr.splice(index, 1) switch (action.type) { case 'ADD_TODO': state.unshift({ ...action.todo, id: generateID() }) break case 'CHECK_TODO': for (const index in state) { if (isTodo(state[index])) { state[index].done = !state[index].done break } } break case 'REMOVE_TODO': for (const index in state) { if (isTodo(state[index])) { remove(index, state) break } } break case 'EDIT_TODO': for (const index in state) { if (isTodo(state[index])) { state[index].text = action.next.text break } } break default: } })
It’s disgusting. There are so much code and so little meaning in this example. It’s under-engineered. Our code does not have to be so procedural. Let’s refactor it to be consumable:
const todosRedcuer = produce((state, action) => { const isTodo = todo => todo.id === action.todo?.id const not = fn => v => !fn(v) const todoIndex = state.findIndex(isTodo) switch (action.type) { case 'ADD_TODO': state.unshift({ ...action.todo, id: generateID() }) break case 'CHECK_TODO': state[todoIndex].done = !state[todoIndex].done break case 'REMOVE_TODO': return state.filter(not(isTodo)) case 'EDIT_TODO': state[todoIndex].text = action.next.text break default: } })
Much better. Now you can see the benefits of Immer. We can freely use well-known methods like push
pop
splice
, we can explicitly assign new values. And If it’s in your need, you can return from produce
and it will behave as a regular function (See the REMOVE_TODO
action).
@markerikson - the maintainer of Redux. Advised me that if you consider using Immer to write Redux reducers you should go with Redux ToolKit - the official way to reduce (😎) Redux boilerplate
The die has been cast - Ramda way
Let’s recreate the same functionality, this time utilizing the power of Ramda:
const reducer = pipe(uncurryN(2), flip) const todosRedcuer = reducer(action => { const lensTodo = pipe(indexOf(action.todo), lensIndex) const lensTodoProp = (prop, state) => compose(lensTodo(state), lensProp(prop)) switch (action.type) { case 'ADD_TODO': return prepend({ ...action.todo, id: generateID() }) case 'CHECK_TODO': return state => over(lensTodoProp('done', state), v => !v, state) case 'REMOVE_TODO': return without([action.todo]) case 'EDIT_TODO': return state => set(lensTodoProp('text', state), action.next.text, state) default: return identity } })
If you wonder - it’s not even worth reading. This code is complex and stupid at the same time - it’s over-engineered. When I had written this I’ve realized I’ve got too far. Let’s refactor it:
const reducer = pipe(uncurryN(2), flip) const todosRedcuer = reducer(action => { const findTodo = indexOf(action.todo) const evolveTodo = ev => state => adjust(findTodo(state), evolve(ev), state) switch (action.type) { case 'ADD_TODO': return prepend({ ...action.todo, id: generateID() }) case 'CHECK_TODO': return evolveTodo({ done: v => !v }) case 'REMOVE_TODO': return without([action.todo]) case 'EDIT_TODO': return evolveTodo({ text: () => action.next.text }) default: return identity } })
Ramda functions
Let’s walk through each of these functions:
Before it’s too late. All Ramda functions are curried in the sense that we can partially apply arguments to them. So instead:
const add5 = n => add(5, n)
We can do:
const add5 = add(5) // If add is curried
pipe
It allows you to compose functions such as the product of the first function becomes an argument of the second one and so on. It reduces the noise when composing functions. And this:
pipe(uncurryN(2), flip)
Is equivalent to this:
fn => flip(uncurryN(2, fn))
Besides, there is compose
function in Ramda set. It works exactly the same but in reverse order:
compose(flip, uncurryN(2))
uncurryN
It transforms curried arguments of function to standard one. So:
const curriedPower = a => b => a ** b const power = uncurryN(2, curriedAdd) power(3, 2) // Returns: 9
Why? I want to use the curried function like
action => state => next_state
. But Redux expects the reducer to be(state, action) => next_state
flip
It swaps the first two arguments of the given function:
const flipPower = flip(power) flipPower(3, 2) // Returns: 8
Why? If you didn’t notice, reducer arguments are reversed. We have
(action, state) => ...
and flip it to(state, action) => ...
indexOf
Works similarly to Array.proptotype.indexOf
with the difference that it matches objects too:
indexOf('🐟', ['🦍', '🐖', '🐟'])
You could use findIndex
to achieve the same effect. It’s Array.prototype.findIndex
exposed as curried function:
const isFish = animal => animal === '🐟' findIndex(isFish, ['🦍', '🐖', '🐟'])
It’s the same as:
;['🦍', '🐖', '🐟'].findIndex(isFish)
equals
It’s how
indexOf
and other Ramdas functions compare arguments
This function compares two values:
const isFish = equals('🐟')
It’s a deep comparison so you can compare objects as well:
equals([1, 2], [1, 2]) // Returns: true
adjust
Adjust applies the function to a specific element of the array
adjust(1, n => n * 2, [1, 2, 3]) // Returns: [1, 4, 3]
Why? It’s how we aim at specific todo to change it further
evolve
One of my favorite functions. It takes the object reducers and applies them for corresponding properties:
const player = { level: 4, gold: 1858, mana: 3000, } evolve( { mana: m => m + 2, gold: g => g + 1, }, player ) // Returns: { level: 4, gold: 1859, mana: 3002 }
prepend
Works as Array.prototype.unshift
but returns a new array instead of modifying the existing one
without
It takes the list of elements and array and returns a new array without them. It uses equals
to compare elements so you can exclude objects too.
without(['👞', '👢'], ['👞', '👟', '🥿', '👠', '👢']) // Returns: ['👟', '🥿', '👠']
identity
It’s just:
v => () => v
Why? By default we want to return the previous state without any change
Conclusion
Both Immer and Ramda are great tools to keep purity in js. The big benefit of Immer over Ramda is the fact that you don’t have to learn anything new - just use all your JavaScript knowledge. What is more, changes inside produce
are very clear. Ramda gives you the right functions to do the job, as a result, your code becomes less repetitive, clean, and very scalable. Of course, you can write all of those functions by yourself, but what is the point of reinventing the wheel? What is the reason to use patterns? If there is a pattern, then there is a place for automation. Nevertheless, these packages can be easily abused. While your code can be too procedural the wrong abstraction may be just as big overhead.
As some others have noticed this “comparison” is not very accurate, since Ramda and Immer are not strict competitors. You’ll never stand before the choice “Which one to use over another”. It was apparent to me - this form I’ve chosen is supposed to make the article more entertaining. I don’t pressure you to make any choice