const vs Immutability in JavaScript: The Binding, Not the Value

Table of Contents
Common Misconception
Many tutorials and even experienced developers oversimplify this. The distinction between reassignment and mutation is the key to understanding JavaScript behavior at a deeper level—getting it wrong leads to subtle bugs in state management.
const in JavaScript does not freeze your values. It locks the variable binding—meaning you can't point the variable at an entirely different value. This is one of the most misunderstood concepts in JavaScript.
The Binding vs. The Value
Reassignment (Blocked)
The binding itself is constant. You cannot point x somewhere else:
const x = 5;
x = 10; // TypeError: Assignment to constant variable.
const user = { name: "Ava" };
user = {}; // TypeError: Assignment to constant variable.
Mutation (Allowed)
You didn't change the binding—you changed the object/value it points to. JavaScript allows this:
const user = { name: "Ava" };
user.name = "Morgan"; // ✅ Allowed — same object, different property
const items = [1, 2, 3];
items.push(4); // ✅ Allowed — same array reference
items[0] = 99; // ✅ Allowed — same array reference
The Memory Model
Think of const as a "permanent pointer" rather than an "immutable object":
const obj = { a: 1, nested: { b: 2 } };
// The pointer (variable name) is locked to this object
// But the object itself is still open for business
obj.a = 100; // ✅ Fine
obj.nested.b = 200; // ✅ Fine
obj.newProp = "new"; // ✅ Fine
// Only the final line fails — reassigning the variable itself
obj = {}; // ❌ TypeError
Why JavaScript Designed It This Way
JavaScript uses a const keyword that signals intent rather than enforcing immutability. This choice relates to JavaScript's history as a lightweight scripting language, but also has advantages in everyday development.
Performance: The JavaScript engine knows that the binding won't change, enabling certain optimizations.
Intent signaling:
consttells other developers: "This variable should not be reassigned."Flexibility: Some objects need mutation (adding properties, pushing array elements) but shouldn't be replaced.
Backward compatibility: JavaScript was designed with a minimalist approach, and adding true deep immutability from the start wasn't feasible.
- Loop variables:
const iin a for-loop—the loop variable itself shouldn't be reassigned. - Arrow functions:
const fetch = async () => { ... }—the function reference is fixed. - Imports:
import React from 'react'—the imported binding is fixed per module scope. - React components:
const App = () => { ... }—classic pattern for function components.
const with objects is not the same as Object.freeze. Common surprises:
| Scenario | Works? | Reason |
|---|---|---|
arr.push(x) | ✅ | Mutates, doesn't reassign |
arr[i] = x | ✅ | Mutates, doesn't reassign |
obj.key = x | ✅ | Mutates, doesn't reassign |
delete obj.key | ✅ | Mutates, doesn't reassign |
obj = | ❌ | Reassigns the variable |
Real Immutability Options
Quick Reference
If you truly need immutability, JavaScript provides several tools—each with different levels of depth and performance implications.
const shallow = Object.freeze({
name: "Ava",
settings: {
notifications: true
}
});
shallow.name = "Morgan"; // ❌ TypeError
shallow.newProp = "test"; // ❌ TypeError
// ❗ Watch out: nested objects are NOT frozen
shallow.settings.notifications = false; // ✅ Works! Shallow only
shallow.settings = { notifications: false }; // ❌ TypeError (top-level only)
// Verify:
Object.isFrozen(shallow); // true
Object.isFrozen(shallow.settings); // false — still mutable!
Shallow behavior
Object.freeze does not recursively freeze nested objects. You must manually freeze every nested object for a truly immutable structure—which is rarely worth it.
const original = {
name: "Ava",
settings: {
notifications: true,
theme: "dark"
},
tags: ["dev", "tech"]
};
// Creates a true deep copy (no shared references)
const copy = structuredClone(original);
// ✅ Mutating the copy has zero effect on original
copy.name = "Morgan";
copy.settings.notifications = false;
copy.tags.push("blog");
console.log(original.name); // "Ava"
console.log(original.settings.notifications); // true
console.log(original.tags); // ["dev", "tech"]
console.log(copy.name); // "Morgan"
console.log(copy.tags); // ["dev", "tech", "blog"]
Why structuredClone?
It handles Date, RegExp, Map, Set, ArrayBuffers, and even circular references. It's the modern replacement for JSON.parse(JSON.stringify()), preserving types that JSON loses.
| Method | Can add props? | Can delete props? | Can change values? | Can change descriptors? |
|---|---|---|---|---|
Object.preventExtensions() | ❌ | ✅ | ✅ | ❌ |
Object.seal() | ❌ | ❌ | ✅ | ❌ |
Object.freeze() | ❌ | ❌ | ❌ | ❌ |
Practical advice
These methods are mostly useful for defensive coding or when you want to explicitly lock down an API contract. In application logic, immutable patterns (spreading, structuredClone) are usually clearer and more flexible.
You want immutable-looking code that's actually mutable under the hood
Immer takes a unique approach: you write code that looks mutable, and it produces a new immutable state automatically.
import { produce } from "immer";
const nextState = produce(currentState, (draft) => {
// "mutate" the draft — feels natural
draft.items.push({ id: 3, name: "Pencil" });
draft.items[0].quantity = 5;
});
// nextState is a brand new object
// currentState is unchanged
// But the code reads like you're modifying it
This pattern powers Redux Toolkit's createSlice and many modern state management systems.
Decision Diagram
Best Practices
| ✅ Do | ❌ Don't | Reason |
|---|---|---|
Use const as the default | Use let as the default | Signals intent; prevents accidental reassignment |
Use structuredClone() for deep copies | Use JSON.parse(JSON.stringify()) | Loses Dates, undefined, circular refs |
Use spread for shallow copies: {...obj} | Mutate objects and arrays directly | React state needs new references to re-render |
| Use Immer for complex nested updates | Manually deep-freeze every nested object | O(n²) complexity and fragile |
Use TypeScript readonly for compile-time checks | Assume const grants protection | Runtime JS offers zero enforcement for mutation |
Common Questions
const prevents accidental reassignment—the most common bug. It tells humans and really smart linters that this binding should stay the same. For true read-only guarantees, combine it with other patterns.
Yes. And you should! In loops, const item creates a new binding per iteration, so it's safe and actually preferred:
const items = [1, 2, 3];
items.forEach((item) => { // ✅ item is per-iteration
console.log(item);
});
items.map((item) => item * 2); // ✅ item is per-iteration
const funcs = [];
for (const i = 0; i < 5; i++) {
funcs.push(() => console.log(i));
}
funcs[0](); // 0 — all log their specific i value ✓
// But this works DIFFERENTLY:
for (let j = 0; j < 5; j++) {
funcs.push(() => console.log(j));
}
funcs[0](); // 0 — same result here
The distinction only matters in var-style function-scoped hoisting scenarios.
TL;DR
One-sentence takeaway
const means "this variable won't be reassigned"—not "this value is immutable." Use it every day. For immutability, pair it with spread syntax, structuredClone, or a library like Immer.
You Might Also Like
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Mastering E2E Testing with Playwright in 2026
Mastering E2E testing with Playwright in 2026: auto-waiting, browser context isolation, network interception, auth storage, and CI parallelization.
Read more
Advanced TypeScript Patterns for Enterprise Applications
Master advanced enterprise TypeScript patterns: branded types, conditional response types, template literal routing, and the satisfies operator.
Read more
State Management in React 2026: Beyond Redux
Comprehensive guide to React state management in 2026: comparing React 19 actions, TanStack Query server state, Zustand, Jotai, and Signals.
Read more