Comparing React state tools: Mutative vs. Immer vs. reducers

Comparing React state tools: Mutative vs. Immer vs. reducers

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 useTodoHook 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

Get started now

Leave a Reply

Your email address will not be published. Required fields are marked *