Nếu bạn đã từng dành thời gian làm việc với React, bạn chắc chắn đã thấy cảnh báo màu vàng trong console của mình: “Each child in a list should have a unique ‘key’ prop.” Nghe có vẻ như một lời gợi ý lịch sự, nhưng thực tế React đang cảnh báo rằng nó không thể phân biệt được các phần tử trong danh sách của bạn. Nếu phớt lờ nó, cuối cùng bạn sẽ gặp phải một lỗi cực kỳ khó chịu khi tái hiện—như state nhảy sang hàng sai, các ô nhập liệu (text input) bị mất focus, hoặc các hiệu ứng hoạt họa (animations) chạy sai phần tử.
Công cụ render của React không so sánh giao diện người dùng (UI) của bạn theo từng pixel. Nó xây dựng một cây các đối tượng nhẹ gọi là virtual DOM, so sánh cây mới với cây trước đó, và tính toán tập hợp các thay đổi nhỏ nhất cần thiết cho real DOM. Khi bạn render một danh sách, React thấy một mảng các phần tử anh em. Nếu không có key, nó không có cách nào đáng tin cậy để biết liệu một mục đã di chuyển, bị thay thế hay bị xóa bỏ. Theo mặc định, nó sẽ khớp theo vị trí, điều này rất mong manh. Key đóng vai trò là các định danh ổn định. Chúng nói với React rằng: “Phần tử này chính là phần tử trước đó, ngay cả khi hiện tại nó đang ở một vị trí khác.” Nếu làm sai điều này, bạn đang đánh đổi các bản cập nhật có tính xác định (deterministic) để lấy sự phỏng đoán.
Cách khắc phục tối thiểu
Cảnh báo này thường xuất hiện bên trong một lời gọi hàm map. Bạn phải gán một giá trị duy nhất cho thuộc tính key trên phần tử cấp cao nhất được trả về từ hàm lặp.
Dưới đây là mẫu mã mà bạn sẽ thấy trong mọi codebase gây ra cảnh báo này:
const UserList = ({ users }) => {
return (
<ul>
{users.map((user) => (
<li>{user.name}</li>
))}
</ul>
);
};
React thấy ba thẻ <li> và không biết thẻ nào là thẻ nào. Cách khắc phục chỉ là thêm một thuộc tính:
const UserList = ({ users }) => {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
key phải được gán cho phần tử nằm trực tiếp bên trong callback của map. Nếu bạn tách <li> thành một component UserItem riêng biệt, key vẫn phải nằm ở component tại nơi gọi nó:
{users.map((user) => (
<UserItem key={user.id} user={user} />
))}
Việc đặt key bên trong UserItem tại thẻ <div> nội bộ của nó sẽ không làm mất cảnh báo và cũng không sửa được hành vi reconciliation. React tìm kiếm key trên phần tử được trả về bởi hàm lặp.
Tại sao dùng Index làm Key lại nguy hiểm
Sẽ rất dễ bị cám dỗ để dập tắt cảnh báo bằng cách sử dụng đối số thứ hai của map:
{users.map((user, index) => (
<li key={index}>{user.name}</li>
))}
Điều này giúp làm sạch console, nhưng nó không giải quyết được vấn đề cốt lõi. Chỉ số mảng (array indices) không phải là định danh. Chúng là vị trí, và vị trí thì luôn thay đổi.
Hãy tưởng tượng một danh sách gồm ba người dùng được render theo thứ tự này:
- Alice (index 0)
- Bob (index 1)
- Charlie (index 2)
Nếu bạn xóa Alice, Bob sẽ chuyển sang index 0 và Charlie chuyển sang index 1. React so sánh cây mới với cây cũ. Nó thấy rằng index 0 hiện đang giữ dữ liệu của Bob, vì vậy nó sẽ thay đổi (mutate) nút DOM hiện có vốn trước đó đang hiển thị Alice. Nếu nút đó đang được focus, con trỏ sẽ vẫn ở hàng đầu tiên, nhưng văn bản sẽ đổi thành Bob. Nếu hàng đó chứa một <input> với state cục bộ, state đó sẽ bị kẹt lại ở index 0. Người dùng nhập liệu vào thứ trông có vẻ là hàng của Bob, nhưng thực tế state đó lại thuộc về Alice. Sự hỗn loạn tương tự cũng xảy ra khi bạn sắp xếp, lọc, hoặc thêm các mục vào đầu danh sách. Nơi duy nhất an toàn để sử dụng index làm key là một danh sách thực sự tĩnh: không sắp xếp lại, không lọc, không chèn thêm và không xóa. Các liên kết điều hướng được viết cứng (hardcoded) và không bao giờ thay đổi là một ví dụ điển hình. Mọi thứ khác đều cần một định danh thực sự.
Tìm Key ổn định ở đâu
Key tốt nhất là một định danh duy nhất đã tồn tại trong mô hình dữ liệu của bạn. Các khóa chính (primary keys) trong cơ sở dữ liệu như id là lý tưởng vì chúng được đảm bảo là duy nhất và tồn tại qua các lần render. Nếu backend của bạn trả về các đối tượng có uuid, slug, hoặc một trường duy nhất tự nhiên nào đó, hãy sử dụng chúng.
Khi phản hồi API của bạn thiếu bất kỳ trường duy nhất nào, bạn có hai hướng đi thực tế. Thứ nhất, hãy trao đổi với đội ngũ backend và yêu cầu họ thêm một id. Việc gửi dữ liệu quan hệ mà không có khóa chính là một dấu hiệu bất ổn (code smell), và việc khắc phục nó ngay tại nguồn sẽ loại bỏ sự mơ hồ trong toàn bộ stack của bạn. Thứ hai, nếu bạn đang tạo các mục hoàn toàn ở phía client—chẳng hạn như một danh sách todo nơi người dùng tạo các tác vụ trước khi chúng được gửi lên server—hãy tạo một ID một lần duy nhất tại thời điểm khởi tạo. Các thư viện như uuid hoặc nanoid được tạo ra chính xác cho mục đích này. Hãy tạo ID khi người dùng gửi form, lưu nó vào đối tượng và sử dụng nó làm key mãi mãi.
Đừng bao giờ tạo key bên trong luồng render (render path). Việc gọi Math.random() hoặc Date.now() trong quá trình render một component sẽ tạo ra một giá trị mới sau mỗi lần chạy. React thấy một key mới, giả định đó là một phần tử hoàn toàn mới, hủy nút DOM cũ và tạo một nút mới. Mọi state bên trong phần tử đó sẽ bị reset. Focus bị mất. Hiệu suất giảm mạnh vì React đang thực hiện các thao tác DOM không cần thiết. Một key được tạo ngẫu nhiên còn tệ hơn là không có key nào cả.
Fragments, Components, và Scope
Một cái bẫy ít rõ ràng hơn liên quan đến React Fragments. Nếu bạn lặp qua dữ liệu bằng hàm map và cần trả về nhiều phần tử anh em mà không muốn dùng một thẻ bao ngoài <div>, bạn có thể sẽ sử dụng cú pháp viết tắt:
{items.map((item) => (
<>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</>
))}
Cú pháp viết tắt <>...</> không hỗ trợ props, điều này có nghĩa là bạn không thể gắn một key. Trong trường hợp này, hãy chuyển sang cú pháp đầy đủ và tường minh:
{items.map((item) => (
<React.Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</React.Fragment>
))}
React cần key đó trên Fragment để nó có thể theo dõi cặp phần tử như một đơn vị duy nhất qua các lần render.
Một điểm tinh tế khác: key không phải là một prop theo nghĩa thông thường. Nếu bạn viết <ListItem key={item.id} />, component ListItem sẽ không thể đọc được props.key. React sử dụng nó nội bộ để quản lý. Nếu component của bạn thực sự cần định danh đó cho logic riêng, hãy truyền nó riêng biệt dưới một cái tên khác, chẳng hạn như itemId.
Các quy tắc thực tế để giúp bạn an toàn
- Ưu tiên sử dụng ID từ cơ sở dữ liệu. Chúng là duy nhất, dựa trên số hoặc chuỗi, và có tính ổn định.
- Sử dụng
uuidhoặcnanoidcho dữ liệu chỉ có ở phía client. Hãy tạo ID một lần duy nhất khi bản ghi được tạo, thay vì tạo bên trong quá trình render của component. - Đừng bao giờ lấy key từ chỉ số (index) của mảng nếu danh sách có thể thay đổi. Việc sắp xếp, lọc và xóa sẽ gây ra các lỗi về hiển thị và trạng thái (state).
- Đừng bao giờ sử dụng
Math.random(),Date.now(), hoặc bất kỳ giá trị nào thay đổi giữa các lần render. Điều này sẽ ép buộc việc unmounting và remounting không cần thiết. - Hãy nhớ rằng Fragments cần sử dụng dạng đầy đủ nếu chúng nằm trong một hàm
mapvà yêu cầu mộtkey. - Đặt key trên phần tử được trả về bởi hàm
map, chứ không phải bên trong một component con.
Bài học cốt lõi
Prop key không phải là một quy tắc lint mang tính hình thức. Đó là cách React duy trì danh tính (identity) qua các lần render. Hãy coi nó giống như một khóa chính (primary key) trong một bảng cơ sở dữ liệu. Khi danh tính đó ổn định, React có thể di chuyển, cập nhật và xóa các phần tử một cách chính xác. Khi nó bị thiếu hoặc không ổn định, bạn sẽ phải trả giá bằng trạng thái UI bị lỗi và quá trình reconciliation bị chậm chạp. Hãy xử lý nó một lần duy nhất ở lớp dữ liệu, và các danh sách của bạn sẽ hoạt động một cách có thể dự đoán được bất kể chúng lớn lên hay thay đổi bao nhiêu.
