Advertisement

One of the most useful distinctions a developer can internalise is between mutable and immutable data. A mutable value can be changed in place; an immutable one cannot — any "change" produces a new value and leaves the original untouched. Which kind you are holding determines whether modifying data here quietly alters it somewhere else, and that is the seed of a whole category of baffling bugs.

The confusion usually surfaces the first time a list, array or object appears to change on its own — because a second variable was pointing at the same underlying data all along.

Sharing versus copying

When you assign a mutable value to a second variable, many languages copy the reference, not the data: both variables now point at the same object. Mutate it through one and the other sees the change, because there was only ever one object. This is efficient and often intended, but it surprises people who imagined they had made an independent copy.

Immutable values sidestep this entirely. Since they cannot be changed in place, sharing a reference is completely safe — nobody can alter what everybody is looking at. Strings and numbers are immutable in many languages precisely so passing them around never causes spooky action at a distance.

Advertisement

Where the bugs come from

The classic bug: a function receives a list, modifies it for its own purposes, and the caller's list silently changes too, because both referenced the same array. Or a "default" value that is a mutable object gets modified and mysteriously carries state between calls. These are not exotic edge cases; they are among the most common real-world defects, and they all trace back to modifying shared mutable state.

The tell is a value that changed when the code you were looking at never touched it. Nine times out of ten, something else holds a reference to the same mutable object and changed it.

Working with the grain

A few habits defuse most of these bugs. When you need an independent copy, make one deliberately — and know whether your language's copy is shallow (top level only) or deep (all the way down), because a shallow copy of a nested structure still shares its inner objects. Prefer immutable data where a language offers it, and prefer functions that return new values over ones that mutate their arguments.

The broader principle behind modern "functional" style is exactly this: reduce shared mutable state, and a large class of concurrency and side-effect bugs simply cannot occur. You do not have to go fully immutable — but knowing, at every moment, whether you can change a value in place is one of the quiet marks of a careful developer.

Advertisement

The shared-reference bug in its most common everyday form

The single most common real-world manifestation of this whole topic is passing a mutable object or array into a function, mutating it there, and being surprised that the caller's own copy changed too — except there never were two copies, only one object and two references to it, and mutating through either reference mutates the one underlying object both of them point to. This is not a bug in the language; it is exactly how reference semantics are supposed to work, and the actual bug is in the assumption, often made without noticing it was ever an assumption at all, that passing an object into a function somehow implicitly created an independent copy of it.

Defensive copying: the manual fix, and why it does not scale well

The traditional fix for unwanted shared mutation is defensive copying — explicitly cloning an object before handing it to code that might mutate it, so any changes land on the copy rather than the shared original — but this has to be applied consistently at every boundary where mutation could plausibly happen, and a single omitted copy anywhere in a codebase reintroduces the exact bug the discipline was meant to prevent. A shallow copy (`{...obj}` or `Object.assign`) only protects the top level, leaving nested objects still shared, which is exactly the same shallow-freeze gap discussed elsewhere in this cluster of articles, just showing up as a shallow-copy gap instead — a deep clone is needed whenever nested mutation is a real risk, and deep cloning has its own real performance cost worth being deliberate about rather than reaching for reflexively.

Advertisement

React's rule against mutating state directly, and why it exists

React specifically requires state updates to produce a new object or array rather than mutating the existing one in place — `setState({...state, field: newValue})` rather than `state.field = newValue` — and the reason is mechanical rather than stylistic: React decides whether to re-render by comparing the previous state reference to the new one, and if a component mutates its existing state object in place, the reference never actually changes, so React's comparison sees the 'same' object and may skip the re-render entirely, silently leaving the UI stuck showing stale data despite the underlying value having technically already changed.

Redux and the ecosystem built entirely around immutable updates

State management libraries like Redux take the React convention above and make it a hard architectural rule: every state change must produce an entirely new state tree rather than mutating the existing one, which is what makes features like time-travel debugging and precise change detection practical — a previous state snapshot genuinely is the old, unmutated object, not a reference to the same object that has since changed underneath it, which would be true if mutation were ever allowed. Libraries like Immer exist specifically to make this discipline ergonomic, letting code that looks like ordinary, direct mutation actually produce a new, structurally-shared immutable object underneath, closing the gap between how convenient mutation is to write and how necessary immutability is for these tools to function correctly.

Debugging a shared-mutation bug: the technique that actually finds it

A bug caused by unexpected shared mutation is notoriously hard to spot by reading code in isolation, because the mutating line and the surprised-to-see-it-changed line can be arbitrarily far apart in the codebase, sometimes in entirely different files or modules; the technique that reliably finds it is not more careful reading but tracing reference identity directly — logging or breakpointing on the specific object and checking, at each point it is touched, whether it is literally the same object (via `===` or an equivalent identity check) rather than merely one that looks structurally similar, which immediately reveals whether two variables that were assumed to be independent copies are in fact the same underlying reference.

Immutability as documentation: what a frozen value communicates to a reader

Beyond the concurrency and change-detection benefits already discussed, marking a value as immutable — through a language feature, a naming convention, or simply consistent team practice — communicates something directly to the next person reading the code: this value will not change after creation, so its state at any later point in the program can be reasoned about just by looking at where it was created, with no need to trace every place it might have been touched afterward. A codebase that treats mutability as the unremarkable default, rather than the deliberate exception, forces every reader to do that reachability analysis by hand for every single value, every time, which is a real and recurring cognitive cost that immutable-by-default design quietly removes.

Equality checks: why immutable values change what '==' should even mean

Mutable objects are conventionally compared by reference — two objects are 'equal' only if they are literally the same object in memory — because comparing their contents would be misleading the moment either one could still change afterward. Immutable values invite a different, and often more useful, convention: value equality, where two separately created immutable objects with identical contents are considered equal, since neither one can ever diverge from the other afterward, which is exactly the equality semantics languages with first-class immutable records or value types, mentioned earlier in this cluster of articles, are specifically designed to provide by default.

Freezing at API boundaries: protecting callers from library-side mutation

A library or module that returns an internal array or object directly, without freezing or copying it, silently hands calling code the ability to mutate the library's own internal state from outside — a common and hard-to-trace bug where a caller innocently mutates what it assumed was its own independent copy and inadvertently corrupts the library's internal data for every subsequent caller. Freezing objects at exactly this kind of API boundary, before returning them to external code, is a narrow, targeted application of immutability that protects a library's own invariants regardless of what any individual caller does with the reference it receives.

The empty-array trap: identity versus emptiness

A recurring, specific instance of the shared-reference bug: returning the same literal empty array or object from a function on every call, as a supposedly harmless default, and having a caller mutate that returned value assuming it is freshly created each time — every subsequent call then returns the same, now-mutated array rather than a fresh empty one, because it always was the exact same object being handed out repeatedly. The fix, creating a new empty array or object on each call rather than reusing one shared literal as a default, is trivial once the underlying reference-sharing cause is recognized, but the symptom without that recognition can look bewildering, since the bug depends entirely on which specific caller happened to mutate the shared default first.

Why array methods split cleanly into mutating and non-mutating camps

JavaScript's own array methods are a genuine, everyday minefield here because near-identical-sounding methods split unevenly between mutating the original array and returning a new one: `push`, `pop`, `sort`, and `splice` all mutate the array in place, while `map`, `filter`, and `slice` return an entirely new array and leave the original untouched — `sort` in particular catches people off guard because it looks, superficially, exactly like the kind of transformation `map` performs, and yet the two behave in opposite ways with respect to the original array, which is exactly the kind of naming-does-not-signal-behavior trap this whole topic keeps producing in practice.

Testing for accidental mutation directly, rather than hoping to spot it by eye

A function suspected of mutating an argument it should only be reading can be verified directly in a test by deep-cloning the input before calling it and asserting the original clone still deep-equals the input reference afterward — a small, mechanical check that catches accidental mutation immediately and explicitly, rather than relying on a reviewer noticing an in-place array method buried inside an otherwise unremarkable function during code review.

Why immutable updates on deeply nested state get verbose fast

Updating one deeply nested field immutably in plain JavaScript requires spreading every level of the object between the root and that field, which grows uglier and more error-prone the deeper the nesting goes — exactly the ergonomic pain that libraries like Immer exist to remove, letting code write what looks like a direct, deep mutation while actually producing a correctly, immutably updated new object underneath, closing the gap between how convenient shallow mutation is to write and how necessary immutable updates are for the state-management patterns discussed earlier.