Phải tạo bản sao mới ở mọi cấp nằm trên đường đi tới giá trị cần đổi. React so state cũ và mới bằng Object.is, tức so tham chiếu — sửa tại chỗ rồi gọi setter thì React thấy "vẫn là object cũ" và bỏ qua lần render.
// Sai: sửa tại chỗ, React không render lại
user.address.city = 'Hà Nội'
setUser(user)
// Đúng: sao chép từng cấp trên đường đi
setUser((prev) => ({
...prev,
address: { ...prev.address, city: 'Hà Nội' },
}))Chỉ những cấp trên đường đi mới cần sao chép. Các nhánh khác giữ nguyên tham chiếu cũ — vừa nhanh hơn, vừa giúp React.memo ở nhánh đó không phải render lại.
Với mảng, quy tắc tương tự: dùng hàm trả về mảng mới, tránh hàm sửa tại chỗ.
setItems((prev) => [...prev, item]) // thêm
setItems((prev) => prev.filter((i) => i.id !== id)) // xoá
setItems((prev) => prev.map((i) => (i.id === id ? { ...i, done: true } : i))) // sửapush, splice, sort, reverse đều sửa tại chỗ. Cần sắp xếp thì sao chép trước: [...items].sort(...).
Khi state lồng sâu tới ba, bốn tầng, đoạn spread trở nên khó đọc và dễ sót. Hai hướng xử lý, theo thứ tự nên thử:
1. Làm phẳng cấu trúc state. Lồng sâu thường là dấu hiệu state đang mô phỏng nguyên hình dạng response API. Tách thành nhiều state nhỏ, hoặc lưu theo id.
2. Dùng Immer (useImmer). Nó cho phép viết như đang sửa tại chỗ nhưng sinh ra bản mới bất biến ở bên dưới.
Lưu ý: Chọn cách 1 trước. Immer làm đoạn code dễ đọc hơn nhưng không sửa được cái gốc: state lồng sâu vẫn khiến mỗi lần cập nhật dài dòng và dễ sót một cấp, đồng thời đẩy nguyên object lớn qua props xuống dưới.