Using Immutable Data Structures Correctly
Mutable state is the most common source of hard-to-find bugs in JavaScript applications. Immutability patterns make state changes explicit, enable change detection through reference comparison, and prevent shared state from being modified in unexpected places, from Object.freeze all the way to Records and Tuples.
Table of Contents
- 1. Why mutable state produces bugs
- 2. Object.freeze: shallow vs. deep immutability
- 3. Spread operator and Object.assign as immutability tools
- 4. Immutable array operations without mutation
- 5. Structural sharing: efficiency without copying
- 6. Immer.js: write mutable, keep immutable
- 7. Reference equality and change detection
- 8. Records and Tuples: native immutability (Stage 2)
- 9. Immutability strategies compared
- 10. Summary
- 11. FAQ
1. Why mutable state produces bugs
JavaScript objects and arrays are passed by reference. When you pass an object to a function, the function does not receive a copy, it receives a pointer to the same object in memory. Any mutation inside the function changes the original, even if the caller does not expect that. These unwanted side effects are one of the most common sources of hard-to-reproduce bugs: state gets changed in one place, but the effect shows up somewhere completely different in the application, often much later.
Immutability patterns solve this problem at the root: instead of changing objects, new objects are created with the updated values. The original stays untouched. In React and Redux this is not just a recommendation, it is a basic requirement for the library to function correctly: change detection is based on reference comparison (===), not on deep value comparison. If you mutate state directly, the comparison shows no change, and the UI does not update. Immutability makes state changes explicit, predictable and testable.
2. Object.freeze: shallow vs. deep immutability
Object.freeze() is the simplest immutability tool in JavaScript. It prevents new properties from being added, and existing ones from being changed or deleted. In strict mode, an attempt to mutate a frozen object throws a TypeError. Outside strict mode, the mutation fails silently, a classic pitfall. That is why strict mode combined with TypeScript and linting rules like prefer-const is recommended.
The critical weak point of Object.freeze() is that it is shallow. Nested objects and arrays are not frozen, only their references inside the outer object are immutable, not the referenced objects themselves. For deep immutability you have to freeze recursively, which is expensive for large objects and not a production-ready pattern for frequent state changes. The pragmatic solution: deep freeze for configuration objects that are set once, and for unit tests, to catch mutating code.
// Shallow freeze: nested objects remain mutable
const config = Object.freeze({
apiUrl: 'https://api.mironsoft.de',
timeout: 5000,
nested: { retries: 3 }, // NOT frozen
});
config.apiUrl = 'other'; // TypeError in strict mode, silently fails otherwise
config.nested.retries = 99; // Works! Shallow freeze doesn't protect nested objects
// Deep freeze utility, recursive, suitable for config objects
function deepFreeze(obj) {
if (obj === null || typeof obj !== 'object') return obj;
Object.getOwnPropertyNames(obj).forEach(name => {
deepFreeze(obj[name]);
});
return Object.freeze(obj);
}
const frozenConfig = deepFreeze({
db: { host: 'localhost', port: 5432 },
cache: { ttl: 300, maxSize: 1000 },
});
frozenConfig.db.host = 'other'; // TypeError: deep freeze protects nested objects
// Detect frozen objects
console.log(Object.isFrozen(frozenConfig)); // true
console.log(Object.isFrozen(frozenConfig.db)); // true
3. Spread operator and Object.assign as immutability tools
The spread operator (...) is the most widely used immutability tool in modern JavaScript. { ...original, key: newValue } creates a new object with all properties of original, with key overwritten. The original stays unchanged. This syntax is elegant and easy to follow for flat updates, but for objects two or three levels deep it already becomes unwieldy, because every level has to be spread individually.
A common mistake with spread: it is shallow too. const copy = { ...original } only copies the top level. Nested objects still share the same reference. Anyone who thinks they made a deep copy and then mutates the copied nested object ends up mutating the original. The pattern for immutable updates across several levels combines multiple spreads: { ...state, user: { ...state.user, name: newName } }. That is correct, but quickly becomes unreadable with deep nesting, the point where Immer.js or a helper function like produce becomes worthwhile.
// Immutable update patterns with spread operator
const state = {
user: { name: 'Max', age: 30, address: { city: 'Berlin', zip: '10115' } },
settings: { theme: 'dark', lang: 'de' },
cart: [{ id: 1, qty: 2 }, { id: 2, qty: 1 }],
};
// Flat update, safe and readable
const withNewTheme = { ...state, settings: { ...state.settings, theme: 'light' } };
// Deep update, correct but verbose
const withNewCity = {
...state,
user: {
...state.user,
address: {
...state.user.address,
city: 'Hamburg',
},
},
};
// Array updates without mutation
const addItem = { ...state, cart: [...state.cart, { id: 3, qty: 1 }] };
const removeItem = { ...state, cart: state.cart.filter(i => i.id !== 1) };
const updateQty = {
...state,
cart: state.cart.map(i => i.id === 2 ? { ...i, qty: i.qty + 1 } : i),
};
// Verify originals are untouched
console.log(state.settings.theme); // 'dark', original unchanged
console.log(state.user.address.city); // 'Berlin', original unchanged
4. Immutable array operations without mutation
JavaScript arrays have mutating methods (push, pop, splice, sort, reverse) and non-mutating methods (map, filter, reduce, concat, slice). The immutability pattern: use only the non-mutating methods, and for operations like sort and reverse, create a copy first. Since ES2023 there are the methods toSorted(), toReversed(), toSpliced() and with(), which return new arrays instead of changing the original.
The most common immutability bug with arrays: array.sort() sorts the original in place and returns it. Anyone who stores the result in a new variable has still mutated the original, because both variables point to the same array. The correct pattern is [...array].sort(compareFn), or with ES2023, array.toSorted(compareFn). The same applies to reverse(): always use [...array].reverse() or array.toReversed(), never call array.reverse() directly on shared state.
5. Structural sharing: efficiency without copying
Immutability sounds expensive: creating a new object on every change. For small objects that is negligible. But for a Redux store with a hundred nested objects, a full deep clone on every action would be unacceptable. The solution is structural sharing: unchanged parts of the object tree are not copied, their references are shared instead. Only the path from the root to the changed property is rebuilt.
This is exactly what the spread operator does implicitly: { ...state, user: { ...state.user, name: 'new' } } creates new objects for state and state.user, but state.settings and state.cart are not copied, the new and old objects share the same references for the unchanged parts. These shared references enable fast reference comparison: if newState.settings === oldState.settings, a React component knows the settings have not changed without having to perform a deep value comparison.
// Structural sharing: unchanged parts share references
const before = {
users: [{ id: 1, name: 'Max' }, { id: 2, name: 'Anna' }],
settings: { theme: 'dark' },
metadata: { version: 3, lastUpdated: '2026-05-10' },
};
// Update only one user, settings and metadata are shared by reference
const after = {
...before,
users: before.users.map(u => u.id === 1 ? { ...u, name: 'Maximilian' } : u),
};
// Verify structural sharing: identical references for unchanged subtrees
console.log(after.settings === before.settings); // true, shared
console.log(after.metadata === before.metadata); // true, shared
console.log(after.users === before.users); // false, new array
console.log(after.users[1] === before.users[1]); // true, user 2 unchanged, shared
// Change detection in O(1), no deep comparison needed
function hasSettingsChanged(prev, next) {
return prev.settings !== next.settings; // Reference equality suffices
}
console.log(hasSettingsChanged(before, after)); // false, same reference
6. Immer.js: write mutable, keep immutable
Immer.js elegantly solves the readability problem of deeply nested immutability updates: you write code that looks like a mutable mutation, and Immer makes sure the result is a new, immutable object. The function produce(baseState, recipe) passes the recipe function a proxy (draft) that records all mutations without touching the original. Once the recipe function finishes, Immer computes the new immutable object using structural sharing.
Immer is especially valuable in Redux Toolkit, which uses produce internally in every reducer function. Developers write state.user.name = 'new' instead of { ...state, user: { ...state.user, name: 'new' } }. That makes complex state updates readable and error-free, while Immer still guarantees full immutability of the original state object. The performance overhead of Immer compared to manual spreads is negligible for typical UI state updates; for very large objects, a targeted benchmark can be worthwhile.
7. Reference equality and change detection
The most important performance feature of consistent immutability is fast change detection through reference comparison. React.memo, useMemo, useCallback and React.PureComponent all check whether props or values have stayed equal using ===. For mutated objects, === is always true (same reference), even if the content has changed, so the component does not re-render. For immutable updates, === is always false for changed objects and true for unchanged ones, exactly what React needs for correct, efficient re-renders.
This mechanism makes immutability the foundation of efficient UI frameworks. Redux's connect(), Zustand's selectors, Recoil's atoms, all of them rely on the assumption that unchanged state keeps the same reference. That enables memoization that actually works. A selector memoized with useMemo only needs to recompute its value when its input references have changed, an O(1) check instead of a deep value comparison.
8. Records and Tuples: native immutability (Stage 2)
Records and Tuples is a TC39 proposal at Stage 2 that introduces native primitive immutability into JavaScript. A Record (using #{ } syntax) behaves like an object, but is primitive and deeply immutable, similar to a string or a number. Two Records with the same values are equal with ===, without needing any identity management. This solves a fundamental problem of object-based immutability: two different objects with identical content are always !==, which makes memoization and change detection harder.
Records and Tuples (using #[ ] syntax) cannot contain regular objects, all values must be primitives or Records/Tuples themselves. That guarantees deep immutability without runtime checks. Records and Tuples can be used as keys in Maps and Sets, a feature that is not possible with regular objects. Although not yet available in browsers, the proposal signals the direction the JavaScript platform is heading regarding first-class support for immutable data structures.
9. Immutability strategies compared
The right immutability strategy depends on the use case: flat vs. deep structures, write frequency, team experience and performance requirements. There is no universal answer, but there are clear recommendations depending on context.
| Strategy | Strengths | Weaknesses | Ideal for |
|---|---|---|---|
| Object.freeze | No overhead, native | Shallow only, errors only in strict mode | Configuration objects, tests |
| Spread operator | Readable, no overhead | Verbose for deep nesting | Flat updates, React state |
| Immer.js | Readable, deep updates made easy | External dependency, proxy overhead | Redux reducers, complex state |
| structuredClone | Native deep copy, no sharing | O(n), no structural sharing | Small objects, one-off copying |
| Records & Tuples | Value equality with ===, primitive | Not yet available (Stage 2) | Future: keys in Maps, memoization |
The pragmatic recommendation for most projects: spread operator for flat updates, Immer.js (via Redux Toolkit) for complex state with deep nesting, Object.freeze for configuration and tests. structuredClone is useful when you truly need an independent copy without any sharing at all, for example to create a state snapshot for undo functionality. The immutability pattern that is most often underestimated: the array methods toSorted(), toReversed() and with(), which have been available in all modern environments since ES2023.
Mironsoft
JavaScript architecture, state management and frontend performance
Need stable state management for your React application?
We analyze existing state management implementations for mutation errors and build in immutability patterns that improve re-render behavior, change detection and testability.
State audit
Analysis of existing Redux/Zustand stores for mutation errors and incorrect change detection
Reducer refactoring
Migration from manual spread patterns to Immer.js / Redux Toolkit with structural sharing
Performance optimization
Setting up memoization and change detection correctly for measurably fewer re-renders
10. Summary
Immutability patterns are not an optional best practice, they are the foundation for correct change detection, reliable memoization and predictable state management in modern JavaScript applications. Object.freeze offers simple shallow immutability for configuration objects. The spread operator is the most widely used tool for flat immutable updates. Structural sharing ensures that immutability does not come at the cost of performance, unchanged objects share their references. Immer.js makes deep updates readable without giving up the benefits of immutability.
The most important shift in perspective: immutability is not a performance problem, it is a performance solution. React.memo and useMemo only work correctly with immutable updates. Arrays with toSorted(), toReversed() and with() make mutating array methods obsolete. Records and Tuples will bring value equality with === to objects, a paradigm shift for state management and memoization in JavaScript.
Immutability Patterns, the key points at a glance
Avoid mutation bugs
Passing objects by reference and mutating them causes hard-to-find bugs. The spread operator and Immer.js create new objects without touching the original.
Structural sharing
Unchanged parts of the object tree share references. O(1) change detection with ===, no deep comparison needed for React, Redux and memoization.
Array immutability ES2023
toSorted(), toReversed(), toSpliced() and with() return new arrays. Never call sort() or reverse() directly on shared state.
Immer.js for complex state
produce(state, draft => { draft.x.y = z }), mutable syntax, immutable result. The core of Redux Toolkit, with full structural sharing.
11. FAQ: JavaScript Immutability Patterns
1Why is immutability important in React?
2Object.freeze vs. spread operator?
3Isn't immutability expensive?
4When Immer.js instead of spread?
5What is structural sharing?
6const vs. Object.freeze?
7Avoiding the Array.sort() mutation trap?
[...array].sort(fn) or ES2023 array.toSorted(fn), always a new array, no mutation of the original.