Use Immer to Simplify Immutable Data Structures in React
Immutable data structures lead to efficient change detection. This is very important for libraries like React where the Virtual DOM diffing needs to be fast. Immer keeps references in our objects and uses them to tell if an object has changed. Unchanged parts of a data object are kept and shared in memory with older versions of the data object.
In my experience, I’ve found the Immer library to work well when combined with the useReducer
hook from React. If you are not familiar with useReducer
, it is an alternative to useState
, that accepts a reducer of type (state, action) => newState
, and returns the current state paired with a dispatch
method. This is better than useState
especially in situations where you have complex state logic that involves many sub-values, or when next state depends on previous state.
Let’s say we have a situation where we have a list of tasks and want to be able to add new tasks to the list. Also we will like to toggle when a task is complete or not. These various states per task can become daunting when using useState
as it is not flexible to new requirements and adding more actions will also pollute the beginning of our functional components. This situation is perfect for the useReducer
hook, and combined with Immer, it makes the task less painful to write and read.
produce
from Immer gives a reducer function that fits the signature function required for useReducer
and we can pass it directly to useReducer
. The produce
function takes two parameters, draft
and action
. The draft
parameter is a temporary draft, which is a proxy of the currentState. Once all mutations are completed, then Immer will produce a nextState based on the mutations to the draft state. This makes it possible to change our data modifying its state while still getting the benefits of immutable data.
The function passed to produce
then behaves like a regular useReducer
function with the one caveat that we can mutate the data without worrying about the implications to our application as the data is not truly mutated but instead made immutable in the backend thanks to Immer.
import React, { useReducer, useCallback } from 'react';
import produce from 'immer';
import { uuid } from 'uuidv4';
const Tasks = () => {
const [tasks, dispatch] = useReducer(
produce((draft, action) => {
switch (action.type) {
case 'add':
draft.push({
id: action.id,
taskName: action.name,
completed: false,
});
break;
case 'toggle':
const task = draft.find((task) => task.id === action.id);
task.completed = !task.completed;
break;
default:
break;
}
}),
[
/* Initial tasks */
]
);
const handleAdd = useCallback((name) => {
dispatch({
type: 'add',
id: uuidv4(),
name,
});
});
const handleToggle = useCallback((id) => {
dispatch({
type: 'toggle',
id,
});
});
};
The above code can be made simpler by using useImmerReducer
:
import React, { useCallback } from 'react';
import { useImmerReducer } from 'immer';
import { uuid } from 'uuidv4';
const Tasks = () => {
const [tasks, dispatch] = useImmerReducer(
(draft, action) => {
switch (action.type) {
case 'add':
draft.push({
id: action.id,
taskName: action.name,
completed: false,
});
break;
case 'toggle':
const task = draft.find((task) => task.id === action.id);
task.completed = !task.completed;
break;
default:
break;
}
},
[
/* Initial tasks */
]
);
const handleAdd = useCallback((name) => {
dispatch({
type: 'add',
id: uuidv4(),
name,
});
});
const handleToggle = useCallback((id) => {
dispatch({
type: 'toggle',
id,
});
});
};
The combination of Immer
and useReducer
makes for very concise and legible code that simplifies state in your applications. Reach for this tool next time your state starts to get wild.