React Basics #8: Lists and key
Last time we covered patterns for drawing the screen differently based on conditions. This time we’ll look at another essential topic: rendering many pieces of data at once, and the special prop key that always shows up alongside.
Rendering an array on screen #
When the data you want to render is an array, use the map method to convert each item into JSX and put the result directly inside JSX.
function FruitList() {
const fruits = ['apple', 'banana', 'cherry'];
return (
<ul>
{fruits.map(fruit => <li key={fruit}>{fruit}</li>)}
</ul>
);
}
export default FruitList;Two key points:
fruits.map(...)builds an array of JSX elements- When React encounters a JSX array inside JSX, it renders the elements one after another
So you can put the array straight into JSX. There’s just one rule: each element needs a prop called key.
Why does key matter? #
key serves as a unique ID that lets React identify each item. When the list changes (items added/removed/reordered), React needs to be able to tell items apart so it can figure out efficiently what changed and how.
Without keys, it’s hard for React to decide whether to draw every element from scratch or reuse the existing ones. The result is degraded performance and, in some cases, subtle bugs like flickering or input fields losing focus to the wrong place.
If you forget keys, React will warn you in the console.
Warning: Each child in a list should have a unique "key" prop.What makes a good key? #
A good key satisfies these conditions:
- Unique — no duplicates among siblings (it doesn’t have to be globally unique, just within the list)
- Stable — the same item should keep the same key across re-renders
The most natural candidate is the unique ID the data already has — a database primary key, an id field from the server, and so on.
function PostList({ posts }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
{post.title}
</li>
))}
</ul>
);
}For simple data without IDs (like a string array), you can use the value itself as the key as long as the values are guaranteed to be unique.
{fruits.map(fruit => <li key={fruit}>{fruit}</li>)}But if “apple” appears twice in the array, you’d have two identical keys and a warning. If that risk exists, it’s safer to give items IDs and treat them as objects.
Can I use the index as the key? #
Since map gives you the index as the second argument, you might think, “Why not just use the index?”
{fruits.map((fruit, index) => <li key={index}>{fruit}</li>)}This works, but the React docs explicitly recommend against it. It causes bugs when the list might be reordered or have items added/removed in the middle.
Where index keys break #
Imagine the following situation.
function TodoList() {
const [todos, setTodos] = useState([
{ text: 'Study React' },
{ text: 'Exercise' },
{ text: 'Read a book' },
]);
return (
<ul>
{todos.map((todo, index) => (
<li key={index}>
{todo.text} <input type="text" placeholder="memo" />
</li>
))}
</ul>
);
}Each item has a memo input next to it. Suppose the user types “7 PM” next to “Exercise.” Then a new todo is added at the front — what happens?
- “Study React” was at index 0, now at index 1
- “Exercise” was at index 1, now at index 2
- The new item is at index 0
React looks at the keys and thinks “oh, item 0 is still here.” But the actual data at that position is now a different item. As a result, the “7 PM” the user typed next to “Exercise” stays next to the wrong item — a strange thing happens.
When index keys are safe #
If the list is static (no add/remove/reorder) and just for display, using an index key is fine. Even then, it’s better to build the habit of using a unique ID if there is one. Lists that start out static often become dynamic later.
crypto.randomUUID() generates a unique ID string. Or use a simple incrementing number (Date.now(), etc.).Splitting into a separate component #
When the contents of <li> get long, it’s typical to split them into a separate component. Remember that the key should be on the top-level element produced by map.
function TodoItem({ todo }) {
return (
<li>
<strong>{todo.text}</strong> — {todo.completed ? 'done' : 'in progress'}
</li>
);
}
export default TodoItem;import TodoItem from './TodoItem';
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
export default TodoList;Don’t put the key on the <li> inside TodoItem; put it on the <TodoItem> itself that map returns. Not somewhere inside the child component, but right where the list is built (the element returned by the map callback).
Combining with filter #
JavaScript array methods chain freely. For example, to show only incomplete todos, filter first and then map.
function TodoList({ todos }) {
return (
<ul>
{todos
.filter(todo => !todo.completed)
.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}The same goes for sorting — you can use sort (or more safely, [...todos].sort(...)).
sort mutates the original array. Mutating an array received as props violates the “props are read-only” rule from #4, and mutating a state array violates the “don’t mutate directly” rule from #5. When you need to sort, always make a copy first like [...todos].sort(...).Handling an empty array #
To show an “empty” message when there’s no data, combine with the conditional rendering you learned in #7.
function TodoList({ todos }) {
if (todos.length === 0) {
return <p>No todos.</p>;
}
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}An empty <ul> is awkward semantically, so an early return reads more naturally.
Try it yourself #
Let’s evolve the MessageForm from #6 into a real list of messages. We’ll use everything you’ve learned up to this point.
Update src/MessageForm.jsx as follows.
import { useState } from 'react';
function MessageForm() {
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [messages, setMessages] = useState([]);
const isValid = name.length > 0 && message.length > 0;
function handleSubmit(e) {
e.preventDefault();
if (!isValid) return;
const newMessage = {
id: crypto.randomUUID(),
name,
message,
createdAt: new Date().toLocaleTimeString(),
};
setMessages(prev => [newMessage, ...prev]);
setName('');
setMessage('');
}
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
type="text"
placeholder="Message"
value={message}
onChange={(e) => setMessage(e.target.value)}
style={{ marginLeft: '8px' }}
/>
<button type="submit" disabled={!isValid} style={{ marginLeft: '8px' }}>
Add
</button>
</form>
<div style={{ marginTop: '16px' }}>
{messages.length === 0 ? (
<p style={{ color: '#888' }}>No messages yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{messages.map(item => (
<li
key={item.id}
style={{ borderBottom: '1px solid #eee', padding: '8px 0' }}
>
<strong>{item.name}</strong>
<span style={{ color: '#888', marginLeft: '8px', fontSize: '12px' }}>
{item.createdAt}
</span>
<p style={{ margin: '4px 0 0 0' }}>{item.message}</p>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default MessageForm;Try adding a few messages. Each new message stacks on top, and the unique ID built with crypto.randomUUID() is used as the key. When the array is empty, you see a placeholder; when there are messages, the list shows.
Everything you’ve learned so far comes together in one screen — props (passing data to children), state (useState), event handling (onSubmit, onChange), conditional rendering (messages.length === 0 ? ... : ...), and the list rendering (map + key) you just learned. Short code, but it shows almost the entire core of React.
Wrapping up #
In this article we covered how to draw arrays on screen and the role of key. The essentials:
- Build a JSX array from your data with
mapand put it inside JSX - Each element needs a unique and stable
key - Prefer the data’s ID; index keys are an anti-pattern
- Put the
keyon the top-level element returned by themapcallback - Combines freely with
filter, sorting, and conditional rendering
This article wraps up the first batch of React Basics (#1–#8). If you’ve followed along this far, you can comfortably build small interactive components like counters, toggles, and message forms. That’s a real milestone.
From the next article, “React Basics #9: Working with forms,” we’ll move into more practical patterns. We’ll cover the canonical pattern for forms (controlled component), and in the following articles useEffect, lifting state up, and Context, one step at a time.