ESC

Type to search the knowledge base.

Immutability Patterns in JS

Update state without mutating — spread paths, arrays, structural sharing ideas, freeze, and libraries when nested updates hurt.

intermediate3 min read
  • javascript
  • immutability
  • state
  • patterns

JS doesn’t enforce immutability. Patterns do: treat values as read-only, produce new values on change. That makes React-style change detection reliable, undo stacks trivial, and race-y shared mutations rarer. You rarely deep-clone the world — you copy the path you edit.

Why bother

// mutation — same reference, UI may skip update
state.items.push(item);
setState(state);

// immutable update — new reference
setState({ ...state, items: [...state.items, item] });

Equality checks (===, Object.is, React memo) need new references for changed data.

Object updates

const user = { id: 1, profile: { bio: 'hi', city: 'Goa' }, tags: ['js'] };

// change top-level
const u1 = { ...user, active: true };

// change nested profile.bio — copy each level
const u2 = {
  ...user,
  profile: { ...user.profile, bio: 'hello' },
};

// remove a key
const { tags, ...withoutTags } = user;

Array updates

const items = ['a', 'b', 'c'];

// append / prepend
[...items, 'd'];
['z', ...items];

// insert at i
const i = 1;
[...items.slice(0, i), 'x', ...items.slice(i)];

// update at i
items.map((item, idx) => (idx === i ? 'B' : item));

// remove at i
items.filter((_, idx) => idx !== i);

// modern copy methods
items.toSorted();
items.toReversed();
items.toSpliced(1, 1, 'x');
items.with(1, 'B');

Avoid push, splice, sort on shared state arrays unless you own a fresh copy.

Nested lists of objects

function updateTodo(todos, id, patch) {
  return todos.map((t) => (t.id === id ? { ...t, ...patch } : t));
}

freeze (shallow)

const cfg = Object.freeze({ api: '/v1', opts: { retry: 1 } });
// cfg.api = '/v2' // throws in strict
cfg.opts.retry = 2; // still mutates nested — shallow freeze

deepFreeze recursively freezes for tests/dev; costly in hot paths.

Structural sharing (concept)

Immutable updates reuse unchanged branches:

user ── profile ── bio (new)
  │         └── city (shared)
  └── tags (shared)

Libraries (Immer, Immutable.js) automate this. Immer lets you write mutating syntax against a draft:

// conceptual Immer
const next = produce(user, (draft) => {
  draft.profile.bio = 'hello';
});

When mutation is fine

  • Local arrays you just created and never shared
  • Performance-critical tight loops building a result you’ll freeze once
  • Accumulating inside reduce before returning
const out = [];
for (const x of input) if (pred(x)) out.push(fn(x));
return out; // new array, fine

Interview answer

“I treat state as immutable: copy objects with spread and arrays with map/filter/slice or toSorted/with. Nested updates copy each level along the path. Object.freeze is shallow. For deep trees I use Immer or careful path copies, not JSON deep clones. Mutation is OK only for owned local builds.”

Equality helpers

// shallow compare props style
function shallowEqual(a, b) {
  if (Object.is(a, b)) return true;
  if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
    return false;
  }
  const ka = Object.keys(a);
  const kb = Object.keys(b);
  if (ka.length !== kb.length) return false;
  return ka.every((k) => Object.is(a[k], b[k]));
}

Immutable updates make shallow equality useful for memoization. If you mutate in place, shallow compare lies and you chase “stuck UI” bugs. Pair immutability with Object.is (not ==) so NaN and -0 behave consistently.

Further reading

Related guides