JavaScriptImmutability

Immutability

An immutable value is one that cannot be changed after it is created. JavaScript primitives — numbers, strings, booleans — already behave this way. Objects and arrays, by default, do not. Treating them as if they were is a stylistic choice that pays off in a hundred small ways: fewer bugs, easier change detection in React, simpler reasoning about state.

Why mutability bites

Mutation creates action at a distance. A function that quietly modifies its argument can break callers that share that argument with anyone else.

JS
function appendItem(cart, item) {
  cart.push(item);    // MUTATES — caller's cart is changed
  return cart;
}

const myCart = ["apple"];
const newCart = appendItem(myCart, "bread");

console.log(myCart);   // ["apple", "bread"] — surprise
console.log(myCart === newCart);   // true — same reference

The caller probably expected myCart to be unchanged. The same bug shows up in React when reducer code mutates state — components don't re-render because the reference didn't change.

Object.freeze — shallow lockdown

Object.freeze prevents adding, removing or changing properties of an object. Attempts fail silently in non-strict mode and throw in strict mode.

JS
"use strict";

const config = Object.freeze({ host: "localhost", port: 8080 });

// config.port = 9000;     // TypeError in strict mode
// delete config.host;     // TypeError
// config.user = "ada";    // TypeError

console.log(Object.isFrozen(config));   // true
It's shallow
Nested objects are not frozen automatically.

JS
const settings = Object.freeze({
  user: { name: "Ada" },
});

settings.user.name = "Lin";   // succeeds — `user` is not frozen
console.log(settings.user.name);   // "Lin"

For deep freezing, recurse:

JS
function deepFreeze(obj) {
  for (const value of Object.values(obj)) {
    if (value && typeof value === "object") deepFreeze(value);
  }
  return Object.freeze(obj);
}
The spread idiom for "edit a copy"

The spread operator is the everyday tool for non-destructive updates. Make a copy, change the field, return the copy.

Object updates

JS
const user = { id: 1, name: "Ada", email: "ada@x" };

// Change one field.
const renamed = { ...user, name: "Ada Lovelace" };

// Add a field.
const withRole = { ...user, role: "admin" };

// Remove a field.
const { email, ...withoutEmail } = user;

Array updates

JS
const items = ["a", "b", "c"];

const appended = [...items, "d"];
const prepended = ["z", ...items];
const withoutB  = items.filter(x => x !== "b");
const swapped   = items.map((x, i) => i === 1 ? "B" : x);
The new read-only array methods

ES2023 added immutable versions of the mutating array methods. They return a new array and leave the original alone.

JS
const xs = [3, 1, 2];

const sorted   = xs.toSorted();
const reversed = xs.toReversed();
const spliced  = xs.toSpliced(0, 1);
const replaced = xs.with(0, 99);

console.log(xs);   // [3, 1, 2] — untouched
Deep updates without a library

Deeply nested updates are where the spread idiom starts to hurt. You spread at every level you touch.

JS
const state = {
  user: {
    address: { city: "Paris", zip: "75001" },
  },
};

const moved = {
  ...state,
  user: {
    ...state.user,
    address: { ...state.user.address, city: "Lyon" },
  },
};

Verbose for two levels, painful for five. That is what Immer was made for.

Immer — write mutating code, get immutable updates

Immer wraps a draft object with a Proxy. You write code that looks like mutation, and Immer produces a new value without changing the original.

With Immer — illustrative

JS
// import { produce } from "immer";

// const moved = produce(state, draft => {
//   draft.user.address.city = "Lyon";
// });

// state is unchanged; moved is a new value with the change applied.

Redux Toolkit ships Immer by default. Reducers feel imperative; the data flow stays immutable.

Why this matters in React
  • React decides whether to re-render by comparing references with ===. Mutating state keeps the reference, so re-render is skipped.

  • Memoized hooks (useMemo, useCallback) and React.memo only see new inputs if references change.

  • Time-travel debugging (Redux DevTools) needs each state to be a distinct value.

Costs to be aware of
  • Copying large structures allocates memory. For most app data this is invisible; for very large arrays, consider structural-sharing libraries (Immer, Immutable.js).

  • Object.freeze adds a tiny per-write check. Generally negligible.

  • Writing immutable code by hand is more keystrokes — the trade-off is bugs you never have.

The rule of thumb
Treat data you didn't create as read-only. Return a new value instead of mutating the old one. Reach for Immer once nested spreads get tedious. Most "weird state bug" stories in JavaScript end with someone mutating something they shouldn't have.
Tip
Freezing in development (`if (process.env.NODE_ENV !== "production") deepFreeze(state)`) catches accidental mutation early without paying the cost in production.