Written by Rashedul Alam✏️
When working with states in React, we have several choices for managing and modifying them in our projects. This article will discuss three state management solutions for managing immutable states: Reducer, Immer, and Mutative.
We will provide an overview of each tool with examples of how to use them. Then, we'll compare their performance and see which is better for state management.
Let's get started.
Overview of React reducers
Most React developers are already familiar with reducer functions. However, let’s review them briefly in simple terms.
Reducers help us modify states within specific action types more elegantly and clean up our code if we need to manage complex states within these types.
For example, let’s consider a simple to-do application. Without reducers, we would use useState
for its state management. It would have some functions such as addTodoItem
, removeTodoItem
, and markComplete
.
Let’s see this in the code:
import { useState } from 'react';
import cn from 'classnames';
interface TodoItemProps {
title: string;
isComplete: boolean;
}
function TodoExample() {
const [todos, setTodos] = useState<TodoItemProps[]>([]);
const [inputText, setInputText] = useState<string>('');
const addTodoItem = (title: string) => {
setTodos((todo) => [...todo, { title, isComplete: false }]);
};
const removeTodoItem = (id: number) => {
setTodos((todo) => todo.filter((_, ind) => ind !== id));
};
const markComplete = (id: number) => {
setTodos((todo) => {
const newTodo = [...todo];
newTodo[id].isComplete = true;
return newTodo;
});
};
return (
<div className="max-w-[400px] w-full mx-auto my-10">
<div className="flex items-center gap-4 mb-4">
<input
placeholder="Enter a new todo item"
className="border rounded-md w-full p-1"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<button
className="bg-gray-200 w-[200px] py-1 rounded-md"
onClick={() => {
addTodoItem(inputText);
setInputText('');
}}
>
Add Todo
</button>
</div>
<div className="space-y-4">
{todos.map((item, ind) => (
<div key={ind} className="flex items-center gap-4">
<h3
className={cn(
item.isComplete
? 'line-through text-gray-600'
: 'text-gray-900',
'text-sm flex-1'
)}
>
{item.title}
</h3>
<button
className="bg-gray-200 px-2 py-1 rounded-md"
onClick={() => markComplete(ind)}
>
Mark Complete
</button>
<button
className="bg-gray-200 px-2 py-1 rounded-md"
onClick={() => removeTodoItem(ind)}
>
Remove
</button>
</div>
))}
</div>
</div>
);
}
export default TodoExample;
We can move the todo
logic in a custom Hook to make the code a bit cleaner. Let’s create a new useTodo
Hook and move our logic here:
import { useState } from 'react';
export interface TodoItemProps {
title: string;
isComplete: boolean;
}
export const useTodo = () => {
const [todos, setTodos] = useState<TodoItemProps[]>([]);
const addTodoItem = (title: string) => {
setTodos((todo) => [...todo, { title, isComplete: false }]);
};
const removeTodoItem = (id: number) => {
setTodos((todo) => todo.filter((_, ind) => ind !== id));
};
const markComplete = (id: number) => {
setTodos((todo) => {
const newTodo = [...todo];
newTodo[id].isComplete = true;
return newTodo;
});
};
return { todos, addTodoItem, removeTodoItem, markComplete };
};
Then, we can modify our component, as shown below. This would help make the code a lot cleaner:
import { useState } from 'react';
import cn from 'classnames';
import { useTodo } from './hooks/useTodo';
function TodoExample() {
const { addTodoItem, markComplete, removeTodoItem, todos } = useTodo();
const [inputText, setInputText] = useState<string>('');
return (
<div className="max-w-[400px] w-full mx-auto my-10">
<div className="flex items-center gap-4 mb-4">
<input
placeholder="Enter a new todo item"
className="border rounded-md w-full p-1"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<button
className="bg-gray-200 w-[200px] py-1 rounded-md"
onClick={() => {
addTodoItem(inputText);
setInputText('');
}}
>
Add Todo
</button>
</div>
<div className="space-y-4">
{todos.map((item, ind) => (
<div key={ind} className="flex items-center gap-4">
<h3
className={cn(
item.isComplete
? 'line-through text-gray-600'
: 'text-gray-900',
'text-sm flex-1'
)}
>
{item.title}
</h3>
<button
className="bg-gray-200 px-2 py-1 rounded-md"
onClick={() => markComplete(ind)}
>
Mark Complete
</button>
<button
className="bg-gray-200 px-2 py-1 rounded-md"
onClick={() => removeTodoItem(ind)}
>
Remove
</button>
</div>
))}
</div>
</div>
);
}
export default TodoExample;
This example is okay for our application. The useState
Hook is handy for managing simple or individual states.
However, if our objects become more complex, then managing these objects or arrays becomes much more challenging and messy with the useState
Hook. In this case, we can use the useReducer
Hook.
Let’s modify our useTodo
Hook to work with the useReducer
Hook:
import { useReducer } from 'react';
export interface TodoItemProps {
title: string;
isComplete: boolean;
}
enum TodoActionType {
ADD_ITEM = 'add-item',
REMOVE_ITEM = 'remove-item',
MARK_ITEM_COMPLETE = 'mark-complete',
}
const reducerFn = (
state: { todos: TodoItemProps[] },
action: { type: TodoActionType; payload: string | number }
): { todos: TodoItemProps[] } => {
const { payload, type } = action;
switch (type) {
case TodoActionType.ADD_ITEM: {
return {
...state,
todos: [
...state.todos,
{ title: payload.toString(), isComplete: false },
],
};
}
case TodoActionType.REMOVE_ITEM: {
return {
...state,
todos: [...state.todos.filter((_, ind) => ind !== payload)],
};
}
case TodoActionType.MARK_ITEM_COMPLETE: {
return {
...state,
todos: state.todos.map((todo, ind) =>
ind === payload ? { ...todo, completed: true } : todo
),
};
}
default: {
return state;
}
}
};
export const useTodo = () => {
const [state, dispatch] = useReducer(reducerFn, {
todos: [],
});
const addTodoItem = (title: string) => {
dispatch({ type: TodoActionType.ADD_ITEM, payload: title });
};
const removeTodoItem = (id: number) => {
dispatch({ type: TodoActionType.REMOVE_ITEM, payload: id });
};
const markComplete = (id: number) => {
dispatch({ type: TodoActionType.MARK_ITEM_COMPLETE, payload: id });
};
return { todos: state.todos, addTodoItem, removeTodoItem, markComplete };
};
Although not ideal in our example, useReducer
can be helpful for extensive updates across multiple states. Here, we can easily update our states using some predefined action type using the dispatch
method, and the core business logic is handled inside the reducer function.
Overview of Immer
Immer is a lightweight package that simplifies working with immutable states. Immutable data structures ensure efficient data change detection, making it easier to track modifications. Additionally, they enable cost-effective cloning by sharing unchanged parts of a data tree in memory.
Let’s look into our to-do example and see how we can use Immer in our example:
import { useImmer } from 'use-immer';
export interface TodoItemProps {
title: string;
isComplete: boolean;
}
export const useTodo = () => {
const [todos, updateTodos] = useImmer<TodoItemProps[]>([]);
const addTodoItem = (title: string) => {
updateTodos((draft) => {
draft.push({ title, isComplete: false });
});
};
const removeTodoItem = (id: number) => {
updateTodos((draft) => {
draft.splice(id, 1);
});
};
const markComplete = (id: number) => {
updateTodos((draft) => {
draft[id].isComplete = true;
});
};
return { todos, addTodoItem, removeTodoItem, markComplete };
};
In this case, we can modify our immutable to-do items with Immer. Immer first gets the base state, makes a draft state, allows the modification of the draft state, and then returns the modified state, allowing the original state to be immutable.
In this example, we used the use-immer
Hook, which can be added by running npm run immer use-immer
.
Overview of Mutative
The implementation of Mutative is almost similar to Immer, but more robust. Mutative processes data with better performance than both Immer and native reducers.
According to the Mutative team, this state management tool helps make immutable updates more efficient. It’s reportedly between two to six times faster than a naive handcrafted reducer and more than 10 times faster than Immer. This is because it:
- Has additional features like custom shallow copy (support for more types of immutable data)
- Allows no freezing of immutable data by default
- Allows non-invasive marking for immutable and mutable data
- Supports safer mutable data access in strict mode
- Also supports reducer functions and any other immutable state library
Let’s look into our example with Mutative below:
import { useMutative } from 'use-mutative';
export interface TodoItemProps {
title: string;
isComplete: boolean;
}
export const useTodo = () => {
const [todos, setTodos] = useMutative<TodoItemProps[]>([]);
const addTodoItem = (title: string) => {
setTodos((draft) => {
draft.push({ title, isComplete: false });
});
};
const removeTodoItem = (id: number) => {
setTodos((draft) => {
draft.splice(id, 1);
});
};
const markComplete = (id: number) => {
setTodos((draft) => {
draft[id].isComplete = true;
});
};
return { todos, addTodoItem, removeTodoItem, markComplete };
};
We used the use-mutative
Hook in this example, which can be added by running the npm run mutative use-mutative
command.
Comparing Mutative vs. Immer vs. reducers
Based on the discussions above, here is a comparison table among the Mutative, Immer, and Reducers:
Reducer | Immer | Mutative | |
---|---|---|---|
Concept | A pure function that takes the current state and an action object as arguments, and returns a new state object | A library that provides a simpler way to create immutable updates by allowing you to write mutations as if the data were mutable | A JavaScript library for efficient immutable updates |
Usage | Reducers are typically used with Redux, a state management library for JavaScript applications | Immer simplifies writing immutable updates by allowing you to write mutations as if the data were mutable. Under the hood, Immer creates a copy of the data and then applies the mutations to the copy | Mutative provides an API for creating immutable updates. It is similar to Immer in that it allows you to write mutations as if the data were mutable, but it claims to be more performant |
Immutability | Reducers are inherently immutable, as they must return a new state object | Immer creates immutable updates by copying the data and then applying the mutations to the copy | Mutative also creates immutable updates |
Performance | The performance of reducers can vary depending on the complexity of the reducer function and the size of the state object | Immer can have some performance overhead due to the need to copy the data before applying mutations | Mutative claims to be more performant than both Reducers and Immer |
Learning curve | Reducers are a relatively simple concept to understand | Immer can be easier to learn than reducers for developers who are not familiar with functional programming | Mutative's API is similar to Immer's, so the learning curve should be similar |
Community support | Reducers are a fundamental concept in Redux, which is a popular state management library. As a result, there is a large community of developers who are familiar with reducers | Immer is a relatively new library, but it has gained some popularity in recent years | Mutative is a newer library than Immer, so the community support is still growing |
Although the primary functionality is the same for all of these state management solutions, there are significant differences in their performance measurements.
Mutative has a benchmark testing implementation to compare Reducer, Immer, and Mutative performance. To run the benchmark, first, clone the mutative repository from GitHub by running the following command:
git clone git@github.com:unadlib/mutative.git
Then install the dependencies and run the benchmark using the following command:
yarn install && yarn benchmark
You will get the following diagrams by running the benchmark. You can get these diagrams in the base directory of the mutative repo. First, you’ll see a performance report showing Mutative outperforming Immer: Then, you should see a Mutative vs. reducers performance comparison for an array, showing that the more items are in the array, the more time reducers require, while Mutative’s time requirement only increases slightly: The next diagram compares the performance of Mutative and reducers for an object. Here, you can see that the more items an object has, the more time is required by both the reducers and Mutative. However, the amount of time needed is far lower for Mutative than reducers as the number of items in the object increases: Finally, you’ll see a diagram comparing Mutative and Immer’s performance for a class. Similar to the previous comparisons, we can see that as the number of keys increases for a class, the execution time increases more drastically for Immer compared to Mutative, which shows that Mutative is a great choice for performance optimization: From the metrics above, we can observe that Mutative is more powerful in performance, especially when handling large data volumes. On the other hand, reducers and Immer cannot handle large amounts of data, unlike mutations, especially arrays and classes.
Overall, in our benchmark test, Mutative places first, reducers place second, and Immer gets the last position.
Conclusion
Most developers use reducers, as they come built with React. But Mutative is a better option for handling large amounts of data in a React application.
You can check out the example shown above is in this SlackBlitz repo. If you have any further questions, you can comment below — and if you explore Mutative, feel free to share your thoughts as well.
Get set up with LogRocket's modern React error tracking in minutes:
- Visit https://logrocket.com/signup/ to get an app ID.
- Install LogRocket via NPM or script tag.
LogRocket.init()
must be called client-side, not server-side.
NPM:
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
Script Tag:
Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
3.(Optional) Install plugins for deeper integrations with your stack:
- Redux middleware
- ngrx middleware
- Vuex plugin
Top comments (0)