Immutability is essential for React, Redux, and functional programming. Learn how to work with data without mutation.
Picture a printed photograph versus a Photoshop layer. The printed photograph never changes — if you want a version with a filter, you print a new one. The Photoshop layer is mutable — you edit it in place. The original is gone. Immutability is the printed photograph approach. Instead of editing the existing object, you produce a new one with the changes applied. The original is preserved exactly as it was. Anyone holding a reference to the original still has the same, unmodified thing they had before. The key insight: immutability is not about preventing all change — it's about making change explicit, traceable, and safe. When you know an object can never be modified after creation, you can share it freely, compare it by reference, cache it confidently, and reason about your program one piece at a time without worrying about what else might have changed it. This is why React, Redux, and functional programming all converge on the same rule: don't mutate, produce new.
// MUTABLE — modify in place
const user = { name: 'Alice', score: 10 }
user.score += 5 // mutate the existing object
// Any other code holding a reference to 'user' now sees score: 15
// Including code that expected it to still be 10
// IMMUTABLE — produce a new object
const user = { name: 'Alice', score: 10 }
const updated = { ...user, score: user.score + 5 }
// 'user' is untouched — score is still 10
// 'updated' is a new object with score: 15
// Both can be kept, compared, or thrown away independently
// With arrays:
const items = [1, 2, 3]
// Mutable operations — modify in place:
items.push(4) // mutates items
items.splice(1, 1) // mutates items
items.sort() // mutates items
// Immutable equivalents:
const withFour = [...items, 4] // add
const without1 = items.filter((_, i) => i !== 1) // remove by index
const sorted = [...items].sort() // sort (copy first!)
// React compares previous and next state with Object.is()
// Object.is() on objects compares REFERENCES, not contents
const prev = { count: 0 }
const next = prev
next.count = 1 // mutated the same object
Object.is(prev, next) // true — same reference!
// React sees no change → no re-render
// Immutable update — new reference
const prev = { count: 0 }
const next = { ...prev, count: 1 }
Object.is(prev, next) // false — different references
// React sees a change → re-renders
// This is why direct state mutation causes "bugs" in React:
// setCount(count++) // ❌ returns old value, AND mutates
// setState(state.push(item)) // ❌ push mutates, returns length not array
// setState([...state, item]) // ✓ new array — React detects the change
// React.memo and useMemo depend on this too:
const MemoizedList = React.memo(({ items }) =>
)
// If you mutate items instead of producing a new array,
// the reference is the same — memo thinks nothing changed — no re-render
const state = {
user: { name: 'Alice', role: 'user' },
items: [{ id: 1, done: false }, { id: 2, done: true }],
settings: { theme: 'dark', lang: 'en' }
}
// Update a nested property:
const withRole = {
...state,
user: { ...state.user, role: 'admin' }
}
// Update an item in an array by index:
const toggleItem = (id) => ({
...state,
items: state.items.map(item =>
item.id === id ? { ...item, done: !item.done } : item
)
})
// Add item to array:
const withNewItem = {
...state,
items: [...state.items, { id: 3, done: false }]
}
// Remove item from array:
const withoutItem = (id) => ({
...state,
items: state.items.filter(item => item.id !== id)
})
// Update deeply nested:
const withDeepChange = {
...state,
settings: {
...state.settings,
theme: 'light'
}
}
// ES2023 — structuredClone for deep copy when needed
const deepCopy = structuredClone(state)
deepCopy.settings.theme = 'light' // safe — original untouched
// Object.freeze prevents property addition, deletion, and reassignment
const CONFIG = Object.freeze({
API_URL: 'https://api.example.com',
MAX_ITEMS: 100,
FEATURES: ['search', 'filter'],
})
CONFIG.API_URL = 'https://evil.com' // silently fails (throws in strict mode)
CONFIG.NEW_PROP = 'x' // silently fails
delete CONFIG.MAX_ITEMS // silently fails
CONFIG.FEATURES.push('export') // ← THIS WORKS — freeze is SHALLOW
// The FEATURES array is not frozen — only the reference to it is frozen
// Deep freeze:
function deepFreeze(value) {
if (typeof value !== 'object' || value === null) return value
Object.keys(value).forEach(key => deepFreeze(value[key]))
return Object.freeze(value)
}
const FROZEN_CONFIG = deepFreeze({ db: { host: 'localhost', port: 5432 } })
FROZEN_CONFIG.db.port = 9999 // throws in strict mode — deeply frozen
// Object.isFrozen(obj) — check if frozen
// Freeze is permanent — cannot unfreeze a frozen object
// Immer lets you write "mutating" code that actually produces immutable results
// It wraps your object in a Proxy, records your mutations,
// and applies them to a new copy — the original is untouched
import { produce } from 'immer'
const state = {
user: { name: 'Alice', score: 0 },
items: [{ id: 1, done: false }, { id: 2, done: false }]
}
const nextState = produce(state, draft => {
// 'draft' is a Proxy — mutations on it are recorded, not applied to state
draft.user.score += 10
draft.items[0].done = true
draft.items.push({ id: 3, done: false })
})
// state is untouched:
state.user.score // 0
state.items.length // 2
// nextState is a new object with the changes:
nextState.user.score // 10
nextState.items.length // 3
// Structural sharing: Immer only creates new objects for changed nodes
// unchanged parts of the tree are SHARED with the original — memory efficient
nextState.items[1] === state.items[1] // true — same reference, not copied
// Naive immutability: clone the entire object on every change — expensive
// Structural sharing: only copy the changed path — efficient
// Example: a tree of 1000 nodes, updating one leaf
const tree = buildTree(1000)
// Without structural sharing: clone all 1000 nodes
const newTree = JSON.parse(JSON.stringify(tree)) // deep clone — 1000 new objects
newTree.branches[2].leaves[5].color = 'red'
// With structural sharing: only 3 new objects (tree → branches[2] → leaves[5])
const newTree = {
...tree, // new root (shares all other branches)
branches: [
...tree.branches.slice(0, 2),
{
...tree.branches[2], // new branch (shares all other leaves)
leaves: tree.branches[2].leaves.map((leaf, i) =>
i === 5 ? { ...leaf, color: 'red' } : leaf // new leaf only for index 5
)
},
...tree.branches.slice(3)
]
}
// All unchanged nodes are REUSED — not copied
// This is how Redux, Immer, and persistent data structures work efficiently
// const prevents REASSIGNMENT of the variable binding
// It says nothing about the object the variable points to
const user = { name: 'Alice' }
user = { name: 'Bob' } // TypeError: Assignment to constant variable
user.name = 'Bob' // ← THIS WORKS — const doesn't freeze the object
user.role = 'admin' // ← THIS WORKS
delete user.name // ← THIS WORKS
// const with primitives IS effectively immutable:
const x = 42
x = 43 // TypeError — primitives are values, not references
// For true immutability of objects: const + Object.freeze
const SETTINGS = Object.freeze({ theme: 'dark', lang: 'en' })
SETTINGS.theme = 'light' // fails — frozen
SETTINGS = {} // fails — constMany devs think const makes objects immutable — but actually const only prevents the variable from being reassigned to a different value. The object the variable points to can be freely mutated — properties can be added, changed, or deleted. True immutability requires Object.freeze() or treating the object as immutable by convention, neither of which const provides.
Many devs think immutability requires cloning the entire object on every change, making it memory-intensive — but actually immutable updates use structural sharing, where unchanged parts of the object tree are referenced rather than copied. Only the nodes along the changed path are new objects. This is how Redux, Immer, and persistent data structure libraries achieve O(log n) update costs rather than O(n) full copies.
Many devs think Object.freeze() provides deep immutability — but actually Object.freeze() is shallow. It freezes the object's own properties (preventing assignment and deletion), but nested objects are still mutable. Object.freeze({a: {b: 1}}) does not protect the nested object — obj.a.b = 99 still works. Deep immutability requires recursively freezing every nested object.
Many devs think the spread operator (...) creates a deep clone — but actually spread creates a shallow copy. Top-level properties are copied by value, but nested objects and arrays are still shared by reference. Mutating a nested property in the spread copy mutates the original too. structuredClone() is the standard for true deep copies.
Many devs think immutability is primarily a functional programming preference with no practical impact — but actually React's entire rendering model, Redux's change detection, memoization correctness, and undo/redo history features all depend on reference equality checks that only work correctly with immutable data. Mutation breaks these systems in ways that are often difficult to debug because the reference stays the same even though the data changed.
Redux's entire architecture is built on the immutability guarantee — reducers must return new state objects, never mutate the existing state. This allows Redux DevTools' time-travel debugging: because each state is an immutable snapshot, Redux can store every historical state and restore any of them instantly. Mutation would make every entry in the history the same object — indistinguishable from each other.
React's useMemo and useCallback hooks use referential equality to decide whether to recompute — useMemo(() => expensiveCalc(data), [data]) only re-runs when the data reference changes. If data is mutated in place (same reference, different content), useMemo never re-runs and returns stale results. This is the root cause of the most confusing React bugs in production: stale memoized values that never update.
Zustand (a popular React state library) defaults to immutable updates but uses a shallow equality check on the entire store — if you return the same reference from a setter, subscribed components don't re-render. Libraries like Valtio take the opposite approach, using Proxy-based mutation tracking, and the difference in how you write updates (immutable spread vs direct mutation) is the primary philosophical distinction between these state management approaches.
Git's data model is the most famous real-world structural sharing implementation — each commit stores only the changed blobs and tree objects, while unchanged files are shared across commits by reference (content hash). This is why git repositories stay relatively small despite storing every version of every file. The same structural sharing principle that makes immutable JavaScript updates efficient is what makes git efficient at scale.
The Immutable.js library (Facebook/Meta) implements persistent data structures — List, Map, and Set that use hash array mapped tries (HAMTs) to achieve O(log32 n) update costs with structural sharing. Before Immer became the standard, Immutable.js was how large React/Redux applications managed deeply nested state efficiently without full deep clones on every update.
What is the difference between shallow copy and deep copy?
Deep clone breaks with JSON.stringify
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.