State Updates Without the Mutation Headache

React developers quickly learn the rule: never mutate state directly. That principle exists for a practical reason. When objects and arrays are passed by reference, an accidental change anywhere in the code silently alters the original state, producing bugs that are hard to trace.

Immer is a small library from Michel Weststrate designed to make working with immutable state more convenient. Instead of requiring you to manually copy every level of a nested structure before updating it, Immer lets you write what looks like normal mutation against a draft copy. When you’re done, it produces a fully immutable next state.

Mutable vs. Immutable Values in JavaScript

The ECMAScript specification defines several built-in types. Six are primitives: undefined, number, string, boolean, bigint, and symbol. A primitive has no methods and its value cannot change once created — it is immutable.

console.log(typeof 5) // number
console.log(typeof 'name') // string
console.log(typeof (1 < 2)) // boolean
console.log(typeof undefined) // undefined
console.log(typeof Symbol('js')) // symbol
console.log(typeof BigInt(900719925474)) // bigint

The remaining types — null, object, and function — behave differently.

console.log(typeof null) // object
console.log(typeof [0, 1]) // object
console.log(typeof {name: 'name'}) // object
const f = () => ({})
console.log(typeof f) // function

These are mutable, meaning their contents can change at any time. Arrays are objects under the hood, which is why they appear in that list. As for the distinction between null and undefined: undefined signals that no value has been assigned, while null is used when something is expected to be an object but isn’t there. For instance, String.prototype.match returns an array (an object) when a match is found, but null when nothing matches.

To see the difference in practice, consider how primitives behave when assigned from one variable to another:

let a = 5;
let b = a
console.log(`a: ${a}; b: ${b}`) // a: 5; b: 5
b = 7
console.log(`a: ${a}; b: ${b}`) // a: 5; b: 7

Here, changing b does not affect a. When the engine executes b = a, it creates a separate memory location with the value and points b at it.

Objects tell a different story:

let c = { name: 'some name'}
let d = c;
console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"some name"}; d: {"name":"some name"}
d.name = 'new name'
console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"new name"}; d: {"name":"new name"}

Updating the name property through d also changes it on c. Both variables reference the same object in memory. In a React application, this shared-reference behavior is dangerous. If one part of the code mutates an object that another part reads for display, the UI can update in unpredictable ways. Primitives avoid this, but they are far too limited to represent the shape of real application state.

Understanding Immer’s produce

Immer’s core API is minimal. The central function is produce, which accepts an initial state and a callback. That callback receives a draft — a copy of the state that you can modify freely. When the callback finishes, Immer generates a new immutable state with your changes applied. Your original state remains untouched.

The general usage pattern looks like this:

// produce signature
produce(state, callback) => nextState

A concrete example shows how easy this is:

import produce from 'immer'

const initState = {
  pets: ['dog', 'cat'],
  packages: [
    { name: 'react', installed: true },
    { name: 'redux', installed: true },
  ],
}

// to add a new package
const newPackage = { name: 'immer', installed: false }

const nextState = produce(initState, draft => {
  draft.packages.push(newPackage)
})

Notice what happens with the pets array. Because you didn’t touch it, Immer keeps a structural share of it between the old state and the new one. The produced nextState is an immutable tree that references the unchanged parts rather than copying them.

Building Reducers With produce

Consider a state object representing a todo item:

const initState = {
  pets: ['dog', 'cat'],
  packages: [
    { name: 'react', installed: true },
    { name: 'redux', installed: true },
  ],
};

You want to add a new todo and then mark it as installed:

const newPackage = { name: 'immer', installed: false };

Using traditional spread syntax, the reducer becomes verbose and prone to errors because you must carefully copy every untouched level:

const updateReducer = (state = initState, action) => {
  switch (action.type) {
    case 'ADD_PACKAGE':
      return {
        ...state,
        packages: [...state.packages, action.package],
      };
    case 'UPDATE_INSTALLED':
      return {
        ...state,
        packages: state.packages.map(pack =>
          pack.name === action.name
            ? { ...pack, installed: action.installed }
            : pack
        ),
      };
    default:
      return state;
  }
};

Rewriting that reducer with Immer removes the need for spreading:

const updateReducerWithProduce = (state = initState, action) =>
  produce(state, draft => {
    switch (action.type) {
    case 'ADD_PACKAGE':
      draft.packages.push(action.package);
      break;
    case 'UPDATE_INSTALLED': {
      const package = draft.packages.filter(p => p.name === action.name)[0];
      if (package) package.installed = action.installed;
      break;
    }
    default:
      break;
    }
  });

The callback only deals with the specific change it cares about. In the UPDATE_INSTALLED case, if it can’t find the todo by id, it simply does nothing. In the default case, Immer returns the draft as-is without requiring you to write an explicit return. There is less boilerplate, and the chances of accidentally mutating the original state drop to zero.

The Curried Form

produce also supports currying. When the first argument is a function, the call returns a second-order function that only needs a state to operate on:

//curried produce signature
produce(callback) => (state) => nextState

A curried version of the earlier reducer looks like this:

const curriedProduce = produce((draft, action) => {
  switch (action.type) {
  case 'ADD_PACKAGE':
    draft.packages.push(action.package);
    break;
  case 'SET_INSTALLED': {
    const package = draft.packages.filter(p => p.name === action.name)[0];
    if (package) package.installed = action.installed;
    break;
  }
  default:
    break;
  }
});

To invoke it, pass the starting state and the action object:

// add a new package to the starting state
const nextState = curriedProduce(initState, {
  type: 'ADD_PACKAGE',
  package: newPackage,
});

// update an item in the recently produced state
const nextState2 = curriedProduce(nextState, {
  type: 'SET_INSTALLED',
  name: 'immer',
  installed: true,
});

When you use React’s useReducer, React supplies the state automatically, so you only need to provide the action.

Hooks: useImmer and useImmerReducer

Immer also ships hooks. The useImmer hook works like useState: it returns a tuple containing the current state and an updater function. That updater accepts an Immer producer, in which you mutate the draft freely until the producer ends. The changes become the next immutable state.

These hooks are published separately and must be installed alongside the main library:

yarn add immer use-immer

Declaring state with useImmer is straightforward:

import React from "react";
import { useImmer } from "use-immer";

const initState = {}
const [ data, updateData ] = useImmer(initState)

Calling the updater follows the producer pattern:

// make changes to data
updateData(draft => {
  // modify the draft as much as you want.
})

The useImmerReducer hook mirrors React’s useReducer signature. The reducer you pass receives a mutable draft rather than a read-only state:

import React from "react";
import { useImmerReducer } from "use-immer";

const initState = {}
const reducer = (draft, action) => {
  switch(action.type) {      
    default:
      break;
  }
}

const [data, dataDispatch] = useImmerReducer(reducer, initState);

Inside that reducer, you freely write mutations to the draft, and Immer handles immutability for you.

Why Choose Immer for Reducers

Beyond reducing boilerplate in state logic, Immer addresses a few recurring pain points that other approaches have.

  • Deep updates stay shallow. Reducers only need to specify what changes; they never spread the entire state object just to reach a nested field.
  • No separate API to learn. Unlike Immutable.js, which introduces custom collections and collection methods, Immer works with plain JavaScript objects, arrays, sets, and maps.
  • Structural sharing is automatic. Since Immer only copies what you modify, unchanged parts of the state tree are shared by reference between the old and new states.
  • Produced states are automatically frozen. Any attempt to mutate the result from produce throws an error. (Applying .sort() directly to an array from Immer, for example, will fail — you need to call .slice() first for a mutable copy.)
  • Lightweight and typed. Immer is small — around 3KB when gzipped — with strong TypeScript support.

Why Immer Should Be a Default Choice

Immer has become a go-to utility for state management for good reason: it is lightweight, and it lets developers keep applying familiar JavaScript semantics instead of learning a new API for immutability. In an existing codebase, you can introduce Immer progressively, updating one reducer at a time without forcing a project-wide rewrite. That makes it possible to clean up state-update logic without blocking feature work or requiring a dedicated migration.

The library’s core promise is straightforward. Instead of dealing with verbose spread operators or complex cloning patterns, you can write code that “mutates” a draft while Immer handles the immutable update behind the scenes. This small shift in approach reduces boilerplate and helps human reviewers read a reducer as a description of the change, not a set of copying instructions.

Under the Hood

If you want to understand how Immer delivers this behavior, the introductory article by Michael Weststrate is worth reading. Immer relies on modern JavaScript features like Proxy and the computer-science concept of copy-on-write to produce a new immutable state from the draft mutations. Those mechanisms may seem daunting, but for the daily usage of a reducer, the developer is largely shielded from that complexity.

There is also value in pushing further into the surrounding debate about immutability itself. Steven de Salas presents a contrarian take on the merits of pursuing strict immutability in his article “Immutability in JavaScript: A Contrarian View.” His points are useful context for deciding where and when to enforce immutability, versus where a simpler mutable approach may be acceptable. Pairing Immer’s pragmatic approach with that kind of discussion gives you a better starting point for making informed project decisions.

Getting Started in Practice

Given the low entry barrier, the best next step is to install Immer and apply it to a reducer you already know. The effect is tangible right away when you convert a nested state update with multiple spreads into a more direct set of assignments inside a draft. Incremental adoption means nothing in your current architecture has to change overnight; the existing immutable code paths can coexist as you convert modules one by one.

For teams that want to keep a familiar hook-based API, the use-immer package provides integration for React. For quick answers on JavaScript semantics, MDN documents on function and proxy can fill any gaps, and Ecma International’s specification is the authoritative reference for understanding the underlying data types and values.