ReduxJS/Toolkit: Immer
posted in javascript on • by Wouter Van Schandevijl •
immerjs/immer
:
Create the next immutable state by mutating the current one
Immer is the solution the ReduxJS/Toolkit has adopted to enforce pure reducers: it allows you to write reducers while (almost) not having to think about immutability anymore!
Awards!? 🏆🥇
- React: Breakthrough of the year 2019
- JavaScript: Most impactful contribution 2019
Redux Bugs
Redux bugs are almost always cases where you didn’t quite spread enough and/or did an accidental mutation of the state.
This is exactly the problem immer addresses and does so in a way that you basically
don’t know you’re using immer until you inspect the type and see WritableDraft<T>
.
Example
Without Immer
Do you really want to be doing this…
function reducerSpreadHell(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue,
},
},
},
}
}
One could argue that you should be normalizing your store instead of writing reducers like that, and you’d be right.
With Immer
Still, with immer, all that becomes just this:
state.first.second[action.someId].fourth = action.someValue;
Immer Reducers
You’d write them as if you just didn’t care about immutability!
import { current, PayloadAction } from "@reduxjs/toolkit";
reducers: {
addTodo: (state: WritableDraft<Todo>[], action: PayloadAction<Todo>) => {
// ReduxJS/Toolkit wraps the Todo[] state in a WritableDraft
// "[].push()" does NOT mutate!
// Same for pop, shift, splice etc...
state.push(action.payload);
},
removeTodo: (state, action: PayloadAction<number>) => {
// ATTN: state = newState does NOT work!
// In such case, return the newState
return state.filter(todo => todo.id !== action.payload);
},
// ATTN: Mutate or Return, but not both!
altAddTodo: (state, action: PayloadAction<Todo>) => void state.push(action.payload),
toggleTodo: (state, action: PayloadAction<number>) => {
// If you want to look at the current state, you need to use current()
// There is also original()
console.log('current(state)', current(state));
const todo = state.find(todo => todo.id === action.payload)!;
todo.done = !todo.done;
},
updateTodo: (state, action: PayloadAction<Todo>) => {
const index = state.findIndex(todo => todo.id === action.payload.id);
if (index !== -1) {
state[index] = action.payload;
}
},
},
Caveats
There are some caveats like using current()
when you want to look at the state.
Replacing the entire state
As in the case of removeTodo, you cannot completely replace state
.
// ⚠️⚠️ DOES NOT WORK ⚠️⚠️
removeTodo: (state, action: PayloadAction<number>) => {
// This could only work if state would be "by ref"
// which JavaScript doesn't have
state = state.filter(todo => todo.id !== action.payload);
}
// ✔️✔️ DOES WORK ✔️✔️
removeTodo: (state, action: PayloadAction<number>) => {
return state.filter(todo => todo.id !== action.payload);
}
Reducers without curly braces
Use void
for your one-liner reducers:
// ⚠️⚠️ TypeScript ERROR ⚠️⚠️
// [].push() returns a number which is not a valid new state
addTodo: (state, action: PayloadAction<Todo>) => state.push(action.payload)
// ✔️✔️ BOTH WORK ✔️✔️
addTodo: (state, action: PayloadAction<Todo>) => void state.push(action.payload)
addTodo: (state, action: PayloadAction<Todo>) => {
state.push(action.payload);
}
Primitives cannot be wrapped
If you extract a primitive value (number, bool, string) from the state and then manipulate it, you’re no longer working with a WritableDraft and are just mutating the local variable.
addMessage: (state, action: PayloadAction<any>) => {
// ⚠️⚠️ DOES NOT WORK ⚠️⚠️
let { newMessagesCount } = state;
newMessagesCount++;
// ✔️✔️ DOES WORK ✔️✔️
state.newMessagesCount++;
}
Under the hood
Immer’s bread and butter is the produce(baseState, recipe)
function.
It takes your baseState
, wraps it in a WritableDraft
and
executes a recipe
against it, which means calling your reducer
with the dispatched action. It then creates the new state
by re-applying the steps done in the recipe to the baseState,
but in a non-mutating way.
The ReduxJS/Toolkit reducers have already called produce
,
so this has been abstracted away for us.
Produce Example
Small code example to illustrate how produce
works.
In the accompanying git repository, the code is a bit more involved and includes tests
to prove that only the objects that changed actually have
different references!
import { produce } from "immer";
interface UpdatePizzaTopping {
type: "🍄" | "🌶️" | "🥓";
doubleIt: boolean;
}
const reducerTastyPizza = (baseState: PizzaState, action: UpdatePizzaTopping) => {
const recipe = (draft: WritableDraft<PizzaState>) => {
const topping = draft.toppings.find(topping => topping.type === action.type);
if (!topping)
return;
topping.doubleIt = action.doubleIt;
}
const newState: PizzaState = produce(baseState, recipe);
return newState;
}
Which you would typically just write like so:
const reducerTastyPizza = (state: PizzaState, action: UpdatePizzaTopping) => {
return produce(state, draft => { /* do stuff here! */ });
}
Outside of ReduxJS/Toolkit
Immer can be useful even if you are not using ReduxJS/Toolkit. Or just outside your reducers – you already have the dependency anyway!
In a form Component, you might have something like
const [title, setTitle] = useState("");
const [text, setText] = useState("");
// If you've got 10 properties, then 10x useState???
Nimmer!
import { produce } from "immer";
const TodoAdd = () => {
const [todo, setTodo] = useState<Todo>(produce(() => ({
title: '',
text: '',
})));
return (
<>
<input
value={todo.title}
onChange={e => setTodo(produce(todo, draft => {draft.title = e.target.value}))}
/>
<textarea
value={todo.text}
onChange={e => setTodo(produce(todo, draft => {draft.text = e.target.value}))}
/>
</>
);
}
Uhoh, setTodo(produce(todo, draft...))
, might as well stick with my 10x useState!
You can make this a lot nicer with a helper function, while still keeping everything type-safe:
const updateTodo = <K extends keyof Todo>(key: K, value: Todo[K]) => {
setTodo(produce(todo, draft => {
draft[key] = value;
}));
}
return (
<input value={todo.title} onChange={e => updateTodo('title', e.target.value)} />
)
useImmer
But of course there is a package for that!
immerjs/use-immer
:
Use immer to drive state with a React hooks
import { useImmer } from "use-immer";
const TodoAdd = () => {
const [todo, setTodo] = useImmer<Todo>({
title: '',
text: '',
});
return (
<>
<input
value={todo.title}
onChange={e => setTodo(draft => draft.title = e.target.value)}
/>
<textarea
value={todo.text}
onChange={e => setTodo(draft => draft.text = e.target.value)}
/>
</>
);
}
use-immer
also exposes useImmerReducer
to replace your useReducer
s.