JavaScript NotesRohit’s interview study guide
Chapter 06

Copying & Immutability

Shallow vs deep copies, every cloning technique and its catch, why React wants new objects, and freezing.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Shallow vs deep copy

  • A shallow copy creates a new top-level object, but nested objects are still shared with the original.
  • A deep copy recursively copies everything, so nothing is shared.

The distinction exists because of how objects are stored. A variable or property never contains an object — it contains a reference to one (see Types and dynamic typing). So when you copy an object's properties, a property holding a primitive copies the value itself, but a property holding a nested object copies only the reference. The copy ends up pointing at the very same nested object. "Shallow" means exactly that: one level is new, everything below is shared.

JavaScript
const original = { toy: "Car", smallBox: { toy: "Doll" } };

const shallow = { ...original };
shallow.toy = "Ball";              // fine — top level is separate
shallow.smallBox.toy = "Robot";    // ⚠ changes original too!

original.toy;          // "Car"
original.smallBox.toy; // "Robot"
What’s happening
  1. original is one object with two properties: toy holds the string "Car", and smallBox holds a reference to a second object, { toy: "Doll" }.
  2. { ...original } creates a new outer object and copies each property across: toy gets its own "Car", and smallBox gets a copy of the reference — so shallow.smallBox === original.smallBox is true.
  3. shallow.toy = "Ball" changes a property on the new outer object only. original.toy stays "Car".
  4. shallow.smallBox.toy = "Robot" first follows shallow.smallBox to the shared inner object, then changes its toy from "Doll" → "Robot". There is only one inner object, so original.smallBox.toy now reads "Robot" too.
  5. This is the bug pattern to recognise: code that "copies" an object and then edits something nested inside the copy is editing the original as well.
AdvancedShallow vs deep: the box analogy

Think of a shallow copy as a new box with the same inner boxes inside; a deep copy builds new inner boxes too.

JavaScript
const deep = structuredClone(original);
deep.smallBox.toy = "Plane";
original.smallBox.toy; // "Robot" — unaffected
What’s happening
  1. structuredClone(original) walks the whole structure and creates a new outer object and a new smallBox object, so deep.smallBox !== original.smallBox.
  2. deep.smallBox.toy = "Plane" changes the copy's own inner box.
  3. original.smallBox.toy is still "Robot" (the value left by the previous example) — nothing is shared any more.
  4. The trade-off: a deep copy costs time and memory in proportion to the size of the whole structure, whereas a shallow copy costs only one level. That's why most real code uses shallow copies deliberately and copies just the parts it changes (see React).

#Shallow cloning techniques

JavaScript
// Objects
const a = { ...obj };
const b = Object.assign({}, obj);

// Arrays
const c = [...arr];
const d = arr.slice();
const e = Array.from(arr);
const f = arr.concat();
What’s happening
  1. { ...obj } (object spread, ES2018) creates a new plain object and copies obj's own enumerable properties into it, including symbol-keyed ones.
  2. Object.assign({}, obj) (ES2015) copies the same properties into the empty target {} and returns that target. With a non-empty first argument it mutates that object, which is why the empty {} is there.
  3. [...arr] uses the array's iterator; Array.from(arr) does too and also accepts array-likes such as a NodeList or arguments.
  4. arr.slice() with no arguments returns a copy of the whole array; arr.concat() with nothing to add does the same. These two are the pre-ES2015 idioms.
  5. Every one of them creates one new container and copies the top-level values into it. Nested objects and arrays inside are shared, exactly as in the toy-box example.
AdvancedWhat spread and Object.assign copy

All of these copy own enumerable properties one level deep. Spread and Object.assign behave almost the same; the difference is that Object.assign sets properties on the target (so it triggers setters), while spread defines new ones.

Two details complete the picture. For arrays, what gets copied is the elements: an extra non-index property like arr.extra = 1 is not copied, and a hole in a sparse array stays a hole with slice/concat but becomes undefined with spread/Array.from. For objects, "own" means the prototype is not copied, which matters for class instances:

JavaScript
class Temp {
  constructor() { this.celsius = 20; }
  get fahrenheit() { return this.celsius * 9 / 5 + 32; }
}
const t = new Temp();
const copy = { ...t };
copy;             // { celsius: 20 }
copy instanceof Temp; // false
copy.fahrenheit;  // undefined (t.fahrenheit is 68)

const target = { set x(v) { console.log("setter ran:", v); } };
Object.assign(target, { x: 1 }); // logs "setter ran: 1"
const merged = { ...target, x: 2 }; // no log; merged is { x: 2 }
What’s happening
  1. new Temp() creates an object whose only own property is celsius: 20. The fahrenheit getter and the link to Temp live on Temp.prototype, not on t.
  2. { ...t } copies own properties only, so copy is a plain { celsius: 20 } whose prototype is Object.prototype. instanceof Temp is false, and copy.fahrenheit finds nothing → undefined.
  3. Object.assign(target, { x: 1 }) performs an ordinary assignment, target.x = 1. target has a setter for x, so the setter runs and logs "setter ran: 1" — no x data property is created.
  4. { ...target, x: 2 } builds a new object by defining properties on it. Spreading target reads x (no getter, so undefined) and defines x: undefined; then x: 2 redefines it. The setter belongs to target, not the new object, so it never runs.
  5. Practical upshot: spread is the safe default for copying data. Object.assign is for deliberately writing into an existing object, setters and all.

#Why React likes shallow copies

React decides whether to re-render by comparing state with Object.is — a reference check, not a deep comparison. If you mutate an object in place, the reference is the same and React thinks nothing changed.

Why a reference check? Comparing two big objects field by field on every update would be slow, and it would have to recurse into every nested object. Comparing two references is a single, constant-time check. React makes a deal with you: if something changed, give me a new object; if nothing changed, give me the same one. Keep that deal and "did it change?" is always just ===.

React
// ❌ Mutation: same reference, React may skip the re-render
user.address.city = "Toronto";
setUser(user);

// ✅ New objects along the path that changed; everything else is shared
setUser({
  ...user,
  address: { ...user.address, city: "Toronto" },
});

// Arrays
setTodos([...todos, newTodo]);                                  // add
setTodos(todos.filter((t) => t.id !== id));                     // remove
setTodos(todos.map((t) => (t.id === id ? { ...t, done: true } : t))); // update
What’s happening
  1. The mutation version changes city inside the existing objects, then passes the same user reference to setUser. React compares Object.is(oldUser, user) → true, concludes nothing changed and bails out of the update. Worse, the "previous" state was edited too, so any code comparing old and new values sees them as identical.
  2. The correct version builds a new outer object (...user) with a new address (...user.address) whose city is "Toronto". The new user and new address fail the === check, so React re-renders.
  3. Every other property is copied by reference: if user had an orders array, newUser.orders === user.orders is still true. Components that receive only orders can see at a glance that it didn't change.
  4. Add: [...todos, newTodo] is a new array containing the old todo objects plus the new one. push would mutate the existing array and fail for the same reason as step 1.
  5. Remove: filter returns a new array without the matching todo. The remaining todo objects are the same references as before.
  6. Update: map returns a new array. The matching todo is replaced by a copy with done: true; every other todo is returned as-is, so it keeps its reference.
AdvancedStructural sharing and when to skip it

This is called structural sharing: you copy only the objects on the path to the change and reuse the rest. It's cheap (no deep clone) and makes "did this change?" a fast === check — which is also what React.memo, useMemo dependencies and Redux selectors rely on.

When not to bother: data that never reaches React state or a store — a local array you build up inside a function before returning it, for example — can be mutated freely. Immutability matters where something else compares references.

AdvancedImmer for deeply nested updates

#Deep clone with JSON (and its drawbacks)

The JSON trick was the standard deep-clone idiom for years because it was built into every environment and needed no library. It works by serialising the object to text and parsing that text back. Text can't hold references, so everything that comes back out is brand new — that's what makes it a deep copy.

JavaScript
const deepClone = JSON.parse(JSON.stringify(original));
// Step 1: stringify takes a "picture" of the object as text
// Step 2: parse builds a brand-new object from that picture
What’s happening
  1. JSON.stringify(original) walks the object recursively and writes it out as a string, e.g. '{"toy":"Car","smallBox":{"toy":"Robot"}}'. At this point no objects exist in the result — just characters.
  2. JSON.parse(...) reads the string and creates a new object for every { } and a new array for every [ ] it meets.
  3. Because the parser has no way to point back at the original objects, nothing is shared: deepClone.smallBox !== original.smallBox.
  4. The catch is step 1: anything JSON's text format can't express is changed or dropped while taking the "picture", and step 2 can't restore it.
AdvancedWhat a JSON round trip loses

It's quick, but JSON can only represent strings, numbers, booleans, null, arrays and plain objects. Everything else is lost or mangled:

ValueAfter a JSON round trip
Functions, undefined, symbolsDropped from objects (become null in arrays)
DateA string
Map, Set{}
NaN, Infinitynull
RegExp{}
Class instancesPlain objects — prototype lost
Circular referencesTypeError: Converting circular structure to JSON
BigIntTypeError
JavaScript
const data = { when: new Date(0), tags: new Set(["a"]), score: NaN, greet() {}, missing: undefined };
JSON.stringify(data);
// '{"when":"1970-01-01T00:00:00.000Z","tags":{},"score":null}'
What’s happening
  1. when: Date has a toJSON method, which stringify calls, producing an ISO string. parse has no idea it was ever a date, so it comes back as the string "1970-01-01T00:00:00.000Z", and calling .getTime() on it would throw.
  2. tags: a Set stores its items in an internal slot, not in properties. stringify only sees own enumerable properties, and a Set has none, so it becomes {} — the data is silently gone.
  3. score: JSON has no NaN or Infinity, so they're written as null.
  4. greet and missing: functions and undefined aren't JSON values, so stringify leaves those keys out entirely. (Inside an array they'd become null instead, to keep the other indexes in place.)
  5. None of this throws — that's the danger. Only circular structures and BigInt raise an error; everything else fails silently, so the JSON trick is only safe when you know the data is plain JSON, such as an API response.

#structuredClone — the better built-in

structuredClone(value) is the platform's deep-clone function (browsers and Node 17+). It uses the same algorithm as postMessage.

That algorithm — the structured clone algorithm — was originally built to copy data between threads (a page and a Web Worker) and into storage like IndexedDB. Since those destinations can't share memory with your code, the algorithm has to be a true deep copy, and it was designed to understand the built-in data types, not just JSON. Exposing it directly as structuredClone gave JavaScript a correct deep copy without a library.

JavaScript
const original = {
  date: new Date(),
  tags: new Set(["a"]),
  nested: { list: [1, 2, 3] },
};
original.self = original; // circular!

const copy = structuredClone(original);
copy.date instanceof Date;   // true
copy.tags.has("a");          // true
copy.self === copy;          // true — circular refs preserved
copy.nested !== original.nested; // true
What’s happening
  1. original.self = original makes the object contain a reference to itself. JSON would throw on this; a naive recursive copy would recurse forever.
  2. structuredClone keeps a "seen" map from each original object to its copy while it walks. On reaching self, it finds original already in the map and reuses the copy instead of starting again.
  3. date is cloned as a real Date with the same timestamp, and tags as a real Set containing "a" — the types JSON would have mangled.
  4. copy.self === copy is true: the cycle is reproduced inside the copy, pointing at the copy rather than back at original.
  5. copy.nested is a new object with a new list array, so editing either one leaves original alone. It also keeps NaN, undefined, BigInt, RegExp and Map values intact, and if two properties point at the same object, the two copies point at one shared copy too.
AdvancedstructuredClone limits: functions, classes

It can't clone functions or DOM nodes (throws DataCloneError), and like JSON it doesn't keep class prototypes or getters — a cloned class instance becomes a plain object.

JavaScript
class Point {
  constructor(x, y) { this.x = x; this.y = y; }
}
const p = structuredClone(new Point(1, 2));
p instanceof Point; // false
p;                  // { x: 1, y: 2 } — a plain object

structuredClone({ onClick() {} }); // DataCloneError: onClick() {} could not be cloned.
What’s happening
  1. new Point(1, 2) has own properties x and y; its methods and identity come from Point.prototype.
  2. structuredClone copies the own data properties into a new plain object. The prototype link isn't part of the data it copies, so p instanceof Point is false and any Point methods are gone.
  3. A getter on the original is read once and its result stored as an ordinary value, so the copy no longer recalculates it.
  4. { onClick() {} } contains a function. Functions carry code and a closure over variables, which can't be transferred to another thread, so the algorithm refuses and throws a DOMException named DataCloneError. Symbol values throw the same way.
  5. In practice: structuredClone is the right default for data — state snapshots, config objects, API results. For objects that carry behaviour (class instances, functions), you need a custom clone or a library.

#Recursive deep clone

A hand-written version is a common interview question. It tests whether you know the type checks (null, arrays, objects) and can apply recursion to a tree. Here's my original, with the bug fixed:

deepClone.js
export default function deepClone(value) {
  // typeof null === "object", so null must be checked first
  if (value === null || typeof value !== "object") {
    return value;
  }

  if (Array.isArray(value)) {
    return value.map((item) => deepClone(item));
  }

  return Object.fromEntries(
    Object.entries(value).map(([key, val]) => [key, deepClone(val)]),
  );
}
What’s happening
  1. Take deepClone({ a: 1, b: { c: [1, { d: 2 }] } }). The value is a non-null object and not an array, so it reaches the last branch. Object.entries gives [["a", 1], ["b", { c: [...] }]].
  2. ["a", 1] → deepClone(1): 1 isn't an object, so the base case returns it unchanged. Primitives are immutable, so returning them as-is is safe.
  3. ["b", {...}] → deepClone({ c: [...] }) recurses: its entries give ["c", [1, { d: 2 }]], and deepClone on the array takes the Array.isArray branch, which maps every item through deepClone — 1 comes back as-is, { d: 2 } becomes a new { d: 2 }.
  4. Each level's Object.fromEntries turns the copied [key, value] pairs back into a new object, so every object and array in the result is new: copy.b !== original.b, copy.b.c !== original.b.c.
  5. Its limits: a Date has no own enumerable keys, so it comes back as {}; a Map or Set likewise becomes {}; functions are returned by reference; prototypes are lost; and a circular structure recurses until RangeError: Maximum call stack size exceeded.
AdvancedCorrecting my notes: null in deepClone
AdvancedDeep clone with dates, maps and cycles

An interview-ready version that also handles dates, maps, sets and circular references:

deepClone-advanced.js
function deepClone(value, seen = new WeakMap()) {
  if (value === null || typeof value !== "object") return value;
  if (seen.has(value)) return seen.get(value); // circular reference

  if (value instanceof Date) return new Date(value);
  if (value instanceof RegExp) return new RegExp(value.source, value.flags);

  if (value instanceof Map) {
    const copy = new Map();
    seen.set(value, copy);
    value.forEach((v, k) => copy.set(deepClone(k, seen), deepClone(v, seen)));
    return copy;
  }

  if (value instanceof Set) {
    const copy = new Set();
    seen.set(value, copy);
    value.forEach((v) => copy.add(deepClone(v, seen)));
    return copy;
  }

  // Keep the prototype so class instances stay class instances
  const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
  seen.set(value, copy);
  for (const key of Reflect.ownKeys(value)) {   // includes symbol keys
    copy[key] = deepClone(value[key], seen);
  }
  return copy;
}
What’s happening
  1. Take orig = { when: new Date(0), point: new Point(3, 4) } with orig.self = orig. The top-level call creates one seen WeakMap; every recursive call passes the same map down, so it remembers every object copied so far in this clone.
  2. orig isn't in seen, isn't a Date/RegExp/Map/Set, so the general branch runs. Object.create(Object.getPrototypeOf(orig)) makes an empty object with the same prototype, and before recursing, seen.set(orig, copy) records the pair.
  3. Reflect.ownKeys(orig) returns every own key — string and symbol, enumerable or not — so when, point and self are visited in turn.
  4. when hits the Date check: new Date(value) creates a new date with the same timestamp. point takes the general branch; its copy's prototype is Point.prototype, so copy.point instanceof Point is true and its methods work.
  5. self points at orig, which is already in seen from step 2, so deepClone returns the existing copy instead of recursing. Result: copy.self === copy. If seen.set came after the loop, this lookup would miss and recurse forever. The same lookup also makes two references to one object produce two references to one copy.
  6. Map and Set get a new empty collection registered in seen first, then each key/value is cloned recursively — Map keys too, since they can be objects. A WeakMap is the conventional choice for seen because its keys are always objects; a plain Map would also work, since seen is discarded when the top-level call returns.
AdvancedWhat the advanced clone still misses

Be ready to name what it still misses, since an interviewer will often ask. It copies non-enumerable properties as enumerable ones and flattens getters into values (copy[key] = value[key] reads the getter, then assigns). It can't copy #private fields — they aren't properties, so Reflect.ownKeys never sees them, and any method that reads one throws TypeError: Cannot read private member on the copy. And it doesn't handle typed arrays, ArrayBuffer, Error or DOM nodes; a cloned Uint8Array ends up as a broken object with the right prototype but no internal buffer.

#Which cloning method should I use?

NeedUse
One level, e.g. React state updateSpread { ...obj } / [...arr]
Deep copy of plain data, Dates, Maps, Sets, cyclesstructuredClone
Deep copy that must keep class prototypes / functionslodash.cloneDeep or a custom function
Quick deep copy of pure JSON dataJSON.parse(JSON.stringify(x))

How to read the table:

  • Spread is the everyday tool. Most "copies" in real code are copy-then-change-one-thing, and structural sharing means you only ever need one level at a time.
  • structuredClone is the default whenever you genuinely need an independent deep copy — an undo snapshot, a copy of state to send to a worker, a config you'll modify per test. It's built in, fast and handles the awkward types.
  • Custom or lodash when the data has behaviour: class instances whose methods you'll call on the copy, or nested functions. Note that functions can't truly be cloned by anything — lodash keeps nested functions by reference.
  • JSON only when the data is known to be plain JSON anyway (an API payload), or when you're about to serialise it regardless and the conversions are what you want.

Lodash cloneDeep has been the battle-tested choice for years; it handles prototypes, typed arrays, Maps/Sets and cycles. With structuredClone built in, most apps no longer need it.

#Object.freeze vs Object.seal vs preventExtensions

These three are about making an object itself unchangeable — something const can't do, since const only locks the variable. They work by flipping flags the engine already keeps. Every object has an internal extensible flag (can new properties be added?), and every property has attributes: configurable (can it be deleted or redefined?) and, for data properties, writable (can its value change?). Each function sets more of these flags than the last, and none of them can be undone.

Add propsDelete propsChange valuesCheck with
Object.preventExtensions❌✅✅Object.isExtensible
Object.seal❌❌✅Object.isSealed
Object.freeze❌❌❌Object.isFrozen
  • preventExtensions flips only the object's extensible flag to false.
  • seal does that and marks every existing property non-configurable.
  • freeze does both and marks every data property non-writable.
JavaScript
"use strict";
const config = Object.freeze({ api: "/v1", retries: 3 });
config.retries = 5;   // TypeError in strict mode (silently ignored otherwise)
config.debug = true;  // TypeError
delete config.api;    // TypeError

const user = Object.seal({ name: "Rohit" });
user.name = "R";      // ✅ allowed
user.age = 30;        // TypeError
delete user.name;     // TypeError
What’s happening
  1. Object.freeze makes config non-extensible and makes api and retries non-writable and non-configurable. It returns the same object (not a copy), which is why you can freeze and assign in one line.
  2. config.retries = 5 writes to a non-writable property → TypeError: Cannot assign to read only property 'retries' of object '#<Object>'. In sloppy mode the assignment is silently ignored and retries stays 3 — a much harder bug to find, which is why the example opts into strict mode.
  3. config.debug = true tries to add a property to a non-extensible object → TypeError: Cannot add property debug, object is not extensible.
  4. delete config.api tries to remove a non-configurable property → TypeError: Cannot delete property 'api' of #<Object>. (In sloppy mode delete just returns false.)
  5. Object.seal leaves name writable, so user.name = "R" works. Adding age and deleting name fail with the same "not extensible" and "cannot delete" errors as above.
  6. Where you'd use each: freeze for constants and configuration that must never change; seal for an object whose shape is fixed but whose values update; preventExtensions rarely, mostly to catch typos that would add a new property. Frozen arrays fail on push and in-place sort, but non-mutating methods like map and toSorted (ES2023) still work, because they return new arrays.
AdvancedPitfall: nested objects stay mutable

#Deep freeze

Object.freeze stops at one level, so to lock an entire structure you apply it to every object reachable from the root. It's the freezing counterpart to a deep clone: walk the tree, and freeze each object you meet.

Recursively freeze every nested object:

deepFreeze.js
function deepFreeze(obj) {
  Object.freeze(obj); // freeze first, so a circular reference back to obj is skipped below
  for (const key of Reflect.ownKeys(obj)) {
    const value = obj[key];
    if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  }
  return obj;
}

const settings = deepFreeze({ theme: { color: "dark" }, langs: ["en"] });
settings.theme.color = "light"; // ignored / TypeError
settings.langs.push("fr");      // TypeError: object is not extensible
What’s happening
  1. deepFreeze(root) freezes the root object first, then loops over its own keys, theme and langs.
  2. theme is a non-null object that isn't frozen yet, so deepFreeze recurses into it: { color: "dark" } is frozen, and its only value is a string, so the recursion stops. Then langs is recursed into and the ["en"] array is frozen.
  3. settings.theme.color = "light" now targets a frozen object. In sloppy mode it's silently ignored and color stays "dark"; in strict mode it throws Cannot assign to read only property 'color'.
  4. settings.langs.push("fr") tries to add index 1 to a frozen array, which fails in either mode, because push itself always throws when it can't write: TypeError: Cannot add property 1, object is not extensible.
  5. On a cycle (a.self = a), the first call freezes a before looping; when the loop reaches self, Object.isFrozen(a) is already true, so the recursion stops.
AdvancedHow deepFreeze handles cycles

Freezing before recursing, plus the isFrozen check, is what stops infinite recursion on circular references. In TypeScript, as const gives you compile-time read-only types without any runtime cost.

The isFrozen check has a side effect worth knowing: an object that's already frozen is assumed to be frozen all the way down, so a shallowly frozen object nested inside is skipped. Two more gaps: Map and Set keep their entries in internal slots, so a frozen Map still accepts .set(); and functions have typeof "function", so they're skipped by the "object" check and stay mutable.

When to use it: deep freezing is a guard rail, not a performance feature. It's most useful in development and tests, to make any accidental mutation of shared state fail loudly at the line that did it. Immer freezes the state it produces by default for this reason. Remember it's irreversible — to "change" frozen data you create a new copy, which is exactly the immutable-update style from the React section.

Built from Rohit’s “Javascript/HTML interview” study doc. Examples target modern browsers and Node 20+.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.