A Better Way to Handle State with Immer
How to handle React state and complex data structures in an immutable way with Immer
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 →