DEV Community

loading...
Cover image for Immer vs Ramda - two approaches towards writing Redux reducers

Immer vs Ramda - two approaches towards writing Redux reducers

fkrasnowski profile image Franciszek Krasnowski ・6 min read

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, '⚽')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:
  }
})
Enter fullscreen mode Exit fullscreen mode

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:
  }
})
Enter fullscreen mode Exit fullscreen mode

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
  }
})
Enter fullscreen mode Exit fullscreen mode

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
  }
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Is equivalent to this:

fn => flip(uncurryN(2, fn))
Enter fullscreen mode Exit fullscreen mode

Besides, there is compose function in Ramda set. It works exactly the same but in reverse order:

compose(flip, uncurryN(2))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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('🐟', ['🦍', 'πŸ–', '🐟'])
Enter fullscreen mode Exit fullscreen mode

You could use findIndex to achieve the same effect. It's Array.prototype.findIndex exposed as curried function:

const isFish = animal => animal === '🐟'

findIndex(isFish, ['🦍', 'πŸ–', '🐟'])
Enter fullscreen mode Exit fullscreen mode

It's the same as:

;['🦍', 'πŸ–', '🐟'].findIndex(isFish)
Enter fullscreen mode Exit fullscreen mode

equals

It's how indexOf and other Ramdas functions compare arguments

This function compares two values:

const isFish = equals('🐟')
Enter fullscreen mode Exit fullscreen mode

It's a deep comparison so you can compare objects as well:

equals([1, 2], [1, 2]) // Returns: true
Enter fullscreen mode Exit fullscreen mode

adjust

Adjust applies the function to a specific element of the array

adjust(1, n => n * 2, [1, 2, 3]) // Returns: [1, 4, 3]
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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: ['πŸ‘Ÿ', 'πŸ₯Ώ', 'πŸ‘ ']
Enter fullscreen mode Exit fullscreen mode

identity

It's just:

v => () => v
Enter fullscreen mode Exit fullscreen mode

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

Discussion (9)

pic
Editor guide
Collapse
markerikson profile image
Mark Erikson

Note that you should be using our official Redux Toolkit package, which already comes with Immer built in:

redux.js.org/tutorials/fundamental...

In addition, createSlice also generates your action creators for free and handles all the TS typing based on the payload types that you declare.

Collapse
fkrasnowski profile image
Franciszek Krasnowski Author

I am aware of Redux Toolkit. (I probably should mention that library in the article). However, this article is about the comparison of approaches to writing reducers, and the problems I've mentioned are not limited to Redux so it's not targeting mainly Redux, but Immer and Ramda as well. I've considered Redux as a good foundation for further analysis of those libraries and something that many devs can relate to. I like the way Redux Toolkit implements Redux, Immer, Redux Thunk and automatic action creators. I'm also open to other implementations for example: redux-observable + Ramda.

Collapse
markerikson profile image
Mark Erikson

As it turns out, you can use Redux Toolkit even if you're not using a Redux store!

A reducer is, ultimately, just a function. It doesn't matter if that function is implemented using vanilla JS, Immer, Ramda, Lodash/FP, or something else.

I've used Redux Toolkit's createSlice API multiple times to create reducers that were only meant for use with the useReducer hook in a React component, in apps that weren't using a Redux store at all.

If you're looking at using Immer to write a reducer, you should seriously consider just using createSlice instead of calling produce yourself. createSlice adds minimal extra runtime overhead beyond Immer by itself, and works great with TypeScript as well.

Thread Thread
fkrasnowski profile image
Franciszek Krasnowski Author

Yeah, it's a great tool indeed. I've included your thoughts in the article

Collapse
chasm profile image
Charles F. Munat

The problem with Immer is that it purports to give you the benefits of functional programming without having to learn functional programming. You can keep using all your old, impure, mutable techniques, but Immer will babysit for you and clean up your messes. This is infantilizing.

I much prefer Ramda because it means that I have to think like a functional programmer -- something that is much easier than most OOP programmers believe. If they hadn't learned OOP first, they wouldn't have so much trouble learning FP. It requires a lot of unlearning.

But when I teach beginners, I always start with FP and only FP, and I often use Ramda. Then I have them reimplement the Ramda functions in vanilla JS -- it's not difficult at all. In the end, they understand immutability, referential transparency, recursion, and more and it just comes naturally to them.

For me, Immer is a code stink.

Collapse
fkrasnowski profile image
Franciszek Krasnowski Author

The problem with Immer is that it purports to give you the benefits of functional programming without having to learn functional programming

Most software developers are lazy. It's easier to just upgrade your existing tool a little to fix some problems than to learn the new ecosystem in which this problem does not exist. You can make great language but most devs who know C will choose C++.

If it comes to me I like how it's done in Rust where you can create mutable references inside a function. In JavaScript similar functionality can be achieved by just making deep copies inside the function:

const fn = arr => {
   let nextArr = deepCopy(arr)
   nextArr[1] = 2
   return nextArr
}
Enter fullscreen mode Exit fullscreen mode

Still Immer makes it terser:

const fn = produce(arr => {
   arr[1] = 2
})
Enter fullscreen mode Exit fullscreen mode

I like the explicitness of this "mutation". Immer can be handy when you have to deal with some stateful problems - it encapsulates your mutating logic. And that's what most functional languages do - hiding state from a developer by for example switching tail recursion to iteration.

Collapse
xavierbrinonecs profile image
Xavier Brinon

This whole approach should be based on complexity and how to fight it. State management is one axis of evil; it is difficult to keep simple.

There is an excellent paper about complexity in programs as the root of all evil, available for free: Out of the tarpit.

Immer will not get you very far against complexity as compared to Ramda or even ADT libraries like Crocks (crocks.dev/)

Also shout out for taking advantage of the ASI feature, those pesky semicolons...

Collapse
bacloud14 profile image
bacloud14

so much to learn!

Collapse
fkrasnowski profile image