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 primitive và mộ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 category | Assignment | === between copies |
|---|---|---|
| Primitive | Copies value | true only if same value |
| Object / array | Copies reference | true 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.
| API | Result |
|---|---|
{ ...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ì.
| Approach | Nested isolation | Functions / DOM | Circular refs | Typical use |
|---|---|---|---|---|
| Shallow spread | No | N/A (refs copied) | OK | Top-level swap |
structuredClone | Yes | No | Yes | Snapshots, workers |
| JSON round-trip | Yes (lossy) | No | No | DTO clone |
cloneDeep | Yes | Configurable | Yes | Legacy / 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.
| API | Depth | Use case |
|---|---|---|
Object.freeze | Shallow | Seal exported constants |
deepFreeze | Recursive | Dev-only config, test fixtures |
| Immutable updates | Per write | Application 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;
sizelà 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.
| Structure | Keys | GC-friendly | Ordered | When |
|---|---|---|---|---|
| Plain object | string/symbol | No | Mostly | JSON-shaped records |
Map | any | No | Yes | Dynamic key sets, object keys |
Set | — | No | Yes | Unique membership |
WeakMap | object only | Yes (weak keys) | No | Side tables, DOM metadata |
Ghi chú immutability: Update
Maptheo 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
- Assign vs copy?
b = ashare mọi thứ; spread chỉ clone một level. - Cần cô lập hoàn toàn? Ưu tiên
structuredClonecho snapshot data; tránh JSON trừ khi payload JSON-native. - Update nested app state? Path update immutable hoặc Immer; không mutate
prevStatetrong reducer. - Detect change? Reference mới ở level quan tâm; dựa structural sharing cho perf.
- Key cache? Ưu tiên primitive hoặc id ổn định trong
Map, không phải config object mutable. - Seal data?
Object.freezecho 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
| Pitfall | Symptom | Fix |
|---|---|---|
| Shallow copy + nested mutate | Original state changes | Spread/immutate along path or structuredClone |
setState(sameRef) | UI stale, memo stuck | Always return new reference when data changes |
| JSON clone for app state | Lost Date, Map, methods | Use structuredClone or domain mapper |
| Mutable default args | Cross-request pollution | Fresh default per call |
Object.freeze assumed deep | Nested still mutable | Deep freeze or treat as shallow only |
Mutating Map in state | React misses update | Copy-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.