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.
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 referenceThe 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.
"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)); // trueconst 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:
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
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
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.
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.
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
// 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) andReact.memoonly 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.freezeadds a tiny per-write check. Generally negligible.Writing immutable code by hand is more keystrokes — the trade-off is bugs you never have.