Painless React state management with ImmerJS

React Feb 8, 2020

Immer is a library that allows you to work with immutable state effortlessly.

States are not meant to be updated directly (because React’s state has to be immutable), things can get really complicated as states become more complex. They become difficult to understand and follow. This is where Immer comes in. Using Immer, states can be simplified and much easier to follow.

The basic idea is that you will apply all your changes to a temporary draftState, which is a proxy of the currentState. Once all your mutations are completed, Immer will produce the nextState based on the mutations to the draft state. This means that you can interact with your data by simply modifying it while keeping all the benefits of immutable data.

Let's take a quick glance at example:

import produce from "immer"

const baseState = [
    {
        todo: "Learn typescript",
        done: true
    },
    {
        todo: "Try immer",
        done: false
    }
]

const nextState = produce(baseState, draftState => {
    draftState.push({todo: "Tweet about it"})
    draftState[1].done = true
})

The baseState will be untouched, but the nextState will be a new immutable tree that reflects all changes made to draftState.

Immer works by writing producers, and the simplest producer possible looks like this:\

import produce from "immer"

const nextState = produce(currentState, draft => {
  // code
})

The produce function takes two arguments. The currentState and a producer function. The current state determines our starting point, and the producer expresses what needs to happen to it. The producer function receives one argument, the draft, which is a proxy to the current state you passed in. Any modification you make to the draft will be recorded and used to produce nextState. The currentState will be untouched during this process.

Let’s take look at what happens when we start modifying the draft in our producer. Note that the producer function doesn’t return anything, the only thing that matters are the changes we make.

import produce from "immer"

const todos = [ /* 2 todo objects in here */ ]

const nextTodos = produce(todos, draft => {
    draft.push({ text: "learn immer", done: true })
    draft[1].done = true
})

// old state is unmodified
console.log(todos.length)        // 2
console.log(todos[1].done)       // false

// new state reflects the draft
console.log(nextTodos.length)    // 3
console.log(nextTodos[1].done)   // true

// structural sharing
console.log(todos === nextTodos)       // false
console.log(todos[0] === nextTodos[0]) // true
console.log(todos[1] === nextTodos[1]) // false

Here we actually see produce in action. We created a new state tree, which contains one extra todo item. Also, the status of the second todo was changed. These where the changes we applied to the draft, and they are nicely reflected in the resulting next state.

Another example:

If you have been working with React for a while now, then you’re not a stranger to the spread operator. With Immer, you need not make use of the spread operator, especially when working with an array in your state.

class App extends React.Component {
  constructor(props) {
      super(props)
      
      this.state = {
        item: "",
        price: 0,
        list: [
          { id: 1, name: "Oats", price: 12 },
          { id: 2, name: "Eggs", price: 10 }
        ]
      }
    }

    handleInputChange = e => {
      this.setState(
      produce(draft => {
        draft[event.target.name] = event.target.value
      }))
    }

    handleSubmit = (e) => {
      e.preventDefault()
      const newItem = {
        id: uuid.v4(),
        name: this.state.name,
        price: this.state.price
      }
      this.setState(
        produce(draft => {
          draft.list = draft.list.concat(newItem)
        })
      )
    };

  render() {
    return (
        <section>
            <form onSubmit={this.handleSubmit}>
              <h2>Create your shopping list</h2>
              <div>
                <input
                  type="text"
                  placeholder="Item's Name"
                  onChange={this.handleInputChange}
                  name="name"
                  className="input"
                  />
              </div>
              <div>
                <input
                  type="number"
                  placeholder="Item's Price"
                  onChange={this.handleInputChange}
                  name="price"
                  className="input"
                  />
              </div>
              <button className="button is-grey">Submit</button>
            </form>
          
          <div>
            {
              this.state.list.length ? (
                this.state.list.map(item => (
                  <ul>
                    <li key={item.id}>
                      <p>{item.name}</p>
                      <p>${item.price}</p>
                    </li>
                    <hr />
                  </ul>
                ))
              ) : <p>Your list is empty</p>
            }
          </div>
        </section>
    )
  }
}

First, we create an object using the data entered by the user, which we then assign to newItem. To update our application’s state, we make use of .concat() which will return a new array that's comprised of the previous items and the new item. This updated copy is now set as the value of draft.list, which can then be used by Immer to update the state of the application.

The callback function gets called after the state update. It’s important to note that it makes use of the updated state.

Let's Talk about Immer performance:

Immer is roughly as fast as ImmutableJS. However, the immutableJS + toJS makes clear the cost that often needs to be paid later; converting the immutableJS objects back to plain objects, to be able to pass them to components, over the network etc... (And there is also the upfront cost of converting data received from e.g. the server to immutable JS)

Immer with proxies is roughly speaking twice to three times slower as a handwritten reducer (the above test case is worst case, see yarn test:perf for more tests). This is in practice negligible.

Libraries build with immer:

  • react-copy-write Immutable state with a mutable API
  • redux-starter-kit A simple set of tools to make using Redux easier
  • immer based handleActions Boilerplate free actions for Redux
  • redux-box Modular and easy-to-grasp redux based state management, with least boilerplate
  • quick-redux tools to make redux development quicker and easier
  • bey Simple immutable state for React using Immer
  • immer-wieder State management lib that combines React 16 Context and immer for Redux semantics
  • robodux flexible way to reduce redux boilerplate
  • immer-reducer Type-safe and terse React (useReducer()) and Redux reducers with Typescript
  • redux-ts-utils Everything you need to create type-safe applications with Redux with a strong emphasis on simplicity
  • react-state-tree Drop-in replacement for useState that persists your state into a redux-like state tree
  • redux-immer is used to create an equivalent function of Redux combineReducers that works with immer state. Like redux-immutable but for immer
  • ngrx-wieder Lightweight yet configurable solution for implementing undo-redo in Angular apps on top of NgRx and Immer

Conclusion:

If you enjoyed this post, feel free to share it and subscribe to our blog. Thank you!

Tags

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.