DEV Community

Cover image for Comparing React state tools: Mutative vs. Immer vs. reducers
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

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;
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then install the dependencies and run the benchmark using the following command:

yarn install && yarn benchmark
Enter fullscreen mode Exit fullscreen mode

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: Performance Report Showing Mutative Outperforming Immer And Reducers 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: Report Comparing Mutative Vs Reducers Performance Rendering An Array, Showing Considerable Time Increase For Reducers The More Items Are In An Array, But Only A Slight Increase For Mutative 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: Report Comparing Mutative Vs Reducers Performance Rendering Objects, Showing Considerable Time Increase For Reducers The More Object Items To Render But Only A Slight Increase For Mutative 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: Report Comparing Mutative Vs Immer Performance Rendering Class Keys, Showing Considerable Time Increase For Immer The More Class Keys Need To Be Rendered, But Only A Slight Increase For Mutative 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:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. 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');
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

Top comments (0)