Simplify immutable data structures in useReducer with Immer

Last updated on by Prateek Surana   •   - min read

When it comes to state management in React, useReducer offers a robust API that shines when you have a complex state with multiple sub-values and using useState might be complicated. But things can still get a bit nasty when dealing with deeply nested objects in within your reducer. So in this article, we’ll see how Immer solves those problems and how you can significantly simplify your reducers with it.

The problem

If you have used useReducer in React, then you know that you cannot mutate the current state, and you need to return a new object if you want to trigger a re-render with the updated values. If you’re curious, here’s an excerpt from the React docs that explains why it is that way:

If you return the same value from a Reducer Hook as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)

It’s usually not a problem when your state is just one-level or even a two-level nested object. For instance:

const reducer = (prevState, action) => {
switch (action.type) {
case 'increment-count':
return {
...prevState
count: state.count + 1
};
case 'decrement-count':
return {
...prevState
count: state.count - 1
};
...
// Omitted for brevity
}
}

But things start to get hard to read and error-prone as you try to modify deeply nested objects, check the below example for reference:

const reducer = (prevState, action) => {
switch (action.type) {
case 'update-username':
return {
...prevState,
project: {
...prevState.project,
users: {
...prevState.project.users,
[payload.userID]: {
...prevState.project.users[payload.userID],
username: payload.username
}
},
},
};
...
// Omitted for brevity
};

That’s where Immer comes in and saves the day. But before that:

What is Immer anyways?

Glad you asked. If you read their description on their homepage, it says:

Immer (German for: always) is a tiny package that allows you to work with immutable state in a more convenient way.

So that description does sound like something we need from the previous section. But how does it work?

Immer provides you with a produce function that takes the state you want to modify as the first argument, and in the second argument is a function that receives a draft as its argument. Then you can modify the draft in that method, and the produce function would output the next state with the changes you made to the draft. The main thing to note here is that you can apply straightforward mutations to the draft, and those mutations are recorded and used to produce the next state. Let’s see an example to understand how it works:

const state = {
name: 'Arya Stark',
pets: [{ name: 'Nymeria', type: 'Direwolf' }],
};

const nextState = produce(state, (draft) => {
draft.name = 'Jon Snow';
draft.pets[0].name = 'Ghost';
});

console.log(state === nextState);
// false

console.log(`${state.name}'s pet is ${state.pets[0].name}
and
${nextState.name}'s pet is ${nextState.pets[0].name}`
);
// Arya Stark's pet is Nymeria
// and Jon Snow's pet is Ghost

As you can see in the above example, the state variable remains untouched yet the nextState reflects the changes we made to the draftState. Isn’t that magical 🧙‍♀️

If you’re interested in how the magic works behind the scenes, you should check out this blog by the creators of Immer.

Curried producers

If you’re not familiar with the term, currying is a process of converting a function that takes multiple arguments into a sequence of functions with a single argument. Check out this explanation on StackOverflow if the above sentence doesn’t make sense to you.

So apart from the behaviour of produce that we saw above, if you pass it a function as the first argument it creates a function that doesn’t apply  produce yet to a specific state, but instead creates a function that will apply produce to any state that is passed to it in the future.

For example :

const state = {
name: 'Thor',
abilities: ['Worthy for Mjölnir', 'God of Thunder']
}

const addAbility = produce((draft, ability) => {
draft.abilities.push(ability)
})

const nextState = addAbility(state, 'Virtually Immortal')

console.log(nextState.abilities)
// ['Worthy for Mjölnir', 'God of Thunder', 'Virtually Immortal']

useReducer with Immer

Now that we have seen how Immer works, you might have an idea of how it will solve the problem that we saw in the beginning with useReducer.

The best way to use Immer with a useReducer is using curried produce that we saw in the previous section. So, for instance if you had a reducer like this.

const [state, dispatch] = React.useReducer((state, action) => {
switch (action.type) {
case 'update-email':
return {
...state,
users: {
...state.users,
[action.userID]: {
...state.users[action.payload.userID],
email: action.payload.email,
},
},
}
...
// Omitted for brevity
}
}, initialState)

// And then somewhere inside the component...
dispatch({
type: 'update-email',
payload: { userID: 'john', email: '[email protected]' }
})

This is how it would look like with a curried produce:

const [state, dispatch] = React.useReducer(
produce((draft, action) => {
switch (action.type) {
case 'update-email':
draft.users[action.payload.userID].email = action.payload.email;
break;
...
// Omitted for brevity
}
}),
initialState
);

Neat isn’t it ;)

But what about TypeScript?

Immer ships with basic type definitions out of the box, which can be picked by TypeScript without any further configuration. Also produce can automatically infer types based on the input provided:

interface Character {
name: string;
family: string;
}

const character: Character = {
name: 'Arya',
family: 'Stark'
}

// Type of nextChacracter would also be State
const nextCharacter = produce(character, draft => {
// ✅ You also get type safety here
draft.name = 'Sansa'
// ❌ Modifying any other property would result in a type error
// draft.direwolf = 'Lady'
})

When it comes to curried produce they can also infer the types as best as possible, but in cases where they cannot be inferred directly it is recommended to use generics instead:

// Apart from the state, type of any additional arguments needs to be defined
// as a tuple. So in the case below since the curried produce accepts a string
// for the name of the character, we take it as the second argument.
const changeCharacterName = produce<Character, [string]>((draft, name) => {
draft.name = name;
});

const nextCharacter = changeCharacterName(character, "Jon")

Lastly, when it comes to type-safety with useReducer I prefer using useImmerReducer because it works great with TypeScript. Under the hood it's just a small wrapper over useReducer with produce similar to what we saw in the previous section but it works pretty well with TypeScript. Here’s a small example that demonstrates how it works with TypeScript:

interface Todo {
id: string;
name: string;
done: boolean;
}

type State = Array<Todo>;

type Action =
| { type: "ADD_TODO"; text: string }
| { type: "TOGGLE_TODO"; id: string }
| { type: "REMOVE_TODO"; id: string };

const reducer = (draft: State, action: Action) => {
switch (action.type) {
case "ADD_TODO":
draft.push({ id: uuid(), name: action.text, done: false });
break;
case "TOGGLE_TODO":
const todo = draft.find((todo) => todo.id === action.id);
break;
case "REMOVE_TODO":
draft.splice(draft.findIndex((todo) => todo.id === action.id), 1);
break;
}
};

const TodoList = () => {
const [todos, dispatch] = useImmerReducer<State, Action>(
reducer,
[]
);
...
// Rest of rendering logic
}

Conclusion

So to summarize in this short guide we saw:

  • The problem that one faces with useReducer when dealing with nested data structures.
  • How Immer solves the problem by giving you the ability to apply straightforward mutations to objects.
  • Lastly we saw how you can use Immer with useReducer to simplify your state updates and how it nicely integrates with TypeScript.

Keep in mind you don’t need to go around and transform all the reducer functions in your code to use Immer. If the object you are dealing with is not deeply nested, it’s completely fine to clone it via shallow copying the previous state.

But when you think that keeping track of state updates are getting out of hand, then it might be a good idea to use Immer and just forget about the complexity of copying over state.


#React #TypeScript
Enjoyed this blog, share it on: Twitter LinkedIn

You might also like:

Want to get better at React, JavaScript, and TypeScript?

I regularly publish posts like this one, containing best practices, tips, and tutorials on React, JavaScript, and TypeScript. Subscribe to my newsletter to get them straight to your inbox. No spam ever. Unsubscribe at any time. You can also subscribe via RSS.

Prateek Surana

About Prateek Surana

Prateek is a Frontend Engineer currently building Devfolio. He loves writing stuff about JavaScript, React, TypeScript, and whatever he learns along his developer journey. Apart from his unconditional love for technology, he enjoys watching Marvel movies and playing quirky games on his phone.