Mastering State Management with Zustand and Immer: A Guide to Efficient State Updates

DushyanthDushyanth
5 min read

State management is a crucial part of any modern JavaScript application. Whether you're building a simple app or a complex one, keeping track of state and efficiently updating it is a top priority. This is where libraries like Zustand and Immer come in. When combined, they offer a powerful solution for managing state in React (and other frameworks) while keeping your code clean and concise.

In this post, we’ll dive into how to use Zustand in combination with Immer to handle state updates, especially when working with deeply nested state or immutable updates.

What is Zustand?

Zustand is a small, fast state management library for React. It is very lightweight and has a minimalistic API, making it a great choice for small to medium-sized applications. Zustand makes state management simple by allowing you to create stores (state containers) that can be accessed and updated easily across your components.

What is Immer?

Immer is a library that helps with immutable state updates. In JavaScript, state updates often require creating a new copy of the state object to avoid mutating it directly. This can be tedious, especially when the state is deeply nested. Immer allows you to mutate the state directly (just like working with mutable objects), while ensuring that the changes are applied immutably behind the scenes.

The Power of Zustand + Immer

When combined, Zustand and Immer offer an elegant solution for managing complex state. Immer’s ability to handle immutable state and Zustand’s simplicity make for a great pairing, especially when working with deeply nested objects or arrays.

Getting Started with Zustand + Immer

Before we dive into best practices, let's set up Zustand with Immer. Here’s how you can create a Zustand store using Immer:

import create from 'zustand';
import { immer } from 'zustand/middleware/immer';

const useStore = create(
  immer((set) => ({
    count: 0,
    increment: () => {
      set((state) => {
        state.count++;  // Direct mutation of the draft state
      });
    },
  }))
);

In this simple store, we have a count state, and we use the increment method to update it. Notice how we mutate state.count directly inside the set() function. Thanks to Immer, the state update is immutable, and we don’t need to return a new object.


Directly Mutating Nested Objects in Immer

One of the most powerful features of Immer is its ability to handle deeply nested objects without requiring us to manually copy or spread them. Let’s take an example where we need to update a nested property in an array of objects.

Scenario: Updating a Set in a Workout

Imagine we have a workout app with workouts, and each workout has a series of sets. We want to update the reps and weight of a specific exercise set.

Here’s an example of how you might use Zustand with Immer to update a nested ExerciseSet:

const useStore = create(
  immer((set) => ({
    currentWorkout: {
      exercises: [
        { id: 1, sets: [{ id: 1, reps: 10, weight: 100 }] },
        { id: 2, sets: [{ id: 2, reps: 8, weight: 120 }] },
      ],
    },
    updateSet: (setId, updatedFields) => {
      set(({ currentWorkout }) => {
        const setToUpdate = currentWorkout.exercises
          .flatMap((e) => e.sets)
          .find((s) => s.id === setId);

        if (!setToUpdate) {
          return;
        }

        //Direct mutation: update the set's properties
        if (updatedFields.reps !== undefined) {
          setToUpdate.reps = updatedFields.reps;
        }

        if (updatedFields.weight !== undefined) {
          setToUpdate.weight = updatedFields.weight;
        }
      });
    },
  }))
);

Why Direct Mutation Works with Immer

Here’s the key idea: Immer provides a mutable draft of the state that you can directly modify. Normally, in state management libraries like Redux or Zustand, state is immutable, and any mutation requires creating a new copy of the object (to preserve immutability). However, with Immer, you can mutate the draft directly, and Immer will handle the creation of the new immutable state for you behind the scenes.

Key Points:

  1. Mutability of the draft: Inside set(), you can mutate the draft state directly.

  2. Automatic immutability: Immer ensures that the state remains immutable, even though you mutate it directly.

  3. No need to return a new state: Unlike traditional state management, you don’t need to manually return a new state object after mutation. Immer takes care of that for you.


Avoid Reassigning State in Immer

A common mistake when working with Immer is reassigning state or nested objects within the draft, which can break Immer's internal mechanism for handling updates. Let’s look at a typical error you might see:

Incorrect Code (Reassigning the Draft):

updateSet: (setId, updatedFields) => {
  set(({ currentWorkout }) => {
    const setToUpdate = currentWorkout.exercises
      .flatMap((e) => e.sets)
      .find((s) => s.id === setId);

    if (!setToUpdate) {
      return;
    }

    // Incorrect! Reassigning the draft will break Immer's state tracking
    setToUpdate = updateSet(current(setToUpdate), updatedFields);
  });
};

Correct Approach:

Instead of reassigning the draft object, mutate the properties directly:

updateSet: (setId, updatedFields) => {
  set(({ currentWorkout }) => {
    const setToUpdate = currentWorkout.exercises
      .flatMap((e) => e.sets)
      .find((s) => s.id === setId);

    if (!setToUpdate) {
      return;
    }

    // Correct! Mutate the properties directly on the draft
    if (updatedFields.reps !== undefined) {
      setToUpdate.reps = updatedFields.reps;
    }

    if (updatedFields.weight !== undefined) {
      setToUpdate.weight = updatedFields.weight;
    }
  });
};

Why This Works:

  • Direct mutation: We directly modify setToUpdate.reps and setToUpdate.weight on the draft, rather than assigning a new object to setToUpdate.

  • Immer manages the immutability: Immer tracks the mutation and ensures the new state is created immutably when needed.


Keep Aware of this pitfall!

  1. Mutate directly on the draft: You can modify the draft state directly. Avoid reassigning variables or returning a new state object manually.

Conclusion

By combining Zustand and Immer, we can simplify state management in our React apps while ensuring that the state remains immutable and easily updatable. Immer makes it easy to mutate deeply nested state directly without breaking immutability, while Zustand provides a simple, performant store to manage that state. Together, they provide a seamless experience for handling complex state updates efficiently.

Hopefully, this guide has given you a better understanding of how to use Zustand with Immer for managing state in your React applications. Happy coding! ✌️

0
Subscribe to my newsletter

Read articles from Dushyanth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dushyanth
Dushyanth

A Full Stack Developer with a knack for creating engaging web experiences. Currently tinkering with GO.