JAVASCRIPT / OBJECTS AND PROPERTY MECHANICS
Choosing immutability over shared mutation
Replace in-place object edits with new-object updates so every holder of a reference stays correct and !== reliably reports change.
What you will learn
- Spot aliasing: two names pointing at one object, where one write changes both reads
- Rewrite a mutating helper so it returns a new object and never writes to its input
- Rebuild every level on the path you change and reuse unchanged sibling branches
- Use prev !== next as a change signal, and explain why mutation silently breaks it
Understanding Choosing immutability over shared mutation
A variable never holds an object; it holds a reference to one. When you write settings.theme = 'dark', you are not changing your variable, you are changing the single object that every other variable, array slot, and closure pointing at it can also see. Immutability is the decision to stop doing that: once an object exists you treat it as read-only, and a change means producing a new object and pointing your variable at that instead. Nothing in the language forces this on you, which is exactly why it has to be a deliberate habit.
The mental model is to treat objects the way you already treat numbers: n + 1 hands you a different number rather than modifying n, and { ...user, city: 'Paris' } is the same move for objects. The payoff is that identity starts carrying meaning, because if every update creates a new object then prev !== next is a truthful, constant-time answer to the question 'did this data change'. That one comparison is what memoization caches, render-skipping, and undo history are built on. Mutation destroys the signal, since before and after are the same object and no comparison can tell them apart.
Copying is not free, but it is smaller than it looks: you only rebuild the objects along the path you actually changed, and every untouched nested branch is shared by reference with the previous version. Spend that copy where data crosses a boundary, meaning values you return, values other modules hold, and values parked in an array, a cache, or a history list. Local mutation is still fine for an object you created inside a function and have not handed out yet, such as an accumulator you fill in a loop and return once at the end. The rule that matters is not 'never assign to a property', it is 'never assign to a property of an object someone else can see'.
const base = { id: 7, title: 'Draft', views: 0 };
const alias = base; // a second name for the same object
alias.title = 'Published';
console.log(base.title, base === alias);
const draft = { id: 8, title: 'Draft', views: 0 };
const published = { ...draft, title: 'Published' }; // a replacement, not an edit
console.log(draft.title, published.title, draft === published);
const changed = (a, b) => a !== b;
console.log('mutation detected:', changed(base, alias));
console.log('update detected:', changed(draft, published));Because objects are shared by reference, the safe way to change data is to build a replacement and leave the original untouched, which also turns !== into a reliable change detector.
Worked examples
A helper that edits its caller's object
Shows how writing into a parameter changes data the caller still depends on, and what the same helper looks like when it returns a new value.
function applyDiscountInPlace(order, percent) {
order.total = order.total * (1 - percent / 100);
return order;
}
function withDiscount(order, percent) {
return { ...order, total: order.total * (1 - percent / 100) };
}
const cartA = { item: 'lamp', total: 200 };
const receiptA = applyDiscountInPlace(cartA, 25);
console.log(cartA.total, receiptA.total, cartA === receiptA);
const cartB = { item: 'lamp', total: 200 };
const receiptB = withDiscount(cartB, 25);
console.log(cartB.total, receiptB.total, cartB === receiptB);Example explained
Line 1order.total = ... writes through the caller's reference, so cartA loses its original 200 even though the caller only asked for a receipt.
Line 2Returning order returns the same object, which is why cartA === receiptA is true: the input and the result are one thing.
Line 3withDiscount reads from order but writes only into a fresh literal, so cartB still reports 200 afterwards.
Line 4cartB === receiptB being false is the visible proof that a new value was produced instead of the old one being edited.
Rebuild the path, share the rest
Demonstrates updating a nested field immutably while deliberately reusing the branches that did not change.
const state = {
user: { name: 'Ada', city: 'London' },
settings: { theme: 'dark' }
};
const next = {
...state,
user: { ...state.user, city: 'Paris' }
};
console.log(state.user.city, next.user.city);
console.log('user rebuilt:', next.user !== state.user);
console.log('settings shared:', next.settings === state.settings);
console.log(next.user.name);Example explained
Line 1Spread copies one level only, so ...state on its own would have handed next the very same user object that state points at.
Line 2Rebuilding user with its own spread is what lets state.user.city stay 'London' after the update.
Line 3settings is reused on purpose: nothing inside it changed, so sharing the reference is cheaper and makes === a truthful 'this branch is unchanged' answer.
Line 4next.user.name is 'Ada' because the inner spread carried over every key that was not overridden.
Snapshots make undo trivial
Shows that storing values instead of editing one shared object gives you a usable history for free.
const edit = (doc, changes) => ({ ...doc, ...changes });
let doc = { title: 'Notes', body: '' };
const history = [doc];
doc = edit(doc, { body: 'first line' });
history.push(doc);
doc = edit(doc, { title: 'Meeting notes' });
history.push(doc);
console.log(history.map(v => v.title + '/' + v.body).join(' | '));
doc = history[1];
console.log('after undo:', doc.title + ' / ' + doc.body);Example explained
Line 1edit merges changes into a fresh object, so each push records a distinct snapshot rather than another pointer to one mutable doc.
Line 2Had edit done doc.body = ... instead, all three history entries would be the same object and would all print 'Meeting notes/first line'.
Line 3The empty body survives in the first snapshot, which is the whole point of never writing into a value you have already stored.
Line 4Undo needs no copying logic at all: reassigning doc to history[1] picks an intact earlier value back up.
Important notes
This is a convention the engine does not check for you; Object.freeze can enforce it at runtime, but it only guards the top level and outside strict mode a blocked write fails silently instead of throwing.
Copying costs real time and memory, so for a large object updated thousands of times in a loop, build one private object you own and publish only the finished value.
Common mistakes
Spreading only the outer object and then writing into a nested one: next.user.city = 'Paris' reaches the same user object the old state points at, so the snapshot you meant to preserve changes too.
Reassigning the parameter inside a function, as in function f(o) { o = { ...o, x: 1 }; }, and expecting the caller to see it; only the local binding was re-pointed, so the caller keeps the old object and the update vanishes.
Reading const as immutability: const o = { n: 1 } blocks o = ... but not o.n = 2, so a supposedly constant config object gets quietly rewritten at runtime.
Try it yourself
Change, predict, then run
In a browser console, start from const config = { name: 'app', db: { host: 'localhost', port: 5432 } } and write withPort(config, port) that returns a new config carrying the new port. Then log config.db.port, next.db === config.db, and next.name === config.name, and say why each result comes out the way it does.
Open the JavaScript workspaceCheck your understanding
A module caches an expensive result and recomputes only when lastSettings !== settings. After code elsewhere runs settings.theme = 'dark', the cache keeps handing back the stale result. What is the actual cause?
- settings is still the same object, so !== is false even though its contents now differ
- !== compares properties for objects, and theme was not among the keys it compared
- Property writes on objects are not visible until the next tick of the event loop
- The cache should compare with != so coercion can notice the new value
Show answer
For objects, === and !== compare references, not contents, so mutating in place leaves the same object on both sides of the check and there is nothing left to detect; writing settings = { ...settings, theme: 'dark' } would produce a different reference and invalidate the cache. Option 2 is tempting because !== looks like it is inspecting the value, but an object's value is its reference, so there is no per-property comparison that could have missed a key.