Immutability: không sửa tại chỗ, tạo bản mới — vì sao nó loại bỏ cả lớp bug
Immutable data nghĩa là một khi tạo ra, giá trị không bị sửa tại chỗ; mọi "thay đổi" tạo ra một bản sao mới với phần đã đổi. Cách làm này loại bỏ một nguồn bug lớn: side effect ngầm, nơi một hàm sửa object mà nơi gọi không ngờ tới, gây ra hành vi sai ở một chỗ hoàn toàn khác. Nó cũng là nền của cách React phát hiện thay đổi (so sánh tham chiếu) và của khả năng suy luận về state. Cái giá là sao chép tốn bộ nhớ và CPU khi dữ liệu lớn — nên immutability là một đánh đổi có chủ đích, không phải luật tuyệt đối.
Cơ chế hoạt động
Object và array trong JS được truyền theo tham chiếu, nên sửa tại chỗ ảnh hưởng mọi nơi giữ cùng tham chiếu. Cập nhật immutable tạo bản mới bằng spread thay vì sửa gốc:
// mutable: sửa tại chỗ — gốc bị đổi
function addItem(cart, item) { cart.items.push(item); return cart }
// immutable: trả bản mới, gốc nguyên vẹn
function addItem(cart, item) {
return { ...cart, items: [...cart.items, item] }
}
Bản immutable không đụng cart gốc — mọi nơi còn giữ tham chiếu cũ vẫn thấy giá trị cũ, đúng như mong đợi. Lưu ý spread là shallow copy: chỉ sao chép một cấp; object lồng sâu cần spread từng cấp trên đường tới chỗ đổi.
Vấn đề gặp trong production
Failure mode: side effect ngầm do mutate đối số. Một hàm tiện ích sửa object truyền vào sẽ gây bug ở nơi không liên quan:
function applyDefaults(config) {
config.timeout = config.timeout ?? 30 // BUG: sửa config gốc của caller
return config
}
const shared = { retries: 3 }
applyDefaults(shared) // shared giờ có timeout=30, ngoài ý muốn của nơi khác đang dùng shared
Vì shared được truyền theo tham chiếu và bị sửa tại chỗ, mọi nơi khác giữ shared đột nhiên thấy giá trị đổi. Loại bug này cực khó truy vì nguyên nhân (hàm sửa đối số) và triệu chứng (giá trị sai ở chỗ khác) cách xa nhau. Trả bản mới (return { ...config, timeout: config.timeout ?? 30 }) loại bỏ hẳn.
Failure mode: React không re-render vì mutate state. React quyết định re-render bằng so sánh tham chiếu state. Sửa tại chỗ giữ nguyên tham chiếu nên React tưởng không có gì đổi:
// BUG: cùng tham chiếu mảng -> React bỏ qua, UI không cập nhật
setItems(items => { items.push(newItem); return items })
// đúng: tham chiếu mới
setItems(items => [...items, newItem])
Đây là một trong những bug React phổ biến nhất với người mới: state "đã đổi" nhưng UI đứng yên, vì tham chiếu không đổi.
Failure mode: chi phí sao chép dữ liệu lớn. Spread một mảng/object khổng lồ mỗi lần cập nhật tốn bộ nhớ và CPU; trong vòng lặp nóng hoặc state rất lớn, đây là chi phí thật. Với dữ liệu lớn cần cập nhật thường xuyên, dùng structural sharing (thư viện immutable như Immer hoặc cấu trúc persistent) — chia sẻ phần không đổi giữa các bản, chỉ sao chép phần thay đổi.
Cách debug và monitor
Khi một giá trị "tự đổi" ở nơi không sửa nó, nghi ngay mutate ngầm: tìm các hàm nhận object/array rồi gọi push/splice/sort/gán property lên đối số — đó là các thao tác sửa tại chỗ. Object.freeze (hoặc freeze sâu khi dev) làm mọi mutate ngoài ý muốn throw ngay tại nguồn thay vì gây bug âm thầm. Với bug React "state đổi mà không render", kiểm tra hàm cập nhật có tạo tham chiếu mới không. Đo chi phí: nếu profiler cho thấy nhiều thời gian vào việc sao chép, đó là lúc cân nhắc structural sharing thay vì spread thủ công.
Tradeoff
Immutability loại bỏ side effect ngầm — dễ suy luận (giá trị không đổi sau lưng), dễ debug (theo được dòng thay đổi qua các bản mới), và là nền cho phát hiện thay đổi bằng so sánh tham chiếu (React, memoization). Cái giá là sao chép: tốn bộ nhớ và CPU, đặc biệt với dữ liệu lớn cập nhật thường xuyên, và spread sâu thủ công dễ sai (quên một cấp). Quy tắc thực tế: mặc định immutable cho state ứng dụng và đối số hàm (không mutate cái mình không sở hữu); với dữ liệu lớn/nóng, dùng structural sharing (Immer, persistent structure) để giữ lợi ích mà không trả full copy; và freeze trong môi trường dev để bắt mutate ngoài ý muốn.
Câu hỏi phỏng vấn
Lợi ích của immutable data là gì, và vì sao mutate state trực tiếp làm React không re-render?
Immutable data không sửa giá trị tại chỗ mà tạo bản mới cho mỗi thay đổi, nhờ đó loại bỏ side effect ngầm — không hàm nào sửa object sau lưng nơi gọi — nên dễ suy luận và debug, và cho phép phát hiện thay đổi bằng so sánh tham chiếu thay vì so sánh sâu. React dựa đúng vào so sánh tham chiếu để quyết định re-render: nếu mutate state tại chỗ (items.push(...)), tham chiếu mảng/object không đổi nên React kết luận "không có gì thay đổi" và bỏ qua render, dù nội dung đã khác; phải trả về tham chiếu mới ([...items, newItem]). Điểm ăn điểm: spread là shallow copy nên cập nhật lồng sâu phải spread từng cấp; và đánh đổi là chi phí sao chép với dữ liệu lớn, xử lý bằng structural sharing (Immer/persistent structure) và freeze ở dev để bắt mutate ngoài ý muốn.
Hands-on
Viết một hàm tiện ích nhận một config object dùng chung và sửa nó tại chỗ (gán default), gọi từ nhiều nơi cùng chia sẻ object đó, và quan sát giá trị "tự đổi" ở chỗ không liên quan; sửa thành trả bản mới và xác nhận hết bug. Trong một component React, cập nhật một mảng state bằng push rồi setState để thấy UI không re-render, rồi đổi sang spread tạo tham chiếu mới. Lấy một state lồng sâu thật và viết cập nhật immutable thủ công bằng spread từng cấp (thấy dễ sai), rồi làm lại bằng Immer và so sánh độ rõ ràng; cuối cùng đo chi phí sao chép trên một mảng rất lớn cập nhật trong vòng lặp và so sánh spread với structural sharing.
Top comments (0)