
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 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.
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
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
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: const tells 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.
const i in a for-loop - the loop variable itself shouldn't be reassigned.const fetch = async () => { ... } - the function reference is fixed.import React from 'react' - the imported binding is fixed per module scope.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 |
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!
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"]
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() | ❌ | ❌ | ❌ | ❌ |
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.
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.
| ✅ 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 |
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.
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.
A junior developer is confused why their `const` variable holding a configuration object allows its properties to be changed, leading to unexpected behavior in a production application. Given the following code, which line will NOT throw a `TypeError`?
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Detailed performance comparison between Intersection Observer and getBoundingClientRect for scroll tracking, lazy loading, and viewport detection.
Read more
Can you use JavaScript in Roblox? No, Roblox uses Luau. Here is how JS and TS web developers transition fast: syntax comparisons, tables vs arrays, event handling, and a cheat sheet.
Read more
Pragmatic guide to writing resilient JavaScript unit tests: the AAA pattern, test fixtures, property-based testing with fast-check, and boundary mocking.
Read more