Immer with React useReducer.

Immer with React useReducer.

Let's mutate the state and Immer do the rest.

ยท

9 min read

Immer is a simple JavaScript library that allows us to use immutable state at the convenience of us updating the state in a mutable way.

But what is Mutation and Immutability and why are they important.

Mutation is simply when you update the old value instead of creating a copy out of it and updating a copy.

Let's see a simple example.


const originalObj = {
  username: "PUMA"
};

const copy = originalObj;

copy.username = "UPMA";

console.log(copy.username); // UPMA
console.log(originalObj.username); // UPMA

As we can see, modifying the copy, affected the original. This is bad!

What if you tried to mutate the array and then call a setState function in React?

Let's create a simple TODO app to see why mutation is bad!


import { useState } from "react";

export default function App() {
  const [todos, setTodos] = useState(["1st", "2nd"]);
  const [newTodo, setNew] = useState("");

  function addTodoHandler() {
    todos.push(newTodo);
    console.log(todos); // logs => ["1st", "2nd"]
    setTodos(todos);
  }


  return (
    <div className="App">
      <h2>Why Mutation is bad?</h2>
      <input type="text" onChange={(e) => setNew(e.target.value)} />
      <button onClick={addTodoHandler}>Add</button>
      <ul>
        {todos.map((todo, index) => {
          return <li key={index}>{todo}</li>;
        })}
      </ul>
    </div>
  );
}

Now if we try to add a new TODO as shown below, We'll get it on the console but the setState does not run, because the reference never changed.

bad mutation.gif

Mutation DEMO

Why did this happen? This is because React follows functional paradigm, meaning that updates should be pure.

How do we fix this? Simple. Let's make the update pure.

How do we make it pure? Simple again. All we need to do is make a copy of the previous data and then update the copy instead of modifying the original. This is also called Immutability

State is a reference to the component state at the time the change is being applied. It should not be directly mutated. Instead, changes should be represented by building a new object based on the input

import {  useState } from "react";

export default function App() {
  const [todos, setTodos] = useState(["1st", "2nd"]);
  const [newTodo, setNew] = useState("");

  function addTodoHandler() {
    setTodos((previousTodos) => [...previousTodos, newTodo]);
  }

  return (
    <div className="App">
      <h2>Why Mutation is bad?</h2>
      <input type="text" onChange={(e) => setNew(e.target.value)} />
      <button onClick={addTodoHandler}>Add</button>
      <ul>
        {todos.map((todo, index) => {
          return <li key={index}>{todo}</li>;
        })}
      </ul>
    </div>
  );
}

imm.gif

Since the state was simple, were were able to make it immutable without affecting the readability.

But what if your state was an object that had array and nested objects and so on...

Do you know how hard can it become? How it affects the readability?

Let us make our TODO App little complex by making the state an object and then adding multiple properties to it.

We'll use useReducer to manage the state instead of useState now.

import "./styles.css";
import { useReducer, useState } from "react";

const initialState = {
  isLoading: false,
  error: "",
  todos: [
    { id: 1, task: "Eat and sleep", done: false },
    { id: 2, task: "Wake up and eat", done: true }
  ]
};

function todoReducer(state, { type, payload }) {
  switch (type) {
    case "ADD_TODO":
      return { ...state, todos: [...state.todos, payload] };
    case "TOGGLE_TODO_STATUS":
      return {
        ...state,
        todos: state.todos.map((todo) => {
          return todo.id === payload.id ? { ...todo, done: !todo.done } : todo;
        })
      };
    default:
      return state;
  }
}
export default function App() {
  const [todoState, todoDispatch] = useReducer(todoReducer, initialState);
  const [task, setTask] = useState("");

  function addTodoHandler() {
    todoDispatch({
      type: "ADD_TODO",
      payload: { task, id: Math.random(), done: false }
    });
    setTask("");
  }

  function toggleTodoHandler(id) {
    todoDispatch({
      type: "TOGGLE_TODO_STATUS",
      payload: { id }
    });
  }

  return (
    <div className="App">
      <h2>TODO List!</h2>
      <input
        type="text"
        value={task}
        placeholder="add todo"
        onChange={(e) => setTask(e.target.value)}
      />
      <button onClick={addTodoHandler}>Create</button>
      <ul>
        {todoState.todos?.map((todo) => {
          return (
            <li key={todo.id} className="todo">
              <span className={todo.done ? "strike" : ""}>{todo.task}</span>
              <button onClick={() => toggleTodoHandler(todo.id)}>
                {todo.done ? "Undo" : "Complete"}
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

Now that's some code. Let us break it into pieces to understand it.

  • We made a todoState using reducer whose initial state is an object consisting of array of todos, a loading and error state.

  • In the reducer, we added two cases , ADD_TODO to add new todo to the todos array, TOGGLE_TODO_STATUS to update the status of a single todo.

todo.gif

This works like a charm!

But looks at this,

case "TOGGLE_TODO_STATUS":
      return {
        ...state,
        todos: state.todos.map((todo) => {
          return todo.id === payload.id ? { ...todo, done: !todo.done } : todo;
        })
      };

Just to toggle Boolean status of a todo, we had to use Spread Operator twice, think about deep this would go if the property was deeply nested?

How do we update the state but also keep the code clean while keeping in mind the Immutability factor.

This is where Immer shines. It allows us to mutate state. But isn't that bad? Nope, What Immer does is that even though we mutate, Immer takes that mutated state and makes it immutable.

CoolioGIF.gif

Before we update our code with Immer we need to know a few things.

Immer has 3 main states ,

  • currentState : the state object itself
  • draftState: This is a proxy of the state, to this we do mutable actions.
  • nextState: draftState is updated and an Immuatable state is returned.

image.png

Immer Syntax

The syntax is pretty straight forward.

It gives us a function called produce which does most of the Immutability work for us. This produce takes currentState i.e. the state object as first argument and a callback with draft as an argument and inside this callback, we mutate the draft state which is then made Immutable i.e. the updated/ new state.

       produce(state, (draft) => {
           // we update the draft here....
       });

Now that we have a basic idea let's try to update our reducer with Immer.

let's consider the ADD_TODO case. This is what it looked like.

case "ADD_TODO":
      return { ...state, todos: [...state.todos, payload] };

When a dispatch is fired, we now need to mutate the original todos array by pushing a new one into it.

This is what our ADD_TODO case looks like.

case "ADD_TODO":
      return produce(state, (draft) => {
        draft.todos.push(payload);
      });

Simple right. All we did is wrap the state in produce function and when dispatched, a draft is made of the state and then the draft is mutated by the array push method.

Similarly we need to update TOGGLE_TODO_STATUS case in the reducer. In this case, we can just use the find method to find the todo and then mutate it.

case "TOGGLE_TODO_STATUS":
      return produce(state, (draft) => {
        const todo = draft.todos.find((todo) => todo.id === payload.id);
        todo.done = !todo.done;
      });

This is how our reducer function now looks like.

function todoReducer(state, { type, payload }) {
  switch (type) {
    case "ADD_TODO":
      return produce(state, (draft) => {
        draft.todos.push(payload);
      });
    case "TOGGLE_TODO_STATUS":
      return produce(state, (draft) => {
        const todo = draft.todos.find((todo) => todo.id === payload.id);
        todo.done = !todo.done;
      });
    default:
      return state;
  }
}

It looks soo clean right!

SailorMoonCleaningGIF.gif

But what if our switch had a lot of cases. We just can sit and wrap each and wrap every case in a produce right. We can, but we don't want to.

How do we clean this then?

It's pretty simple.

All we need to do is to wrap the reducer in produce .


const todoReducer=produce(reducer);

The whole reducer itself.


const todoReducer = produce((draft, { type, payload }) => {
  switch (type) {
    case "ADD_TODO":
      draft.todos.push(payload);
      return;
    case "TOGGLE_TODO_STATUS":
      const todo = draft.todos.find((todo) => todo.id === payload.id);
      todo.done = !todo.done;
      return;
    default:
      return draft;
  }
});

DEMO with Immer produce

The syntactic overview of useReducer + Immer would look something Like this.

const [state, dispatch]=useReducer(produce(reducer), initialState)

Here what we see is basically Currying

This might look confusing to a few. Thankfully to those few, Immer has a better way to writing useReducer with Immer and it's called useImmerReducer

This would basically trim the reducer to as follows.

const [state, dispatch]=useImmerReducer(reducer, initialState)

And this is what our state and reducer together would look like.

  // state
  const [todoState, todoDispatch] = useImmerReducer(todoReducer, initialState);

 // reducer

const todoReducer = (state, { type, payload }) => {
  switch (type) {
    case "ADD_TODO":
      state.todos.push(payload);
      return;
    case "TOGGLE_TODO_STATUS":
      const todo = state.todos.find((todo) => todo.id === payload.id);
      todo.done = !todo.done;
      return;
    default:
      return state;
  }
};

Final Version of App JS


import "./styles.css";
import { useReducer, useState } from "react";
import { useImmerReducer } from "use-immer";

const initialState = {
  isLoading: false,
  error: "",
  todos: [
    { id: 1, task: "Eat and sleep", done: false },
    { id: 2, task: "Wake up and eat", done: true }
  ]
};

const todoReducer = (state, { type, payload }) => {
  switch (type) {
    case "ADD_TODO":
      state.todos.push(payload);
      return;
    case "TOGGLE_TODO_STATUS":
      const todo = state.todos.find((todo) => todo.id === payload.id);
      todo.done = !todo.done;
      return;
    default:
      return state;
  }
};

export default function App() {
  const [todoState, todoDispatch] = useImmerReducer(todoReducer, initialState);
  const [task, setTask] = useState("");

  function addTodoHandler() {
    todoDispatch({
      type: "ADD_TODO",
      payload: { task, id: Math.random(), done: false }
    });
    setTask("");
  }

  function toggleTodoHandler(id) {
    todoDispatch({
      type: "TOGGLE_TODO_STATUS",
      payload: { id }
    });
  }

  return (
    <div className="App">
      <h2>TODO List!</h2>
      <input
        type="text"
        value={task}
        placeholder="add todo"
        className="input"
        onChange={(e) => setTask(e.target.value)}
      />
      <button className="btn" onClick={addTodoHandler}>
        Create
      </button>
      <ul>
        {todoState.todos?.map((todo) => {
          return (
            <li key={todo.id} className="todo">
              <span className={todo.done ? "strike" : ""}>{todo.task}</span>
              <button onClick={() => toggleTodoHandler(todo.id)}>
                {todo.done ? "Undo" : "Complete"}
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

Try the DEMO using userImmer

So, what's the conclusion then. It's pretty simple. We saw why mutation is bad and Immutability is the way to go!

meme-one.png

Summary

  • Mutation is bad because we might see un-expected results like in the case of a state update.
  • State updates must be pure! i.e. Immutability should be followed while updating it.
  • When we write our code in pure fashion, there are high chances of affecting the code readability.
  • This is where Immer shines. It lets us update state in an immutable-way while we still have the convenience of writing code in a mutable fashion.
  • It follows copy-on-write mechanism to update a state.
  • It has 3 states -
    • currentState
    • draftState
    • nextState
  • produce and useImmerReducer from Immer help us in writing code in a mutable fashion while maintaining Immutability

thatsawrap.jpg

Thanks for Reading! Please drop your thoughts and suggestions. Happy Coding. ๐Ÿ˜

References

ย