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.

Vì sao immutability là vấn đề production

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. Hoặc Redux DevTools hiện snapshot “before” sạch sẽ nhưng thực ra đã bị corrupt. Hoặc useEffect keyed trên user.settings không chạy vì bạn mutate cùng object tại chỗ.

Đây không phải edge case — đó là lớp bug shared-reference. 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.

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. Với async state timing, xem bài event loop; với memory leak từ reference giữ lại, xem bài memory management.

Mô hình tư duy: 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).


Demo tương tác

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.

Mở demo đầy đủ:


Ngữ nghĩa value vs reference

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 (=== cho object) nghĩa là “cùng pointer,” không phải “cùng shape”. 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

Phỏng vấn vs production: Phỏng vấn test ===; bug production đến từ aliasing không chủ ý khi bạn tưởng đã có bản copy.


Lớp bug shared-reference

Pattern thật hay cắn team:

1. 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 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 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 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]);

Fix không phải “cẩn thận” khi scale — là copy có kỷ luật hoặc helper immutable update.


Shallow copy: thực sự copy gì

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

Quy tắc ngón tay cái: 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.

Khi shallow copy đủ: object config phẳng, row chỉ primitive, hoặc khi cố ý share sub-tree immutable (vd metadata interned).


Lựa chọn deep clone

structuredClone() (chuẩn platform)

Có trên browser hiện đại và Node 17+. Clone hầu hết structured-cloneable type đệ quy.

Hỗ trợ: plain object, array, Date, RegExp, Map, Set, ArrayBuffer, typed array, circular reference (giữ nguyên).

Không clone (ném DataCloneError): 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'

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))

Cách cũ nhanh bẩn. 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

Ổn cho API payload JSON-serializable thôi — không phải app state nói chung.

lodash.cloneDeep / custom walker

Vẫn cần khi clone class instance có method, object exotic, hoặc logic tùy chỉnh (vd redact secret). Đá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

Vì sao immutability quan trọng cho change detection

UI library và memoization giả định detect change rẻ. Check phổ biến là reference equality (===).

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]) so sánh deps bằng Object.is. Mutate deps[0].field tại chỗ không đổi reference → effect có thể không chạy như mong đợi.

Memoization và selector

Reselect, TanStack Query structural sharing, và memo cache thủ công key trên input reference. Immutable update làm “changed” rõ ràng: root/branch mới ⇒ recompute; sub-tree không đổi giữ reference ⇒ bỏ qua.

Time-travel và debug

Redux, Zustand middleware, và undo stack lưu snapshot. 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 };

Nuance hiệu năng: 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).


Pattern immutable update cho nested state

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',
      },
    },
  },
};

Pattern scale được:

1. Update đúng path cần chạm

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)

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

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. Store normalized

entities phẳng + mảng ids giảm độ sâu lồng — ít spread mỗi update. Xem pattern cache normalized TanStack Query cho server state.


Object.freeze và deep freeze

Object.freeze(obj) làm vỏ object không mở rộng và own property không ghi/không cấu hình. 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 (đệ 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;
}

Lưu ý: 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 object

Plain object tốt cho record key string shape ổn định (DTO, props). Built-in cover case object xử lý kém:

Map

  • Bất kỳ value làm key — key theo identity, không ép string
  • Giữ thứ tự insert; size là O(1)
  • 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

Dùng cho: index entity, memo cache, key không phải string, map node GraphQL.

Set

Value duy nhất theo equality SameValueZero; membership nhanh.

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

Dùng cho: dedupe id, track request in-flight, union tag.

WeakMap / WeakSet

Key chỉ là object; entry không ngăn GC key. 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

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

Ghi chú immutability: 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.


Checklist quyết định hàng ngày

  1. Assign vs copy? b = a share mọi thứ; spread chỉ clone một level.
  2. 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. Update nested app state? Path update immutable hoặc Immer; không mutate prevState trong reducer.
  4. Detect change? Reference mới ở level quan tâm; dựa structural sharing cho perf.
  5. Key cache? Ưu tiên primitive hoặc id ổn định trong Map, không phải config object mutable.
  6. Seal data? Object.freeze cho constant shallow; deep freeze cho cây tĩnh; không thay kỷ luật reducer.

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()

Kết luận

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. 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”. Với update lồng nhau, chỉ clone path bạn đổi và dựa structural sharing (Immer) khi spread bùng nổ. 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.

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.