Lists and Keys
Real applications rarely render a fixed, hand-written set of elements — they render data: a list of products, a feed of posts, rows in a table. React handles this with plain JavaScript array methods, most commonly .map(), combined with a special prop called key that tells React how to track each item across renders. Getting keys wrong is one of the most common sources of subtle bugs in React apps — state landing on the wrong row, inputs losing their value, animations glitching — so this lesson covers not just the syntax but exactly why keys matter and how to pick good ones.
Overview / How it works
JSX is just JavaScript, so producing multiple elements from an array is nothing more than mapping that array to an array of JSX elements. React can render an array of elements directly — you don’t need a special “list component” or built-in loop syntax. Whenever you write {items.map(item => <li>{item}</li>)} inside JSX, React receives an array of React elements and renders them in order.
The problem is what happens on re-render. Every time a component’s state or props change, React re-runs the component function and gets a brand-new array of elements describing the desired UI. React then has to figure out the minimal set of real DOM changes needed to go from what’s currently on screen to this new description — a process called reconciliation. For a list, that means comparing the old array of elements to the new array, item by item, and deciding which existing DOM nodes to reuse, which to update, which to remove, and which to create fresh.
Without extra information, React would have to guess how items in the old array correspond to items in the new array. By default, if there is no key, React falls back to comparing elements by their position (index) in the array. That works fine as long as the list only ever changes by appending to the end. But if you insert, remove, or reorder items in the middle of the list, position-based matching gets it wrong: React thinks the item at index 2 is “the same item, just with different content,” when really a completely different item slid into that slot. This causes React to update the wrong DOM node instead of moving or removing the right one — and because component state (like an input’s typed text, or a `useState` value) is tied to the position React thinks a component occupies, state can appear to “jump” to the wrong row.
The key prop solves this by giving each item in the array a stable, unique identity that doesn’t depend on its position. React uses keys to match old elements to new elements across renders: an element with key="item-42" in the previous render is matched to the element with key="item-42" in the new render, no matter where it moved to in the array. If a key that existed before is missing from the new array, React unmounts that component and removes its DOM node and state. If a new key appears, React mounts a fresh component. This is exactly the behavior you want when list items are inserted, removed, or reordered.
Keys are also why React can preserve internal state correctly across re-renders: a component’s identity in React’s tree is a combination of its position in the tree and its key (for siblings coming from the same array). Change the key, and React treats it as an entirely different component instance — even if everything else about it is identical.
Syntax
{items.map(item => (
<ComponentOrElement key={item.id}>
{item.someField}
</ComponentOrElement>
))}
| Part | Meaning |
|---|---|
items.map(...) |
Transforms an array of data into an array of JSX elements — plain JavaScript, no special React syntax. |
key={item.id} |
A prop React reads (not passed to your component) to uniquely and stably identify each element among its siblings. |
{item.someField} |
Any expression can be used as JSX children, same as anywhere else in JSX. |
The key must be placed on the outermost element returned for each array item — the element sitting directly inside the array — not on some element buried further down.
Examples
Example 1: A simple list of strings
function FruitList() {
const fruits = ["Apple", "Banana", "Cherry"];
return (
<ul>
{fruits.map(fruit => (
<li key={fruit}>{fruit}</li>
))}
</ul>
);
}
Renders: an unordered list with three items: Apple, Banana, Cherry.
Because the fruit names themselves are unique within this array, using the string as the key is safe here. This only works because the list is static and the values won’t repeat — for most real data you’ll want a dedicated ID field instead (see Common Mistakes).
Example 2: A list of objects with a real ID field
function UserList({ users }) {
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} — {user.email}
</li>
))}
</ul>
);
}
// Example usage:
// <UserList users={[
// { id: "u1", name: "Asha", email: "asha@example.com" },
// { id: "u2", name: "Ravi", email: "ravi@example.com" },
// ]} />
Renders: a bulleted list showing “Asha — asha@example.com” and “Ravi — ravi@example.com”.
Here each user object carries its own stable id from the data source (typically a database primary key). This is the ideal key: unique, stable across re-renders, and independent of the item’s position in the array — exactly what reconciliation needs.
Example 3: A todo list where items are added and removed
import { useState } from "react";
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn React" },
{ id: 2, text: "Build a project" },
]);
const [text, setText] = useState("");
function handleAdd(e) {
e.preventDefault();
if (!text.trim()) return;
setTodos([...todos, { id: Date.now(), text }]);
setText("");
}
function handleRemove(id) {
setTodos(todos.filter(todo => todo.id !== id));
}
return (
<div>
<form onSubmit={handleAdd}>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="New todo"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.text}{" "}
<button onClick={() => handleRemove(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
Renders: a text input with an “Add” button, and a list of todos each with its own “Delete” button. Typing and submitting adds a new row; clicking Delete removes that specific row.
Notice two immutability patterns at once: setTodos([...todos, newTodo]) creates a new array rather than pushing onto the old one, and todos.filter(...) also returns a new array. Because each todo carries a stable id that doesn’t change when other todos are added or removed, React can correctly match old list items to new ones and only touches the DOM nodes that actually changed — deleting the middle item removes exactly that <li>, without disturbing the others.
How it works step by step
On first render (mount): React calls the component, gets back the array of elements from .map(), and creates a real DOM node for every element, in order. Each created component instance is registered in React’s internal tree keyed by (parent, key).
On a state update: the component function runs again, producing a new array of elements. React walks the new array and, for each element, looks up whether an element with the same key existed at the same position in the parent in the previous render. If found, it reuses that component instance and its DOM node, only updating what actually changed (text content, attributes, etc.) — the component’s internal state (from useState, `useRef`, and so on) is preserved. If a key from the old array is absent from the new array, React unmounts that instance (running effect cleanup functions) and removes its DOM node. If a key is new, React mounts a fresh instance.
On unmount: if the entire list (or its parent) is removed from the tree, every item’s cleanup logic runs and all of its DOM nodes are removed together.
Common Mistakes
Mistake 1: Using the array index as the key for a list that can reorder
{/* Wrong: index changes meaning when items are inserted/removed/reordered */}
{todos.map((todo, index) => (
<li key={index}>
{todo.text}
<input defaultValue={todo.text} />
</li>
))}
If you delete the first todo, every remaining item shifts up one index. React sees “key 0 is still here” and reuses that DOM node’s state (including the value the user typed into that <input>) for what is now a different todo — the input text stays put while the underlying data moves. Use a stable, data-derived key instead:
{todos.map(todo => (
<li key={todo.id}>
{todo.text}
<input defaultValue={todo.text} />
</li>
))}
Index keys are acceptable only when the list is static (never reordered, filtered, or has items inserted/removed anywhere but the end) and items have no internal state.
Mistake 2: Omitting the key entirely
{/* Wrong: no key prop */}
{products.map(product => (
<ProductCard product={product} />
))}
React logs a console warning: Warning: Each child in a list should have a unique "key" prop. React still renders the list, but silently falls back to index-based matching, reintroducing all the bugs above. Always add an explicit key:
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
Mistake 3: Mutating the array instead of creating a new one
{/* Wrong: mutates state directly, React won't detect the change */}
function addTodo(text) {
todos.push({ id: Date.now(), text });
setTodos(todos);
}
Because todos is still the same array reference, React’s state update may not trigger a re-render as expected, and you lose the ability to reliably diff old vs. new items. Always build a new array:
function addTodo(text) {
setTodos([...todos, { id: Date.now(), text }]);
}
Best Practices
- Use a stable, unique identifier from your data (a database ID, a UUID) as the key — never generate it during render with
Math.random(), since that produces a new key every render and defeats reconciliation entirely. - Only use the array index as a key for lists that are truly static: never reordered, filtered, or spliced, and whose items hold no internal state or uncontrolled inputs.
- Keep keys unique only among siblings coming from the same array — keys don’t need to be globally unique across the whole app, just within one list.
- Put the key on the top-level element returned inside
.map(), not on some nested child. - Never pass
keydown as if it were a regular prop — React strips it before your component receives its props, so if you need the same value inside the component, pass it again under a different prop name. - Always update lists immutably (
[...arr, x],arr.filter(...),arr.map(...)) rather than mutating withpush,splice, or index assignment. - Extract non-trivial list items into their own component so each `.map()` callback stays small and readable.
Practice Exercises
- Build a
ContactListcomponent that receives an array of{ id, name, phone }objects as a prop and renders each as a list item showing the name and phone number, using the correct key. - Extend the todo list example from Example 3 with a “Mark complete” button that toggles a
completedboolean on the matching todo (usingsetTodos(todos.map(...))to update immutably) and renders completed items with strikethrough text. - Take a list rendered with
key={index}and a text input per row, add a “Sort alphabetically” button, and observe what happens to text typed into the inputs before and after fixing the key to use a stable ID — write down what you notice.
Summary
- Render lists in JSX with
array.map(), which is plain JavaScript, not special React syntax. - Every element produced inside a list needs a unique
keyprop so React can match old and new elements during reconciliation. - Good keys are stable and derived from the data itself (an ID), not from array position.
- Using the array index as a key breaks when the list is reordered, filtered, or has items inserted/removed anywhere but the end — state and DOM can attach to the wrong item.
- Keys are stripped before reaching your component as props — read them again under a different name if the component itself needs the value.
- Always update list state immutably so React can detect and correctly diff the change.
