Topic 7: React Lists
📖 9 min read · 🎯 intermediate · 🧭 Prerequisites: state-and-props, events
Why this matters
Up until now, you've been writing each piece of UI by hand — one element, then another. But think about any real app you've used: an inbox full of emails, a product page with dozens of items, a feed that keeps scrolling. You can't write those by hand. The data changes, grows, shrinks. React has a clean, predictable way to turn an array of data into a list of elements on screen — and once you understand it, plus one small but important detail called a key, you'll have the skill behind half of every UI you'll ever build.
What You'll Learn
- Render arrays of data by mapping over them and returning JSX elements
- Understand why
keyprops are mandatory and how to choose stable values for them - Conditionally include or exclude list items based on data properties
- Build lists of stateful components where each card manages its own local state
- Manage a dynamic list with add and remove operations wired to a form
The Analogy
Think of a React list like a post office sorting shelf. Each cubby holds one piece of mail, and every cubby has a number printed on it — that number is the key. When new mail arrives, the sorter glances at the numbers and slots it in the right spot without disturbing the rest. If you ripped the numbers off every cubby, the sorter would have to re-read every piece of mail from scratch just to figure out what moved. React's reconciler works exactly the same way: keys let it make surgical updates instead of blowing away and rebuilding the whole shelf.
Chapter 1: Rendering Lists
Rendering a list in React means calling .map() on an array and returning a JSX node for each element. The returned nodes land in the virtual DOM as siblings, and React renders them as real DOM nodes.
The golden rule: every element produced by .map() needs a key prop — a string or number that is unique among siblings and stable across re-renders.
Example: Rendering a Simple List
src/components/NumberList.js
import React from 'react';
function NumberList({ numbers }) {
const listItems = numbers.map((number) => (
<li key={number.toString()}>{number}</li>
));
return <ul>{listItems}</ul>;
}
export default NumberList;
src/App.js
import React from 'react';
import './App.css';
import NumberList from './components/NumberList';
function App() {
const numbers = [1, 2, 3, 4, 5];
return (
<div className="App">
<h1>Number List</h1>
<NumberList numbers={numbers} />
</div>
);
}
export default App;
When numbers changes (say, a new number is appended), React uses the key to determine that only one new <li> needs to be inserted — leaving the existing four nodes untouched.
Chapter 2: Using Keys in Lists
Keys are React's hint to the reconciler. Without them, React falls back to positional comparison: if item 0 changes, it re-renders item 0. If you insert an item at the top, every position shifts and React re-renders everything. With stable keys React can say "this <li key="2"> is the same node it was last render — skip it."
Rules for choosing keys:
- Use a database ID or any field that is unique per item and never changes across renders (
todo.id,user.id,product.id). - Do not use the array index as a key when the list can be reordered or filtered — index-based keys break the reconciler's diffing.
- Keys do not appear in the rendered DOM; they are internal to React.
Example: Adding Keys to List Items
src/components/TodoList.js
import React from 'react';
function TodoList({ todos }) {
const listItems = todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
));
return <ul>{listItems}</ul>;
}
export default TodoList;
src/App.js
import React from 'react';
import './App.css';
import TodoList from './components/TodoList';
function App() {
const todos = [
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Master Redux' },
{ id: 3, text: 'Explore React Router' },
];
return (
<div className="App">
<h1>Todo List</h1>
<TodoList todos={todos} />
</div>
);
}
export default App;
Each todo has a stable numeric id, so even if the array is sorted or filtered the reconciler always matches the right <li> to the right data.
Chapter 3: Conditional Rendering in Lists
Sometimes an array contains items that should be hidden rather than shown. You can return null from inside .map() for any item that shouldn't render — React silently omits null from the output.
Example: Conditional Rendering
src/components/ProductList.js
import React from 'react';
function ProductList({ products }) {
const listItems = products.map((product) =>
product.available ? <li key={product.id}>{product.name}</li> : null
);
return <ul>{listItems}</ul>;
}
export default ProductList;
src/App.js
import React from 'react';
import './App.css';
import ProductList from './components/ProductList';
function App() {
const products = [
{ id: 1, name: 'Laptop', available: true },
{ id: 2, name: 'Phone', available: false },
{ id: 3, name: 'Tablet', available: true },
];
return (
<div className="App">
<h1>Product List</h1>
<ProductList products={products} />
</div>
);
}
export default App;
Only "Laptop" and "Tablet" appear in the rendered <ul>. The null for "Phone" leaves no trace in the DOM.
An alternative approach is to filter the array before mapping, which produces cleaner JSX and avoids null entries entirely:
const listItems = products
.filter((product) => product.available)
.map((product) => <li key={product.id}>{product.name}</li>);
Both approaches are valid; the filter + map pattern is often easier to read when the condition is complex.
Chapter 4: Handling Lists of Components
A list doesn't have to be <li> tags — it can be a list of full components, each with its own local state. The outer list passes the key to the component; the component itself never sees or uses key (it's consumed by React internally).
Example: List of User Cards
Each UserCard owns its own liked state. Liking one card has zero effect on the others because each component instance is isolated.
src/components/UserCard.js
import React, { useState } from 'react';
function UserCard({ user }) {
const [liked, setLiked] = useState(false);
return (
<div className="user-card">
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={() => setLiked(!liked)}>
{liked ? 'Unlike' : 'Like'}
</button>
</div>
);
}
export default UserCard;
src/components/UserList.js
import React from 'react';
import UserCard from './UserCard';
function UserList({ users }) {
const userCards = users.map((user) => (
<UserCard key={user.id} user={user} />
));
return <div className="user-list">{userCards}</div>;
}
export default UserList;
src/App.js
import React from 'react';
import './App.css';
import UserList from './components/UserList';
function App() {
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
{ id: 3, name: 'Charlie', email: 'charlie@example.com' },
];
return (
<div className="App">
<h1>User List</h1>
<UserList users={users} />
</div>
);
}
export default App;
The key prop on <UserCard key={user.id}> tells React which card instance maps to which user. If the users array were sorted by name, React would reorder the existing component instances rather than remount them — preserving each card's liked state in the process.
Chapter 5: Using Lists with Forms
Real applications need lists that grow and shrink at runtime. The pattern is: store the list in useState, write addItem and removeItem updater functions, pass them as props to child components.
Example: Todo List with Add and Remove
src/components/TodoForm.js
import React, { useState } from 'react';
function TodoForm({ addTodo }) {
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
addTodo(text);
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a new todo"
/>
<button type="submit">Add</button>
</form>
);
}
export default TodoForm;
src/components/TodoItem.js
import React from 'react';
function TodoItem({ todo, removeTodo }) {
return (
<li>
{todo.text}
<button onClick={() => removeTodo(todo.id)}>Remove</button>
</li>
);
}
export default TodoItem;
src/components/TodoListWithForm.js
import React, { useState } from 'react';
import TodoForm from './TodoForm';
import TodoItem from './TodoItem';
function TodoListWithForm() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Master Redux' },
]);
const addTodo = (text) => {
const newTodo = { id: crypto.randomUUID(), text };
setTodos((prev) => [...prev, newTodo]);
};
const removeTodo = (id) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
};
return (
<div>
<h1>Todo List</h1>
<TodoForm addTodo={addTodo} />
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} removeTodo={removeTodo} />
))}
</ul>
</div>
);
}
export default TodoListWithForm;
src/App.js
import React from 'react';
import './App.css';
import TodoListWithForm from './components/TodoListWithForm';
function App() {
return (
<div className="App">
<TodoListWithForm />
</div>
);
}
export default App;
Key patterns in play here:
addTodouses the functional updater form (setTodos(prev => [...prev, newTodo])) so state is always derived from the latest queued value, not a possibly-stale closure overtodos. Two rapid clicks in the same render tick still produce two appended items.removeTodouses the same functional-updater form withArray.filterto produce a new array that excludes the target item byid.crypto.randomUUID()is built into every modern browser and Node 19+, generating a collision-free key with no dependency. AvoidDate.now()as a key source: two events fired in the same millisecond produce identical keys, and tests that mock timers (Vitest / Jest fake timers) makeDate.now()return0on every call.TodoFormcallse.preventDefault()to stop the browser from submitting the form and reloading the page.
flowchart TD
A[TodoListWithForm<br/>state: todos array] -->|addTodo prop| B[TodoForm]
A -->|todos + removeTodo props| C[ul]
C -->|map over todos| D[TodoItem × N]
B -->|user submits form| E[addTodo called]
D -->|user clicks Remove| F[removeTodo called]
E --> G[setTodos spread new item]
F --> H[setTodos filter out id]
G --> A
H --> A
🧪 Try It Yourself
Task: Extend the TodoListWithForm so that each item can be toggled "done". When done, the text should appear with a strikethrough style.
Success criterion: Clicking a todo item's text crosses it out; clicking it again restores it. Adding or removing items should not reset the done state of other items.
Starter snippet — add done to TodoItem:
import React from 'react';
function TodoItem({ todo, removeTodo, toggleTodo }) {
return (
<li>
<span
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.done ? 'line-through' : 'none', cursor: 'pointer' }}
>
{todo.text}
</span>
<button onClick={() => removeTodo(todo.id)}>Remove</button>
</li>
);
}
export default TodoItem;
You'll need to add a toggleTodo function in TodoListWithForm that uses .map() to flip done on the matching item, and pass it down as a prop.
🔍 Checkpoint Quiz
Q1. Why does React require a key prop on list items, and what happens if you omit it?
A) Keys are optional — React works fine without them
B) Keys help React identify changed, added, or removed items; omitting them forces full list re-renders and causes a console warning
C) Keys are only needed when the list has more than 10 items
D) Keys replace the id attribute in the rendered HTML
Q2. What does the following component render when products contains the array shown in Chapter 3?
function ProductList({ products }) {
const listItems = products.map((product) =>
product.available ? <li key={product.id}>{product.name}</li> : null
);
return <ul>{listItems}</ul>;
}
A) Three <li> elements: Laptop, Phone, Tablet
B) Two <li> elements: Laptop, Tablet
C) One <li> element: Phone
D) An empty <ul> because null causes the whole map to short-circuit
Q3. Given this addTodo implementation, what is wrong with it?
const addTodo = (text) => {
const newTodo = { id: Date.now(), text };
todos.push(newTodo); // mutate
setTodos(todos); // pass same reference
};
A) Date.now() is not a valid key source
B) push mutates the existing array; React sees the same array reference and does not re-render
C) setTodos cannot accept an array
D) Nothing is wrong — this is equivalent to the spread pattern
Q4. You have a list of UserCard components, each with its own liked state. If the parent re-sorts the users array and passes it back down, what happens to each card's liked state — assuming each UserCard has a stable key={user.id}?
A) All cards lose their liked state and reset to false
B) The liked state is preserved because React matches component instances by key, not position
C) The component re-mounts because the order changed
D) React throws an error about key collisions
A1. B — Keys let React's reconciler diff the list surgically. Without them React uses array index, which breaks when items are inserted, removed, or reordered, and React logs a warning to the console.
A2. B — The ternary returns null for { id: 2, name: 'Phone', available: false }, so only Laptop and Tablet appear in the <ul>.
A3. B — Array.push mutates the original array in place. When you call setTodos(todos), React receives the same object reference it already has, concludes nothing changed, and skips the re-render. Always produce a new array via the functional updater so you avoid stale-closure bugs as well: setTodos(prev => [...prev, newTodo]). (The id: Date.now() in the snippet is unrelated to the bug being tested; see Chapter 5 for why crypto.randomUUID() is the preferred id source.)
A4. B — React matches each UserCard instance to its previous instance by key. When the order changes, React repositions the existing mounted components rather than unmounting and remounting them, so local state (liked) survives the sort — provided the key value is the same on both renders. If you change the key (e.g., switch from user.id to the array index, or to a freshly-generated UUID each render), React treats them as new components and resets liked to false.
🪞 Recap
- Use
.map()to transform an array of data into an array of JSX elements, and assign a stable, uniquekeyto every element in the result. - Keys should come from the data itself (database IDs, UUIDs) — never from the array index when the list can change order.
- Return
nullinside.map()(or pre-filter with.filter()) to conditionally exclude items without breaking the list structure. - Each component in a list gets its own isolated state; React preserves that state across re-renders as long as the
keystays stable. - Dynamic lists (add / remove) live in
useState; updaters must produce new arrays — spread for add,.filter()for remove,.map()for update — never mutate state directly.
📚 Further Reading
- React docs — Rendering Lists — the authoritative guide on keys, map patterns, and list reconciliation
- React docs — Preserving and Resetting State — deep dive into how keys control component identity
- uuid on npm — production-grade unique ID generation for list keys
- ⬅️ Previous: Events
- ➡️ Next: React Router