JS immutability
Shared mutable objects are where "something changed it and I do not know what" bugs live, and where reactive frameworks silently fail to update. Immutability, applied where it matters, makes state changes explicit and traceable. The cost is copying, so apply it deliberately.
Method
- Update by producing new values, not mutating. Spread for shallow
copies (
{ ...obj, field: next },[...arr, item]), and the non-mutating array methods (map,filter,concat,slice,toSorted/toReversed/with) over the mutating ones (push,splice,sort, which change in place). Asort()on a prop you did not own is a classic shared-state bug. - Copy at the depth you actually change. Spread is shallow: nested
objects are still shared references, so mutating a nested field of a
"copy" mutates the original too. Update the path you change
(
{ ...s, user: { ...s.user, name } }) or use a structural-sharing helper (immer'sproducewrites mutable-looking code that yields an immutable result) for deep updates without deep-copy cost. - Encode intent in the type.
readonlyproperties,ReadonlyArray, andReadonly<T>make "do not mutate this" a compile error, not a convention (see ts-api-types). Acceptreadonlyinputs on functions that only read; return frozen or readonly data from getters so callers cannot corrupt your state. - Reserve
Object.freezefor enforcement where it pays. Freeze catches accidental mutation at runtime (throwing in strict mode), but it is shallow and has a cost; use it on config, constants, and development-mode state guards, not on every object in a hot path (see performance-optimization). - Immutability is what makes reactivity and undo work. Frameworks detect change by reference equality; mutating in place leaves the reference the same and the UI stale (see frontend-state). Immutable updates give you cheap change detection, time-travel/undo, and safe sharing across concurrent async work (the old response cannot mutate the new state).
- Do not fight it where mutation is local and clear. Building an
array in a tight loop with
pushbefore returning it, or mutating a freshly-created local object, is fine and faster; the discipline is about SHARED and reactive state, not a ban on all mutation.
Boundaries
- Immutability trades allocation for safety; in hot paths over large data the copy cost is real, so measure and localize mutation there (see js-event-loop on not blocking the thread).
constprevents reassignment, not mutation:const obj = {}still letsobj.x = 1. Immutability is about the value,constis about the binding.- Deep-freezing or deep-cloning everything is over-application; target the state that is shared, reactive, or crosses async boundaries.