Trong React, bạn cập nhật một mảng hay object trong state bằng cách tạo một bản mới và truyền nó cho hàm set. Đừng bao giờ thay đổi giá trị hiện có tại chỗ: dùng spread (...), map, filter hoặc slice để tạo một bản sao chứa thay đổi, rồi gọi setItems(copy).
[...items, { ... }] là một mảng mới có thêm một phần tử, còn filter trả về một mảng mới không có phần tử bị xóa. Cả hai đều để nguyên mảng cũ.
Vì sao thay đổi trực tiếp không render
Khi bạn gọi hàm set, React so sánh giá trị mới với giá trị cũ bằng Object.is. Với mảng và object, phép so sánh này xét tham chiếu chứ không xét nội dung. Nếu bạn thay đổi mảng bằng push rồi truyền lại chính mảng đó, React thấy cùng một tham chiếu, kết luận không có gì thay đổi, và bỏ qua lần render.
Click "push" vài lần. Console cho thấy mảng đang dài ra, nhưng không có dòng render mới nào và dòng chữ vẫn là a. Sau đó click "spread": một lần render xảy ra và mọi phần tử đã push hiện ra cùng lúc, vì chúng đã nằm trong mảng từ đầu. Thay đổi trực tiếp không gây lỗi, nó chỉ khiến màn hình bị cũ cho đến khi có thứ khác kích hoạt render. Click "push" thêm một lần nữa sau đó, và lần này nó có thể render: React không phải lúc nào cũng bỏ qua được công việc từ trước, nên một thay đổi trực tiếp lúc thì hiện ra, lúc thì không. Chính sự khó đoán đó là lý do không nên thay đổi trực tiếp state.
Mảng: phương thức nên dùng và nên tránh
Quy tắc rất đơn giản: dùng các phương thức trả về một mảng mới, tránh các phương thức thay đổi chính mảng mà chúng được gọi trên đó.
| Mục đích | Tránh (thay đổi trực tiếp) | Dùng (trả về mảng mới) |
|---|---|---|
| Thêm | push, unshift | [...arr, item], [item, ...arr] |
| Xóa | pop, shift, splice | filter, slice |
| Thay thế | arr[i] = x, splice | map, with(i, x) |
| Chèn | splice | [...arr.slice(0, i), x, ...arr.slice(i)], toSpliced |
| Sắp xếp, đảo ngược | sort, reverse | toSorted, toReversed, hoặc sao chép trước |
toSorted, toReversed, toSpliced và with là các phương thức mảng mới hơn (ES2023), được mọi trình duyệt lớn hiện nay hỗ trợ từ năm 2023. Chúng làm cùng việc với các phương thức thay đổi trực tiếp tương ứng, nhưng trên một bản sao.
Cập nhật một phần tử bằng map
Để thay đổi một phần tử, hãy map qua mảng và trả về một object mới cho phần tử bạn muốn, còn mọi phần tử khác trả về object gốc.
Phần { ...todo, done: !todo.done } rất quan trọng. Một bug phổ biến là sao chép mảng nhưng lại thay đổi object bên trong nó:
const next = [...todos];
next[0].done = true; // next is new, but next[0] is the same object as todos[0]
setTodos(next);
Đoạn này vẫn render nên trông có vẻ ổn, nhưng mảng cũ và mảng mới giờ dùng chung một object đã bị thay đổi. Bất cứ thứ gì so sánh phần tử cũ với phần tử mới (một component con dùng React.memo, một effect phụ thuộc vào phần tử đó) đều không thấy khác biệt. Hãy sao chép phần tử bạn thay đổi.
Chèn tại một chỉ số
Không có hàm insert nào không thay đổi mảng gốc, nên hãy cắt mảng làm hai và đặt phần tử mới vào giữa. toSpliced(index, 0, item) làm điều tương tự chỉ trong một lời gọi.
Đặt chỉ số thành 0 rồi chèn lại: chữ C mới sẽ nằm ở đầu. Thay biểu thức next bằng letters.toSpliced(index, 0, 'C') và kết quả vẫn y như vậy.
Sắp xếp và đảo ngược
sort và reverse thay đổi chính mảng mà chúng được gọi trên đó, dù chúng cũng trả về mảng đó. Vì vậy setItems(items.sort()) là một thay đổi trực tiếp đi kèm lệnh set với cùng tham chiếu, đúng là bug ở đầu trang này. Hãy sắp xếp một bản sao.
[...scores].reverse() hoạt động vì spread tạo bản sao trước, rồi reverse chỉ thay đổi bản sao đó.
Cập nhật object và object lồng nhau
Object tuân theo cùng quy tắc. Spread object cũ vào một object mới và ghi đè các trường thay đổi. Với object lồng nhau, hãy spread ở mọi cấp trên đường đi xuống tới trường cần sửa: một lần spread chỉ sao chép cấp ngoài cùng, nên object bên trong vẫn là object cũ.
Mỗi cấp bạn sao chép là một object mới; mọi cấp bạn không đụng tới vẫn là object cũ. Đó là điều khiến spread rẻ: nếu state còn chứa một danh sách đơn hàng dài, { ...user } chỉ sao chép tham chiếu tới danh sách, không sao chép chính danh sách.
Nếu một object trong state lồng sâu ba bốn cấp và bạn cứ phải viết những chuỗi spread dài, đó thường là dấu hiệu state có thể phẳng hơn. Lưu phần tử trong một bảng tra theo id, hoặc tách một object lớn thành vài lời gọi useState, giúp mỗi lần cập nhật ngắn gọn. Khi nhiều sự kiện cùng thay đổi một cấu trúc, reducer cũng giúp ích (xem useReducer).
structuredClone và spread
structuredClone(user) tạo một bản sao sâu chỉ trong một lời gọi, sau đó bạn có thể thoải mái thay đổi bản sao:
const next = structuredClone(user);
next.address.city = 'Oslo';
setUser(next);
Cách này đúng, nhưng nó sao chép toàn bộ object mỗi lần, kể cả những phần bạn không thay đổi. Điều đó tốn thời gian với state lớn, và nghĩa là mọi object lồng bên trong đều có tham chiếu mới, nên một component con đã memo nhận user.orders sẽ render lại dù đơn hàng không hề thay đổi. structuredClone cũng ném lỗi với hàm, và instance của class trở thành object thường. Spread chỉ sao chép đường dẫn bạn thay đổi, nên nó là lựa chọn thông thường; hãy để dành structuredClone cho những lần cập nhật hiếm hoi thật sự viết lại phần lớn object.
Viết thay đổi trực tiếp với Immer
Immer là một thư viện nhỏ cho phép bạn viết code trông như thay đổi trực tiếp. Nó đưa cho bạn một bản nháp (draft), ghi lại những gì bạn thay đổi, và tạo ra một giá trị bất biến mới, chỉ sao chép những phần đã đổi. Package use-immer gói nó vào một hook:
import { useImmer } from 'use-immer';
function Profile() {
const [user, updateUser] = useImmer({
name: 'Ada',
address: { city: 'London' },
});
function moveTo(city) {
updateUser((draft) => {
draft.address.city = city;
});
}
// ...
}
Các trình soạn thảo trên trang này không có Immer, vì chúng chỉ tải React. Thư viện này đáng thêm vào khi state lồng sâu và các chuỗi spread trở nên khó đọc. Với state phẳng, spread và map thông thường là đủ, và đó là những gì bạn sẽ thấy trong phần lớn code React.
Câu hỏi thường gặp
Làm sao thêm một phần tử vào mảng trong state React?
Tạo một mảng mới chứa các phần tử cũ cộng với phần tử mới rồi truyền cho hàm set: setItems([...items, newItem]). Đừng gọi items.push(newItem), vì cách đó thay đổi mảng hiện có và React thấy vẫn là tham chiếu cũ.
Vì sao component không render lại khi cập nhật mảng?
Nhiều khả năng bạn đã thay đổi mảng tại chỗ (push, splice, sort, hoặc arr[i] = x) rồi truyền lại chính mảng đó. React so sánh giá trị cũ và mới bằng Object.is, thấy vẫn là cùng một object, và bỏ qua lần render. Hãy truyền một mảng mới.
Làm sao cập nhật một object bên trong mảng trong React?
Dùng map và trả về một object mới cho phần tử thay đổi, còn mọi phần tử khác giữ nguyên: setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t)).
Làm sao cập nhật object lồng nhau trong state React?
Sao chép mọi cấp trên đường dẫn tới trường bạn thay đổi: setUser({ ...user, address: { ...user.address, city: 'Oslo' } }). Một lần spread chỉ sao chép cấp ngoài cùng, nên object bên trong vẫn bị dùng chung.
Nên dùng structuredClone hay spread operator cho state React?
Nên dùng spread. Nó chỉ sao chép phần thay đổi và giữ nguyên các phần không đụng tới là cùng object, điều này có lợi cho React.memo và các phép so sánh. structuredClone sao chép mọi thứ mỗi lần, viết đơn giản hơn nhưng chậm hơn với state lớn và phá vỡ tính bằng nhau về tham chiếu của những phần không đổi.