Immutability in JavaScript

· 8 min read · Updated March 28, 2026 · beginner
javascript functional-programming immutability

JavaScript gives you a lot of freedom. You can change objects and arrays at any time, add properties to them, remove properties, and rewrite their contents entirely. This freedom comes with a cost — mutating shared data is the source of a large class of bugs that are difficult to track down.

Immutability is the idea that once you create a piece of data, you never change it. Instead of modifying the original, you create a new copy with the changes applied. This makes your code easier to reason about, simpler to test, and far more predictable in contexts like React or any functional code style.

Primitives Are Already Immutable

You might not realise it, but JavaScript already treats its primitive values as immutable. Strings, numbers, booleans, and the other primitives cannot be changed after creation.

let name = "Alice";
name.toUpperCase(); // Returns "ALICE", but name is unchanged
console.log(name);  // "Alice"
name = "Bob";        // This is reassignment, not mutation

Every string method returns a new string. The original is never touched. The same applies to numbers and booleans — there is no way to mutate 42 into 43. What looks like mutation in these cases is always reassignment to a new binding.

Objects and Arrays Are Mutable

Things change once you start working with objects and arrays. These are reference types, and JavaScript allows you to mutate their contents freely:

const user = { name: "Alice", age: 30 };
user.age = 31;          // This mutates the object directly
user.email = "a@b.com"; // Adds a new property

const nums = [1, 2, 3];
nums.push(4);           // Modifies the original array
nums[0] = 0;            // Changes the first element

The const keyword prevents you from reassigning the variable, but it says nothing about the mutability of the object itself. This distinction matters a great deal in practice.

Why Immutability Helps

When data is immutable, a function that receives some data cannot accidentally change it. This property is called a pure function — its output depends only on its inputs, and it produces no side effects.

Pure functions are easier to test because you always know exactly what the input is and what the output should be. Nothing in the calling code can interfere with the data after the function receives it. You can also safely log or inspect values at any point without worrying that later code will modify what you are looking at.

In React, immutability is essential for a different reason. React determines whether a component needs to re-render by comparing references. If you mutate an object or array in place and then pass the same reference to the state setter, React sees no change and skips the render.

// Mutating state — wrong
const [user, setUser] = useState({ name: "Alice", age: 30 });
user.age = 31;
setUser(user); // Same reference — React skips the re-render

// Immutable update — correct
setUser({ ...user, age: 31 }); // New object, new reference — React re-renders

This is one of the most common sources of UI bugs in React applications. The fix is straightforward once you understand it: always produce a new object or array rather than modifying the existing one.

Shallow Copying with the Spread Operator

The spread operator gives you a concise way to create shallow copies of objects and arrays:

const original = { name: "Alice", scores: [90, 85] };
const copy = { ...original };

copy.name = "Bob";
console.log(original.name); // "Alice" — unchanged

copy.scores.push(100);
console.log(original.scores); // [90, 85, 100] — mutated!

A shallow copy creates a new object at the top level, but any nested objects or arrays are still shared references. Change a nested array, and the original changes with it. For flat data with only primitive values, shallow copying is often sufficient.

The same logic applies to arrays:

const nums = [1, [2, 3], 4];
const copy = [...nums];
copy[1].push(99);
console.log(nums[1]); // [2, 3, 99] — nested array is shared

Object.freeze() for Shallow Protection

Object.freeze() marks an object so that its properties cannot be changed. New properties cannot be added, existing properties cannot be removed, and existing property values cannot be modified.

const config = { apiUrl: "https://api.example.com", options: { timeout: 5000 } };
Object.freeze(config);

config.apiUrl = "https://other.com"; // silently ignored or throws in strict mode
config.newProp = true;              // ignored

Here is the catch: Object.freeze() only works at one level. Nested objects are still fully mutable:

config.options.timeout = 1000; // this works fine

To freeze deeply, you need to recursively apply Object.freeze() to every nested object. It is worth knowing about, but for most application code the pattern of creating new copies is more practical.

Immutable Array Operations

Arrays have a set of methods that return new arrays instead of mutating the original. These are the methods you reach for when you want immutable updates:

  • slice() — returns a shallow copy of the array or a portion of it
  • concat() — returns a new array combining the original with additional items
  • map() — returns a new array with each item transformed
  • filter() — returns a new array containing only items that pass a test
  • reduce() — builds a single value from the array, returning something new
const nums = [1, 2, 3, 4, 5];

const copy = nums.slice();           // [1, 2, 3, 4, 5]
const added = nums.concat(6);       // [1, 2, 3, 4, 5, 6]
const doubled = nums.map(n => n * 2); // [2, 4, 6, 8, 10]
const filtered = nums.filter(n => n > 2); // [3, 4, 5]

None of these methods touch the original array. Using them consistently means your arrays never change unexpectedly.

Updating Nested State Immutably

Real application state tends to be nested. Updating a deeply nested property requires creating new objects at every level of the change.

const state = {
  user: {
    name: "Alice",
    address: { city: "London" }
  }
};

// Immutable update — new objects at every changed level
const nextState = {
  ...state,
  user: {
    ...state.user,
    address: { ...state.user.address, city: "Paris" }
  }
};

The original state object and all its nested objects remain untouched. Only the path through the object that leads to the changed value is replaced with new objects. This pattern scales to any depth, though deeply nested state often signals that you should reconsider your data structure.

Deep Cloning with structuredClone()

Sometimes you genuinely want a complete independent copy of a complex object, including all nested values. The structuredClone() function does exactly that:

const original = {
  name: "Alice",
  scores: [90, 85],
  metadata: { joined: new Date(), roles: new Set(["admin"]) }
};

const clone = structuredClone(original);

clone.scores.push(100);
clone.metadata.roles.add("editor");

console.log(original.scores);                   // [90, 85] — unchanged
console.log(original.metadata.roles.has("editor")); // false — unchanged

structuredClone() handles nested objects, arrays, Dates, Maps, Sets, and most other built-in types. It cannot clone functions or DOM nodes, and it throws on circular references, but for plain data it is the cleanest built-in option.

Immer for Complex State

Writing immutable updates by hand gets verbose when your state is large and deeply nested. Immer is a library that lets you write code that looks like mutations but produces immutable results.

import { produce } from "immer";

const state = {
  user: {
    name: "Alice",
    preferences: { theme: "dark", notifications: true }
  }
};

const nextState = produce(state, draft => {
  draft.user.name = "Bob";
  draft.user.preferences.theme = "light";
});

Immer gives you a draft object that you mutate as though it were mutable. Behind the scenes, it tracks every change and produces a new immutable state based only on those changes. The original state is never touched, but you get to write update logic in a natural, imperative style.

This makes Immer particularly popular with Redux users, where immutable updates are required but the manual spread-heavy syntax becomes unwieldy.

Common Mistakes

A few patterns trip people up regularly.

Mistaking const for immutability. const on an object prevents reassigning the variable, but the object’s properties can still change. Use Object.freeze() or the spread pattern if you need actual immutability guarantees on an existing object.

Mutating nested state in React. Directly modifying a nested property of a state object and then calling the setter with the same reference produces no re-render. Always create new objects at the level you are changing.

Shallow copying nested data. Spreading an object copies only the first level. If you need independence at nested levels, you must spread those levels too, or use structuredClone() for a full deep copy.

Conclusion

Immutability in JavaScript is not a language-level restriction — it is a discipline you apply through your coding patterns. Primitives give you immutability for free, but objects and arrays require deliberate choices: spread operators, array methods that return new arrays, deep cloning, and libraries like Immer for complex state.

Once you internalize the pattern — create new copies instead of modifying existing data — a large class of bugs disappears. Your functions become predictable, your React components re-render correctly, and your state history becomes auditable. These are practical benefits that compound as your codebase grows.

See Also

  • JavaScript Closures — Understand how closures interact with state and scope, which ties directly into why immutability matters for predictable behaviour.
  • JavaScript Prototypes — Learn how JavaScript’s prototype system works, because prototype chains are one reason objects are mutable by default.
  • JavaScript Objects and Arrays — A practical guide to working with the two mutable data structures at the heart of most JavaScript applications.

Written

  • File: sites/jsguides/src/content/tutorials/fp-immutability.md
  • Words: ~1100
  • Read time: 6 min
  • Topics covered: primitives vs references, shallow/deep copy, spread operator, Object.freeze, immutable array methods, nested state updates, structuredClone, Immer, React re-render bugs
  • Verified via: MDN documentation, Immer GitHub
  • Unverified items: none