A Better Way to Handle State with Immer

How to handle React state and complex data structures in an immutable way with Immer

Nivetha Krishnan
Bits and Pieces

--

Photo by Clément Hélardot on Unsplash

What is Immer?

Immer is a tiny package that allows you to work with immutable states in a more convenient way.

How Immer Works?

The basic idea with Immer is that all the changes are applied to a temporary draft called Proxy of that currentState. Once all your mutations are completed, Immer will produce the nextState based on the mutations to the draft state.

Under the hood, Immer figures out which parts of the draft have been changed and produces a completely new object that contains the changes.

This is why you can mutate it freely as much as you like!

//Syntax for using Immer 
updatePerson(draft => {
draft.places.city = 'Lagos';
});

Using Produce

The Immer package exposes a default function that does all the work.

produce(currentState, recipe: (draftState) => void): nextState

produce takes a base state, and a recipe that can be used to perform all the desired mutations on the draft that is passed in. The interesting thing about Immer is that the baseState will be untouched, but the nextState will reflect all changes made to draftState.

Inside the recipe, all standard JavaScript APIs can be used on the draft object, including field assignments, delete operations, and mutating array, Map and Set operations like push, pop, splice, set, sort, remove, etc.

Replacing State with Immer

If your state is deeply nested, you might want to consider flattening it. But, if you don’t want to change your state structure, you might prefer a shortcut for nested state structure, which is Immer. But unlike a regular mutation, it doesn’t overwrite the past state!

useState + Immer:

The useState hook assumes any state that is stored inside it is treated as immutable. Deep updates in the state of React components can be greatly simplified by using Immer. The following example shows how to use produce in combination with useState

import React, { useCallback, useState } from "react";
import produce from "immer";
const TodoList = () => {
const [todos, setTodos] = useState([
{
id: "React",
title: "Learn React",
done: true
},
{
id: "Immer",
title: "Try Immer",
done: false
}
]);
const handleToggle = useCallback((id) => {
setTodos(
produce((draft) => {
const todo = draft.find((todo) => todo.id === id);
todo.done = !todo.done;
})
);
}, []);
const handleAdd = useCallback(() => {
setTodos(
produce((draft) => {
draft.push({
id: "todo_" + Math.random(),
title: "A new todo",
done: false
});
})
);
}, []);
return (<div>{* List of Todo */}</div>)
}

useImmer

useImmer(initialState) is very similar to useState. The function returns a tuple, the first value of the tuple is the current state, the second is the updater function, which accepts an Immer producer function or a value as arguments.

import React, { useCallback } from "react";
import { useImmer } from "use-immer";
const TodoList = () => {
const [todos, setTodos] = useImmer([
{
id: "React",
title: "Learn React",
done: true
},
{
id: "Immer",
title: "Try Immer",
done: false
}
]);
const handleToggle = useCallback((id) => {
setTodos((draft) => {
const todo = draft.find((todo) => todo.id === id);
todo.done = !todo.done;
});
}, []);
const handleAdd = useCallback(() => {
setTodos((draft) => {
draft.push({
id: "todo_" + Math.random(),
title: "A new todo",
done: false
});
});
}, []);

Using Immer with Reducer

useReducer + Immer

Similarly to useState, useReducer combines neatly with Immer as well, as below example

import React, {useCallback, useReducer} from "react"
import produce from "immer"
const TodoList = () => {
const [todos, dispatch] = useReducer(
produce((draft, action) => {
switch (action.type) {
case "toggle":
const todo = draft.find(todo => todo.id === action.id)
todo.done = !todo.done
break
case "add":
draft.push({
id: action.id,
title: "A new todo",
done: false
})
break
default:
break
}
}),
[
/* initial todos */
]
)
const handleToggle = useCallback(id => {
dispatch({
type: "toggle",
id
})
}, [])
const handleAdd = useCallback(() => {
dispatch({
type: "add",
id: "todo_" + Math.random()
})
}, [])
}

useImmerReducer

import React, { useCallback } from "react";
import { useImmerReducer } from "use-immer";
const TodoList = () => {
const [todos, dispatch] = useImmerReducer(
(draft, action) => {
switch (action.type) {
case "toggle":
const todo = draft.find((todo) => todo.id === action.id);
todo.done = !todo.done;
break;
case "add":
draft.push({
id: action.id,
title: "A new todo",
done: false
});
break;
default:
break;
}
},
[ /* initial todos */ ]
);

Conclusion

Immer is a great way to keep the update handlers concise, especially if there’s nesting in your state, and reduce copying repetitive code. It’s an effective solution for those of you looking to mix useState and useReducer with more complex data structures such as deeply nested arrays.

Build composable web applications

Don’t build web monoliths. Use Bit to create and compose decoupled software components — in your favorite frameworks like React or Node. Build scalable and modular applications with a powerful and enjoyable dev experience.

Bring your team to Bit Cloud to host and collaborate on components together, and greatly speed up, scale, and standardize development as a team. Start with composable frontends like a Design System or Micro Frontends, or explore the composable backend. Give it a try →

Learn More

--

--