Skip to main content

React & Immer

egghead.io lesson 8: Using Immer with _useState_. Or: _useImmer_

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 as by using Immer. The following example shows how to use produce in combination with useState, and can be tried on CodeSandbox.

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>{*/ See CodeSandbox */}</div>)
}

useImmer

Since all state updaters follow the same pattern where the update function is wrapped in produce, it is also possible to simplify the above by leveraging the use-immer package that will wrap updater functions in produce automatically:

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
});
});
}, []);

// etc

For the full demo see CodeSandbox.

useReducer + Immer

Similarly to useState, useReducer combines neatly with Immer as well, as demonstrated in this CodeSandbox:

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()
})
}, [])

// etc
}

useImmerReducer

...which again, can be slightly shorted by useImmerReducer from the use-immer package (demo):

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 */ ]
);

//etc

Redux + Immer

Redux + Immer is extensively covered in the documentation of Redux Toolkit. For Redux without Redux Toolkit, the same trick as applied to useReducer above can be applied: wrap the reducer function with produce, and you can safely mutate the draft!

For example:

import {produce} from "immer"

// Reducer with initial state
const INITIAL_STATE = [
/* bunch of todos */
]

const todosReducer = 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
}
})