jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Immutable State in JavaScript — References, Cloning, structuredClone, and Change Detection

Master JS value vs reference semantics, shallow copy traps, structuredClone limits, immutable updates, Object.freeze, and Map/Set for predictable state.

Why immutability is a production concern {Vì sao immutability là vấn đề production}

You copy state with { ...prev }, mutate a nested field somewhere else, and suddenly a memoized list re-renders everywhere {Bạn copy state bằng { ...prev }, mutate field lồng nhau ở chỗ khác, và đột nhiên list memoized re-render khắp nơi}. Or Redux DevTools shows a pristine “before” snapshot that was already corrupted {Hoặc Redux DevTools hiện snapshot “before” sạch sẽ nhưng thực ra đã bị corrupt}. Or a useEffect keyed on user.settings never fires because you mutated the same object in place {Hoặc useEffect keyed trên user.settings không chạy vì bạn mutate cùng object tại chỗ}.

These are not edge cases — they are the shared-reference bug class {Đây không phải edge case — đó là lớp bug shared-reference}. Senior frontend work means knowing when two variables point at the same heap object, what each copy API actually duplicates, and how immutable updates interact with change detection {Senior frontend nghĩa là biết khi nào hai biến trỏ cùng heap object, mỗi copy API thực sự duplicate gì, và immutable update tương tác change detection thế nào}.

This post covers value vs reference semantics, shallow vs deep cloning, structuredClone, immutable update patterns, Object.freeze, and when to reach for Map/Set instead of plain objects {Bài này cover value vs reference, shallow vs deep clone, structuredClone, pattern immutable update, Object.freeze, và khi nào dùng Map/Set thay plain object}. For async state timing, see the event-loop post; for memory leaks from retained references, see the memory-management post {Với async state timing, xem bài event loop; với memory leak từ reference giữ lại, xem bài memory management}.

Mental model {Mô hình tư duy}: Primitives are copied by value; objects, arrays, functions, and most built-ins are copied by reference (really: the variable holds a pointer, assignment copies the pointer) {Primitive copy theo value; object, array, function và hầu hết built-in copy theo reference (thực ra: biến giữ pointer, assignment copy pointer)}.


Interactive demo {Demo tương tác}

Step through reference assignment, shallow copy, structuredClone, and immutable nested updates — with a live tree showing shared vs newly created nodes {Bước qua reference assignment, shallow copy, structuredClone, và immutable update lồng nhau — với cây live hiện node shared vs mới tạo}.

Open the full demo {Mở demo đầy đủ}: /tools/js-immutability-demo/.


Value vs reference semantics {Ngữ nghĩa value vs reference}

JavaScript has seven primitive types (undefined, null, boolean, number, bigint, string, symbol) and one object type (objects, arrays, functions, dates, etc.) {JavaScript có bảy primitivemột object type (object, array, function, date, v.v.)}.

let x = 10;
let y = x;
y = 20;
console.log(x); // 10 — y got its own copy

const user = { name: 'Alice' };
const alias = user;
alias.name = 'Bob';
console.log(user.name); // 'Bob' — same object in memory
Type categoryAssignment=== between copies
PrimitiveCopies valuetrue only if same value
Object / arrayCopies referencetrue if same object identity

Identity (=== for objects) means “same pointer,” not “same shape” {Identity (=== cho object) nghĩa là “cùng pointer,” không phải “cùng shape”}. Two { name: 'Alice' } literals are never === unless one was assigned from the other {Hai literal { name: 'Alice' } không bao giờ === trừ khi một cái assign từ cái kia}.

const a = { id: 1 };
const b = { id: 1 };
console.log(a === b); // false — different objects, equal shape

Interview vs production {Phỏng vấn vs production}: Interviews test ===; production bugs come from unintended aliasing when you thought you had a copy {Phỏng vấn test ===; bug production đến từ aliasing không chủ ý khi bạn tưởng đã có bản copy}.


The shared-reference bug class {Lớp bug shared-reference}

Real patterns that bite teams {Pattern thật hay cắn team}:

1. “Defensive” spread that is not deep {Spread “phòng thủ” nhưng không deep}

function updateUserName(state, name) {
  const next = { ...state }; // shallow — state.user still shared
  next.user.name = name;     // mutates state.user too!
  return next;
}

2. Default parameters and shared mutable defaults {Default parameter và default mutable dùng chung}

function createItem( tags = [] ) {
  tags.push('new');
  return { tags };
}
// Each call without tags mutates the SAME default array in some engines/patterns
// Prefer: tags = tags ?? [] inside, or factory defaults per call

3. Cache keyed by object you later mutate {Cache keyed bằng object sau đó bị mutate}

const cache = new Map();
const config = { ttl: 60 };
cache.set(config, fetchPromise);
config.ttl = 120; // same key object — cache entry silently "changed"

4. React props and context {React props và context}

const [items, setItems] = useState(initial);
items.push(newItem);       // mutates state in place
setItems(items);           // same reference — React may skip child updates
// Correct:
setItems([...items, newItem]);

The fix is never “just be careful” at scale — it is disciplined copying or immutable update helpers {Fix không phải “cẩn thận” khi scale — là copy có kỷ luật hoặc helper immutable update}.


Shallow copy: what it actually copies {Shallow copy: thực sự copy gì}

Shallow copy creates a new container (object or array) but reuses nested references {Shallow copy tạo container mới (object hoặc array) nhưng tái dùng reference lồng nhau}.

APIResult
{ ...obj }New object; own enumerable string keys copied by reference
Object.assign({}, obj)Same as spread for plain objects
[...arr] / Array.from(arr)New array; elements same references
arr.slice()New array shell; elements shared
Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj))Shallow clone including non-enumerable own props
const a = {
  id: 1,
  user: { name: 'Alice' },
  tags: ['js'],
};

const b = { ...a };
b.id = 2;              // a.id still 1 — top-level primitive
b.user.name = 'Bob';   // a.user.name is 'Bob' — NESTED SHARED
b.tags.push('ts');     // a.tags is ['js', 'ts'] — NESTED SHARED

Rule of thumb {Quy tắc ngón tay cái}: If any value in your tree is an object or array, shallow copy does not isolate nested mutations {Nếu bất kỳ value nào trong cây là object hoặc array, shallow copy không cô lập mutation lồng nhau}.

When shallow copy is enough {Khi shallow copy đủ}: flat config objects, rows with only primitives, or when you intentionally share immutable sub-trees (e.g. interned metadata) {object config phẳng, row chỉ primitive, hoặc khi cố ý share sub-tree immutable (vd metadata interned)}.


Deep clone options {Lựa chọn deep clone}

structuredClone() (platform standard) {structuredClone() (chuẩn platform)}

Available in modern browsers and Node 17+ {Có trên browser hiện đại và Node 17+}. Clones most structured-cloneable types recursively {Clone hầu hết structured-cloneable type đệ quy}.

Supports {Hỗ trợ}: plain objects, arrays, Date, RegExp, Map, Set, ArrayBuffer, typed arrays, circular references (preserved) {plain object, array, Date, RegExp, Map, Set, ArrayBuffer, typed array, circular reference (giữ nguyên)}.

Does NOT clone (throws DataCloneError) {Không clone (ném DataCloneError)}: functions, DOM nodes, symbols as keys (symbol-keyed props dropped in some paths), prototypes/custom classes (plain data copies), property descriptors/getters (data copied as plain values) {function, DOM node, symbol làm key (prop symbol-keyed có thể mất), prototype/class tùy chỉnh (copy data thành plain), descriptor/getter (value copy thành plain)}.

const original = {
  user: { name: 'Alice' },
  tags: ['js'],
  created: new Date(),
  meta: new Map([['v', 1]]),
};

const clone = structuredClone(original);
clone.user.name = 'Bob';
console.log(original.user.name); // 'Alice'

Use structuredClone for state snapshots, undo stacks, worker messages, and offline drafts when you need faithful data graphs without lodash {Dùng structuredClone cho state snapshot, undo stack, worker message, và offline draft khi cần graph data trung thực không cần lodash}.

JSON.parse(JSON.stringify(obj)) {JSON.parse(JSON.stringify(obj))}

The old quick-and-dirty path {Cách cũ nhanh bẩn}. Loses: undefined, function, Symbol, Date (becomes string), Map/Set, RegExp, circular refs (throws) {Mất: undefined, function, Symbol, Date (thành string), Map/Set, RegExp, circular ref (throw)}.

JSON.parse(JSON.stringify({ d: new Date() }));
// { d: "2025-12-02T..." } — not a Date instance

Fine for JSON-serializable API payloads only — not general app state {Ổn cho API payload JSON-serializable thôi — không phải app state nói chung}.

lodash.cloneDeep / custom walkers {lodash.cloneDeep / custom walker}

Still needed when cloning class instances with methods, handling exotic objects, or cloning with custom logic (e.g. redact secrets) {Vẫn cần khi clone class instance có method, object exotic, hoặc logic tùy chỉnh (vd redact secret)}. Trade-off: bundle size and maintenance {Đánh đổi: bundle size và bảo trì}.

ApproachNested isolationFunctions / DOMCircular refsTypical use
Shallow spreadNoN/A (refs copied)OKTop-level swap
structuredCloneYesNoYesSnapshots, workers
JSON round-tripYes (lossy)NoNoDTO clone
cloneDeepYesConfigurableYesLegacy / classes

Why immutability matters for change detection {Vì sao immutability quan trọng cho change detection}

UI libraries and memoization assume you can detect change cheaply {UI library và memoization giả định detect change rẻ}. The dominant check is reference equality (===) {Check phổ biến là reference equality (===)}.

React {React}

function Profile({ user }) {
  return <Avatar name={user.name} />;
}

const MemoAvatar = React.memo(Avatar);

// Parent re-renders but passes same user reference → MemoAvatar skips
// Parent passes new user object (immutable update) → MemoAvatar re-renders

useEffect(..., [deps]) compares deps with Object.is {useEffect(..., [deps]) so sánh deps bằng Object.is}. Mutate deps[0].field in place without changing reference → effect may not run when you expect {Mutate deps[0].field tại chỗ không đổi reference → effect có thể không chạy như mong đợi}.

Memoization and selectors {Memoization và selector}

Reselect, TanStack Query structural sharing, and hand-rolled memo caches key on input references {Reselect, TanStack Query structural sharing, và memo cache thủ công key trên input reference}. Immutable updates make “changed” unambiguous: new root or new branch ⇒ recompute; unchanged subtrees keep same references ⇒ skip work {Immutable update làm “changed” rõ ràng: root/branch mới ⇒ recompute; sub-tree không đổi giữ reference ⇒ bỏ qua}.

Time-travel and debugging {Time-travel và debug}

Redux, Zustand middleware, and undo stacks store snapshots {Redux, Zustand middleware, và undo stack lưu snapshot}. In-place mutation corrupts history: past states mutate with the present {Mutate tại chỗ corrupt lịch sử: state quá khứ đổi theo hiện tại}.

// Broken undo
history.push(state);
state.count += 1; // history[0].count also += 1 if same reference

// Correct
history.push(state);
state = { ...state, count: state.count + 1 };

Performance nuance {Nuance hiệu năng}: Immutability does not mean copying everything every time — structural sharing reuses unchanged subtrees (see Immer below) {Immutability không nghĩa copy mọi thứ mỗi lần — structural sharing tái dùng sub-tree không đổi (xem Immer bên dưới)}.


Immutable update patterns for nested state {Pattern immutable update cho nested state}

Manual deep spreading gets painful fast {Spread deep thủ công đau nhanh}:

// Update user.settings.theme in a nested cart + users tree
return {
  ...state,
  users: {
    ...state.users,
    [userId]: {
      ...state.users[userId],
      settings: {
        ...state.users[userId].settings,
        theme: 'dark',
      },
    },
  },
};

Patterns that scale {Pattern scale được}:

1. Update at the path you touch {Update đúng path cần chạm}

Only clone branches along the path to the changed leaf; siblings keep old references {Chỉ clone nhánh dọc path tới leaf đổi; sibling giữ reference cũ}.

function setTheme(state, userId, theme) {
  const user = state.users[userId];
  if (user.settings.theme === theme) return state; // no-op, same ref

  return {
    ...state,
    users: {
      ...state.users,
      [userId]: {
        ...user,
        settings: { ...user.settings, theme },
      },
    },
  };
}

2. Immer (structural sharing) {Immer (structural sharing)}

Write “mutative” code against a draft; Immer produces the next immutable tree with maximal reference reuse {Viết code “mutative” trên draft; Immer sinh cây immutable tiếp theo với tái dùng reference tối đa}.

import { produce } from 'immer';

const next = produce(state, (draft) => {
  draft.users[userId].settings.theme = 'dark';
});
// next !== state; unchanged branches share references with state

Briefly: Immer is the pragmatic default for nested Redux/Zustand reducers when manual spreads become unreadable {Ngắn gọn: Immer là default thự dụng cho reducer Redux/Zustand lồng nhau khi spread thủ công khó đọc}.

3. Normalized stores {Store normalized}

Flat entities + ids arrays reduce nesting depth — fewer spreads per update {entities phẳng + mảng ids giảm độ sâu lồng — ít spread mỗi update}. See TanStack Query normalized cache patterns for server state {Xem pattern cache normalized TanStack Query cho server state}.


Object.freeze and deep freeze {Object.freeze và deep freeze}

Object.freeze(obj) makes the object shell non-extensible and own properties non-writable/non-configurable {Object.freeze(obj) làm vỏ object không mở rộng và own property không ghi/không cấu hình}. Shallow only — nested objects remain mutable {Chỉ shallow — object lồng vẫn mutable}.

const config = Object.freeze({
  api: { baseUrl: '/api' },
});

config.api.baseUrl = '/evil'; // silently fails in sloppy mode; throws in strict
console.log(config.api.baseUrl); // '/evil' — nested NOT frozen

Deep freeze (recursive) helps for static config trees in dev or library boundaries {Deep freeze (đệ quy) giúp cây config tĩnh trong dev hoặc boundary thư viện}:

function deepFreeze(obj) {
  if (obj === null || typeof obj !== 'object') return obj;
  Object.freeze(obj);
  for (const value of Object.values(obj)) {
    deepFreeze(value);
  }
  return obj;
}

Caveats {Lưu ý}: frozen objects break some libraries expecting mutable drafts; performance cost on large graphs; does not replace immutable update discipline in app code {object freeze phá một số thư viện cần draft mutable; chi phí hiệu năng trên graph lớn; không thay kỷ luật immutable update trong app code}.

APIDepthUse case
Object.freezeShallowSeal exported constants
deepFreezeRecursiveDev-only config, test fixtures
Immutable updatesPer writeApplication state

Map, Set, WeakMap, WeakSet vs plain objects {Map, Set, WeakMap, WeakSet vs plain object}

Plain objects are great for string-keyed records with a stable shape (DTOs, props) {Plain object tốt cho record key string shape ổn định (DTO, props)}. Built-ins cover cases objects handle poorly {Built-in cover case object xử lý kém}:

Map {Map}

  • Any value as key (objects, functions) — identity keyed, not coerced to string {Bất kỳ value làm key — key theo identity, không ép string}
  • Preserves insertion order; size is O(1) {Giữ thứ tự insert; size là O(1)}
  • Frequent add/delete without prototype pollution concerns {Add/delete thường xuyên không lo prototype pollution}
const cache = new Map();
const key = { id: 1 };
cache.set(key, data);
cache.get(key); // works — object key by identity

Use for: entity indexes, memo caches, non-string keys, GraphQL node maps {Dùng cho: index entity, memo cache, key không phải string, map node GraphQL}.

Set {Set}

Unique values by SameValueZero equality; fast membership {Value duy nhất theo equality SameValueZero; membership nhanh}.

const seen = new Set();
seen.add(userId);
if (seen.has(userId)) { /* skip duplicate fetch */ }

Use for: deduping ids, tracking in-flight requests, tag unions {Dùng cho: dedupe id, track request in-flight, union tag}.

WeakMap / WeakSet {WeakMap / WeakSet}

Keys are objects only; entries do not prevent garbage collection of keys {Key chỉ là object; entry không ngăn GC key}. No iteration, no size {Không iterate, không size}.

const privateData = new WeakMap();

function attachSecret(obj, secret) {
  privateData.set(obj, secret);
}
// When obj is GC'd, WeakMap entry disappears — no leak

Use for: private metadata on DOM nodes, caching computed results per object instance without retaining objects forever {Dùng cho: metadata private trên DOM node, cache kết quả tính theo instance object không giữ object mãi}.

StructureKeysGC-friendlyOrderedWhen
Plain objectstring/symbolNoMostlyJSON-shaped records
MapanyNoYesDynamic key sets, object keys
SetNoYesUnique membership
WeakMapobject onlyYes (weak keys)NoSide tables, DOM metadata

Immutability note {Ghi chú immutability}: Updating a Map in immutable style means new Map(oldMap).set(k, v) or Immer (supports Map/Set) — never mutate a Map stored in React state {Update Map theo immutable nghĩa là new Map(oldMap).set(k, v) hoặc Immer (hỗ trợ Map/Set) — không mutate Map trong React state}.


Decision checklist for daily work {Checklist quyết định hàng ngày}

  1. Assigning vs copying? b = a shares everything; spread only clones one level {Assign vs copy? b = a share mọi thứ; spread chỉ clone một level}.
  2. Need full isolation? Prefer structuredClone for data snapshots; avoid JSON unless payload is JSON-native {Cần cô lập hoàn toàn? Ưu tiên structuredClone cho snapshot data; tránh JSON trừ khi payload JSON-native}.
  3. Updating nested app state? Immutable path update or Immer; never mutate prevState in reducers {Update nested app state? Path update immutable hoặc Immer; không mutate prevState trong reducer}.
  4. Detecting change? New reference at the level you care about; rely on structural sharing for perf {Detect change? Reference mới ở level quan tâm; dựa structural sharing cho perf}.
  5. Keying caches? Prefer primitives or stable ids in Map, not mutable config objects {Key cache? Ưu tiên primitive hoặc id ổn định trong Map, không phải config object mutable}.
  6. Sealing data? Object.freeze for shallow constants; deep freeze for static trees; not a substitute for reducer discipline {Seal data? Object.freeze cho constant shallow; deep freeze cho cây tĩnh; không thay kỷ luật reducer}.

Common pitfalls summary {Tóm tắt pitfall thường gặp}

PitfallSymptomFix
Shallow copy + nested mutateOriginal state changesSpread/immutate along path or structuredClone
setState(sameRef)UI stale, memo stuckAlways return new reference when data changes
JSON clone for app stateLost Date, Map, methodsUse structuredClone or domain mapper
Mutable default argsCross-request pollutionFresh default per call
Object.freeze assumed deepNested still mutableDeep freeze or treat as shallow only
Mutating Map in stateReact misses updateCopy-on-write: new Map(m).set()

Takeaways {Kết luận}

Immutability in JavaScript is not a functional-programming aesthetic — it is reference discipline for predictable change detection, debugging, and concurrency boundaries {Immutability trong JavaScript không phải thẩm mỹ FP — là kỷ luật reference cho change detection, debug, và boundary concurrency dự đoán được}. Know what b = a, { ...a }, and structuredClone(a) each guarantee; shallow copy is the silent source of most “ghost” state bugs {Biết b = a, { ...a }, và structuredClone(a) đảm bảo gì; shallow copy là nguồn im lặng của hầu hết bug state “ma”}. For nested updates, clone only the path you change and lean on structural sharing (Immer) when spreads explode {Với update lồng nhau, chỉ clone path bạn đổi và dựa structural sharing (Immer) khi spread bùng nổ}. Reach for Map/Set when keys are objects or membership churns; use WeakMap when metadata must not extend object lifetime {Dùng Map/Set khi key là object hoặc membership thay đổi nhiều; dùng WeakMap khi metadata không được kéo dài lifetime object}.

The interactive demo above lets you see shared nodes light up when nested mutation leaks — worth five minutes before your next reducer refactor {Demo tương tác trên cho bạn thấy node shared sáng lên khi nested mutation rò rỉ — đáng năm phút trước lần refactor reducer tiếp theo}.