📜 Lists and Keys
Real applications work with collections of data - users, products, posts, comments, messages, and more. Rendering lists efficiently is a fundamental React skill. But there's a catch: React needs a special "key" prop to track items in lists. Get it wrong, and you'll have bugs that are hard to debug. Get it right, and your lists will be fast, predictable, and bug-free. In this lesson, you'll master rendering lists, understand why keys matter, and learn how to perform CRUD operations on dynamic lists. Let's make lists awesome! 🚀
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Render arrays of data using map()
- Understand why the key prop is critical
- Choose appropriate keys for different scenarios
- Implement CRUD operations on lists
- Use filter(), reduce(), and other array methods
- Type arrays properly with TypeScript
- Handle empty lists gracefully
- Optimize list rendering performance
- Build dynamic, interactive lists
Estimated Time: 70-85 minutes
Project: Build an enhanced todo list with filtering, sorting, and search
In This Lesson
📋 Rendering Lists
Think about the apps you use every day: your inbox is a list of emails, your feed is a list of posts, your cart is a list of products. Almost every screen you'll ever build is, at its heart, a collection of data drawn on screen. So the question is simple but fundamental: how do you turn an array of data into a stack of UI?
React's answer is delightfully plain — there's no special "loop" syntax. You use the same JavaScript .map() you already know to transform an array of data into an array of JSX elements, and React renders them. If you can write a for-loop's worth of logic in one line, you can render a list. 🎯
Basic List Rendering
Simple Array to JSX
const SimpleList: React.FC = () => {
const fruits = ['Apple', 'Banana', 'Cherry', 'Date'];
return (
<ul>
{fruits.map((fruit, index) => (
<li key={index}>{fruit}</li>
))}
</ul>
);
};
// Renders:
// • Apple
// • Banana
// • Cherry
// • Date
How map() Works
Here's the mental model: .map() is a machine that takes an array in one end and produces a new array out the other, running your function on each item along the way. It's like a conveyor belt in a factory — raw parts go in, finished products come out, and the original parts are never touched. In React we just make the "finished product" a piece of JSX.
Understanding the Transformation
// What we start with
const numbers = [1, 2, 3];
// map() transforms each item
const doubled = numbers.map(num => num * 2);
// [2, 4, 6]
// In React, we transform data into JSX
const listItems = numbers.map(num => <li>{num}</li>);
// [<li>1</li>, <li>2</li>, <li>3</li>]
// JSX expression:
<ul>{listItems}</ul>
Rendering Objects
Strings are the easy case. In real apps your arrays are almost always arrays of objects — a user has a name, an email, an id; a product has a title, a price, a photo. The pattern doesn't change at all: you .map() over the objects and reach into each one for the fields you want to display.
Lists of Objects
interface User {
id: number;
name: string;
email: string;
}
const UserList: React.FC = () => {
const users: User[] = [
{ 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>
{users.map(user => (
<div key={user.id} style={{ padding: '1rem', border: '1px solid #ddd', marginBottom: '0.5rem' }}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
);
};
Common Pattern: Extract to Component
Keep It Clean
interface User {
id: number;
name: string;
email: string;
}
// Individual item component
interface UserCardProps {
user: User;
}
const UserCard: React.FC<UserCardProps> = ({ user }) => (
<div style={{ padding: '1rem', border: '1px solid #ddd', marginBottom: '0.5rem' }}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
// List component
const UserList: React.FC = () => {
const users: User[] = [
{ 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>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
};
✅ This is cleaner and more maintainable!
💡 Key Insight
The pattern is always the same:
- Have an array of data
- Use
.map()to transform each item - Return JSX for each item
- Add a unique
keyprop
🔑 The Key Prop
If you've ever seen the yellow warning "Each child in a list should have a unique key prop" in your console, this section is for you. Keys are one of React's most misunderstood features — partly because when you get them wrong, the app usually still looks fine, right up until it produces a baffling bug that seems to have nothing to do with lists. Let's demystify them so you never get bitten. 🔍
Here's the intuition: imagine a coat check at a theater. When you hand over your coat, you get a numbered ticket. That ticket is how the attendant matches you to your coat later, no matter how many other coats came and went in the meantime. A key is that ticket. It gives each list item a stable identity so React can tell, between one render and the next, which item is which — even when items are added, removed, or reordered.
📖 Definition
Key Prop: A special attribute that helps React identify which items have changed, been added, or been removed. Keys give list items a stable identity.
Why Keys Matter
What Happens Without Keys?
// ❌ Bad: No keys
<ul>
{items.map(item => <li>{item}</li>)}
</ul>
// React console warning:
// Warning: Each child in a list should have a unique "key" prop.
Without keys, React doesn't know which element is which. This causes:
- ⚠️ React warns in console
- ⚠️ Potential rendering bugs
- ⚠️ Poor performance on updates
- ⚠️ State can get mixed up
How Keys Work
On every render, React lines up the new list of keys against the previous one and asks four questions per item: is this key still here (update it), brand new (create it), gone (delete it), or in a different spot (move it)? This diff is what makes React fast — it does the smallest amount of DOM work possible. Keys are the labels that make that comparison reliable.
Keys Must Be Unique Among Siblings
Uniqueness Rules
// ✅ Good: Unique keys among siblings
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
// ✅ Also good: Different lists can reuse keys
<div>
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
</div>
// ❌ Bad: Duplicate keys in same list
<ul>
<li key="1">Item 1</li>
<li key="1">Item 2</li> {/* Duplicate key! */}
</ul>
🎨 How Keys Help React Track Items
The Index as Key Problem
⚠️ Warning: Using Index as Key
// ⚠️ Problematic in many cases
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
Why it's problematic:
Imagine you have a list: ["A", "B", "C"]
key=0: A
key=1: B
key=2: C
You remove "A". Now you have: ["B", "C"]
key=0: B (was key=1 before!)
key=1: C (was key=2 before!)
React thinks items at key=0 and key=1 changed content, when really you just deleted one item!
When index as key is okay:
- ✅ List is static (never changes)
- ✅ Items are never reordered
- ✅ Items are never deleted
- ✅ Items have no state or controlled inputs
When NOT to use index:
- ❌ Items can be added/removed
- ❌ List can be sorted/filtered
- ❌ Items have input fields
- ❌ Items have local component state
Example: The Bug Index Keys Can Cause
This is the classic "haunted checkbox" bug, and it trips up nearly every React developer at least once. The list looks right, but state clings to a position instead of to an item — so deleting one row makes a checkmark mysteriously jump to a different row. Read this example carefully; understanding it once will save you hours of confusion later.
Demonstration of the Problem
interface TodoItemProps {
text: string;
}
const TodoItem: React.FC<TodoItemProps> = ({ text }) => {
const [done, setDone] = useState(false);
return (
<div>
<input
type="checkbox"
checked={done}
onChange={() => setDone(!done)}
/>
{text}
</div>
);
};
// ❌ Using index as key
const BadTodoList: React.FC = () => {
const [todos, setTodos] = useState(['Task A', 'Task B', 'Task C']);
return (
<div>
{todos.map((todo, index) => (
<TodoItem key={index} text={todo} /> {/* Bug! */}
))}
<button onClick={() => setTodos(todos.slice(1))}>
Remove first item
</button>
</div>
);
};
// What happens:
// 1. Check "Task A"
// 2. Remove first item
// 3. Now "Task B" appears checked! (because it has the old key=0)
✅ The Fix: Use Stable IDs
interface Todo {
id: number; // or string
text: string;
}
const GoodTodoList: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([
{ id: 1, text: 'Task A' },
{ id: 2, text: 'Task B' },
{ id: 3, text: 'Task C' }
]);
return (
<div>
{todos.map(todo => (
<TodoItem key={todo.id} text={todo.text} /> {/* ✅ Correct! */}
))}
</div>
);
};
🎯 Choosing Good Keys
So keys matter — but where do you actually get one? A good key has three properties, and it's worth memorizing them: it's stable (the same item keeps the same key across renders), unique (no two siblings share it), and consistent (it doesn't get regenerated on every render). Think of it like a social security number: assigned once, never reused, never randomly reissued.
The golden rule: the key should come from your data, not from the loop. An item's identity belongs to the item itself — its database id, its UUID — not to whatever position it happens to occupy in an array today.
Best Key Sources
| Source | When to Use | Example |
|---|---|---|
| Database ID | Data from backend with IDs | key={user.id} |
| UUID/GUID | Generated unique IDs | key={item.uuid} |
| Unique Property | Natural unique identifier | key={user.email} |
| Generated on Creation | Client-side items | key={Date.now()} |
| Composite Key | Multiple properties unique | key={`${cat}-${id}`} |
| Index | Static, never-changing list | key={index} |
Generating Keys for New Items
Pattern: Add ID When Creating
interface Todo {
id: number;
text: string;
completed: boolean;
}
const TodoApp: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
const newTodo: Todo = {
id: Date.now(), // Simple unique ID
text: text,
completed: false
};
setTodos([...todos, newTodo]);
};
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
};
Better ID Generation
Using Libraries or Crypto
// Option 1: Use crypto.randomUUID() (modern browsers)
const id = crypto.randomUUID();
// "36b8f84d-df4e-4d49-b662-bcde71a8764f"
// Option 2: Use nanoid library
import { nanoid } from 'nanoid';
const id = nanoid();
// "V1StGXR8_Z5jdHi6B-myT"
// Option 3: Simple counter (if single user)
let nextId = 1;
const generateId = () => nextId++;
const addTodo = (text: string) => {
const newTodo = {
id: generateId(), // or crypto.randomUUID()
text,
completed: false
};
setTodos([...todos, newTodo]);
};
✅ Key Guidelines
- Stable: Same item always has same key
- Unique: No two siblings share a key
- Consistent: Key doesn't change between renders
- Not Random: Don't use
Math.random()as keys!
⚠️ Common Mistakes
// ❌ Bad: Random key (changes every render!)
key={Math.random()}
// ❌ Bad: Non-unique key
key="same-key-for-all"
// ❌ Bad: Duplicate keys
items.map(item => <li key={1}>...</li>)
// ❌ Bad: Complex object as key (must be string/number)
key={{id: 1}}
🎨 Keys Quick Reference
🔧 Array Methods (map, filter, reduce)
Here's a mindset shift that will make you a much better React developer: stop reaching for loops that mutate, and start thinking in transformations. React's whole model is "given this data, here's the UI" — so the tools you want are the ones that take an array and hand you a brand-new array (or value) without touching the original. That's exactly what map, filter, and reduce do.
A quick analogy for the big three: map is a translator — same number of items, each one converted. filter is a bouncer — it lets some items through and turns others away. reduce is an accountant — it runs through everything and reports one final total. Master these three and you can express almost any list operation you'll ever need. 🛠️
map() - Transform Each Item
Converting Data to JSX
// map() creates a new array by transforming each element
const numbers = [1, 2, 3, 4, 5];
// Example 1: Double each number
const doubled = numbers.map(n => n * 2);
// [2, 4, 6, 8, 10]
// Example 2: Convert to JSX
const listItems = numbers.map(n => <li key={n}>{n}</li>);
// [<li>1</li>, <li>2</li>, ...]
// Example 3: Extract properties
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
const names = users.map(user => user.name);
// ['Alice', 'Bob']
const userCards = users.map(user => (
<div key={user.id}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
));
filter() - Select Items
Filtering Lists
// filter() creates a new array with items that pass a test
const numbers = [1, 2, 3, 4, 5, 6];
// Example 1: Get even numbers
const evens = numbers.filter(n => n % 2 === 0);
// [2, 4, 6]
// Example 2: Filter completed todos
interface Todo {
id: number;
text: string;
completed: boolean;
}
const todos: Todo[] = [
{ id: 1, text: 'Learn React', completed: true },
{ id: 2, text: 'Build app', completed: false },
{ id: 3, text: 'Deploy', completed: false }
];
const completed = todos.filter(todo => todo.completed);
// [{ id: 1, text: 'Learn React', completed: true }]
const active = todos.filter(todo => !todo.completed);
// [{ id: 2, ... }, { id: 3, ... }]
// Example 3: Search functionality
const searchTerm = 'react';
const searchResults = todos.filter(todo =>
todo.text.toLowerCase().includes(searchTerm.toLowerCase())
);
// [{ id: 1, text: 'Learn React', completed: true }]
reduce() - Compute Single Value
Aggregating Data
// reduce() combines all elements into a single value
const numbers = [1, 2, 3, 4, 5];
// Example 1: Sum
const sum = numbers.reduce((total, num) => total + num, 0);
// 15
// Example 2: Count completed todos
const completedCount = todos.reduce((count, todo) =>
count + (todo.completed ? 1 : 0), 0
);
// Example 3: Calculate total price
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
const cart: CartItem[] = [
{ id: 1, name: 'Book', price: 12.99, quantity: 2 },
{ id: 2, name: 'Pen', price: 1.99, quantity: 5 }
];
const total = cart.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
// 35.93
// Example 4: Group by property
const grouped = todos.reduce((groups, todo) => {
const key = todo.completed ? 'completed' : 'active';
if (!groups[key]) groups[key] = [];
groups[key].push(todo);
return groups;
}, {} as Record<string, Todo[]>);
// { completed: [...], active: [...] }
Chaining Array Methods
Combining Operations
// You can chain methods for powerful transformations
const todos: Todo[] = [
{ id: 1, text: 'Learn React', completed: true },
{ id: 2, text: 'Build App', completed: false },
{ id: 3, text: 'Learn TypeScript', completed: true },
{ id: 4, text: 'Deploy', completed: false }
];
// Example 1: Filter then map
const completedTexts = todos
.filter(todo => todo.completed)
.map(todo => todo.text);
// ['Learn React', 'Learn TypeScript']
// Example 2: Filter, map, then render
const ActiveTodoList: React.FC = () => {
return (
<ul>
{todos
.filter(todo => !todo.completed)
.map(todo => (
<li key={todo.id}>{todo.text}</li>
))
}
</ul>
);
};
// Example 3: Search, filter, sort, map
const searchTerm = 'learn';
const searchResults = todos
.filter(todo => todo.text.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => a.text.localeCompare(b.text))
.map(todo => (
<div key={todo.id}>
<strong>{todo.text}</strong>
<span>{todo.completed ? ' ✓' : ''}</span>
</div>
));
Other Useful Array Methods
| Method | Purpose | Example |
|---|---|---|
find() |
Get first matching item | todos.find(t => t.id === 2) |
findIndex() |
Get index of first match | todos.findIndex(t => t.id === 2) |
some() |
Check if any item matches | todos.some(t => t.completed) |
every() |
Check if all items match | todos.every(t => t.completed) |
slice() |
Get portion of array | todos.slice(0, 5) |
sort() |
Sort array (copy first!) | [...todos].sort((a,b) => ...) |
✅ Array Methods Cheat Sheet
- map(): Transform each item → new array
- filter(): Select items that match → new array
- reduce(): Combine all items → single value
- find(): Get first match → single item or undefined
- some(): Any item matches? → boolean
- every(): All items match? → boolean
🎮 Interactive Demo: Array Methods in Action
Click a method to run it against the array. Notice that map() and filter() each return a brand-new array, while reduce() collapses everything into a single value — the original array is never mutated.
const arr: number[] = [1, 2, 3, 4, 5, 6];
👆 Pick a method to see its result and its return type.
✏️ CRUD Operations
CRUD — Create, Read, Update, Delete — is the beating heart of nearly every interactive app. Add a todo (Create), see your todos (Read), check one off (Update), throw one away (Delete). Learn to do these four cleanly and you can build a task manager, a shopping cart, a contact book, or a spreadsheet.
There's one rule that ties all of them together in React, and it's non-negotiable: never mutate state directly. Don't push, don't reassign todos[0].done = true, don't splice. Instead, produce a new array and hand it to setState. Why? Because React decides whether to re-render by comparing the old array reference to the new one. Mutate in place and the reference never changes — React sees "same array" and your UI silently goes stale. The array methods below are perfect for this because every one of them returns a fresh array.
Complete CRUD Example
Full Todo App with CRUD
import React, { useState } from 'react';
interface Todo {
id: number;
text: string;
completed: boolean;
}
const TodoApp: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([
{ id: 1, text: 'Learn React', completed: false },
{ id: 2, text: 'Build App', completed: false }
]);
const [inputText, setInputText] = useState('');
// CREATE - Add new todo
const addTodo = () => {
if (inputText.trim() === '') return;
const newTodo: Todo = {
id: Date.now(),
text: inputText,
completed: false
};
setTodos([...todos, newTodo]);
setInputText('');
};
// READ - Already handled by rendering the list
// UPDATE - Toggle completion
const toggleTodo = (id: number) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
};
// UPDATE - Edit text
const updateTodoText = (id: number, newText: string) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, text: newText }
: todo
));
};
// DELETE - Remove todo
const deleteTodo = (id: number) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// DELETE - Clear completed
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
<h1>Todo List</h1>
{/* CREATE */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTodo()}
placeholder="Add a todo..."
style={{ flex: 1, padding: '0.5rem' }}
/>
<button onClick={addTodo} style={{ padding: '0.5rem 1rem' }}>
Add
</button>
</div>
{/* READ - Display list */}
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map(todo => (
<li
key={todo.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem',
background: '#f5f5f5',
marginBottom: '0.5rem',
borderRadius: '4px'
}}
>
{/* UPDATE - Toggle */}
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#000'
}}
>
{todo.text}
</span>
{/* DELETE */}
<button
onClick={() => deleteTodo(todo.id)}
style={{
background: '#f44336',
color: 'white',
border: 'none',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Delete
</button>
</li>
))}
</ul>
{/* Additional actions */}
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between' }}>
<span>{todos.filter(t => !t.completed).length} items left</span>
<button onClick={clearCompleted}>Clear completed</button>
</div>
</div>
);
};
export default TodoApp;
🎮 Interactive Demo: CRUD in Action
Each button runs one immutable operation and shows the resulting todos array. Nothing is edited in place — Create spreads into a new array, Update maps, and Delete filters.
const [todos, setTodos] = useState<Todo[]>(initial);
👆 Run an operation to see the new immutable state.
CRUD Operation Patterns
| Operation | Pattern | Example |
|---|---|---|
| Create (Add) | Spread and append | [...items, newItem] |
| Read (Display) | Map to JSX | items.map(item => <li>...</li>) |
| Update | Map and replace | items.map(i => i.id === id ? updated : i) |
| Delete | Filter out | items.filter(i => i.id !== id) |
Bulk Operations
Operating on Multiple Items
// Select/deselect all
const [todos, setTodos] = useState<Todo[]>([]);
const toggleAll = () => {
const allCompleted = todos.every(todo => todo.completed);
setTodos(todos.map(todo => ({
...todo,
completed: !allCompleted
})));
};
// Delete multiple
const deleteMultiple = (idsToDelete: number[]) => {
setTodos(todos.filter(todo => !idsToDelete.includes(todo.id)));
};
// Update multiple
const markSelectedAsCompleted = (selectedIds: number[]) => {
setTodos(todos.map(todo =>
selectedIds.includes(todo.id)
? { ...todo, completed: true }
: todo
));
};
Optimistic Updates
Update UI Immediately
const deleteTodoWithAPI = async (id: number) => {
// 1. Update UI immediately (optimistic)
const previousTodos = todos;
setTodos(todos.filter(todo => todo.id !== id));
try {
// 2. Send request to server
await api.deleteTodo(id);
// Success! UI is already updated
} catch (error) {
// 3. If error, revert UI
setTodos(previousTodos);
alert('Failed to delete todo');
}
};
📘 Typing Lists with TypeScript
Everything so far works in plain JavaScript — so what does TypeScript add? Confidence. When you tell TypeScript "this is an array of Todo," it now watches your back inside every .map(), .filter(), and .reduce(). Typo a property name? Red squiggle. Forget that find() might return undefined? It makes you handle that case. Your editor's autocomplete lights up with exactly the fields each item has.
The best part is how little you have to write to get all this. You annotate your data once — at the array — and TypeScript infers the types flowing through every callback after that. Let's see how. 🧭
Array Type Annotations
Different Ways to Type Arrays
// Method 1: Type[]
const numbers: number[] = [1, 2, 3];
const strings: string[] = ['a', 'b', 'c'];
// Method 2: Array<Type>
const numbers: Array<number> = [1, 2, 3];
const strings: Array<string> = ['a', 'b', 'c'];
// Interface arrays
interface User {
id: number;
name: string;
}
const users: User[] = [];
// or
const users: Array<User> = [];
// Union type arrays
type Status = 'pending' | 'active' | 'completed';
const statuses: Status[] = ['pending', 'active'];
// Array of objects inline
const items: Array<{ id: number; name: string }> = [
{ id: 1, name: 'Item 1' }
];
Typing Props with Lists
List Component Types
interface Todo {
id: number;
text: string;
completed: boolean;
}
// List component props
interface TodoListProps {
todos: Todo[];
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
const TodoList: React.FC<TodoListProps> = ({ todos, onToggle, onDelete }) => {
return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
};
// Item component props
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
const TodoItem: React.FC<TodoItemProps> = ({ todo, onToggle, onDelete }) => {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
};
Typing Array Methods
Type-Safe Transformations
interface Product {
id: number;
name: string;
price: number;
category: string;
}
const products: Product[] = [
{ id: 1, name: 'Book', price: 12.99, category: 'books' },
{ id: 2, name: 'Pen', price: 1.99, category: 'stationery' }
];
// TypeScript infers types in callbacks
const names = products.map(product => product.name);
// Type: string[]
const expensive = products.filter(product => product.price > 10);
// Type: Product[]
const total = products.reduce((sum, product) => sum + product.price, 0);
// Type: number
// Explicit typing when needed
const formatted = products.map((product): string => {
return `${product.name}: $${product.price}`;
});
// Type: string[]
Generic List Components
Reusable Type-Safe Lists
// Generic list component
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
getKey: (item: T) => string | number;
}
function List<T>({ items, renderItem, getKey }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={getKey(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
// Usage with User type
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
const UserList: React.FC = () => (
<List
items={users}
renderItem={(user) => (
<div>
<strong>{user.name}</strong>
<p>{user.email}</p>
</div>
)}
getKey={(user) => user.id}
/>
);
// Usage with Product type
interface Product {
id: string;
name: string;
price: number;
}
const products: Product[] = [
{ id: 'p1', name: 'Book', price: 12.99 }
];
const ProductList: React.FC = () => (
<List
items={products}
renderItem={(product) => (
<span>{product.name}: ${product.price}</span>
)}
getKey={(product) => product.id}
/>
);
💡 TypeScript Benefits for Lists
- Autocomplete: IDE suggests available properties
- Type safety: Catches errors at compile time
- Refactor confidence: Rename properties safely
- Documentation: Types document what data looks like
- Inference: Often don't need explicit types in callbacks
🗂️ Empty States and Conditional Lists
Every list starts empty, and lists get emptied — a search returns no matches, a user clears their cart, a brand-new account has nothing in it yet. What does your UI show in that moment? Beginners often ship a blank white void, which leaves the user wondering whether the app is broken or still loading. That empty moment is actually one of your best chances to guide someone. 🧭
A thoughtful empty state answers three questions at a glance: What is this space for? Why is it empty right now? What should I do next? "No results for 'blue widget'" is worlds better than a blank page — and "No todos yet — add your first one!" with a button turns emptiness into an invitation.
Checking for Empty Lists
Basic Empty State
const TodoList: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
// Check if empty
if (todos.length === 0) {
return (
<div style={{ textAlign: 'center', padding: '3rem' }}>
<p style={{ fontSize: '3rem' }}>📝</p>
<h3>No todos yet</h3>
<p>Add your first todo to get started!</p>
</div>
);
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
};
Conditional Rendering Patterns
Different Empty State Approaches
// Pattern 1: Ternary operator
<div>
{todos.length === 0 ? (
<p>No todos yet!</p>
) : (
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
)}
</div>
// Pattern 2: Logical && operator
<div>
{todos.length === 0 && <p>No todos yet!</p>}
{todos.length > 0 && (
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
)}
</div>
// Pattern 3: Early return (component level)
const TodoList: React.FC<{ todos: Todo[] }> = ({ todos }) => {
if (todos.length === 0) {
return <EmptyState />;
}
return (
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
);
};
Rich Empty States
Engaging Empty State Component
interface EmptyStateProps {
icon?: string;
title: string;
description: string;
action?: {
label: string;
onClick: () => void;
};
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon = '📭',
title,
description,
action
}) => {
return (
<div style={{
textAlign: 'center',
padding: '3rem',
background: '#f5f5f5',
borderRadius: '8px',
margin: '2rem 0'
}}>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>
{icon}
</div>
<h3 style={{ marginBottom: '0.5rem' }}>{title}</h3>
<p style={{ color: '#666', marginBottom: '1rem' }}>{description}</p>
{action && (
<button
onClick={action.onClick}
style={{
padding: '0.75rem 1.5rem',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem'
}}
>
{action.label}
</button>
)}
</div>
);
};
// Usage
const MyTodos: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
if (todos.length === 0) {
return (
<EmptyState
icon="✨"
title="No todos yet"
description="Get started by adding your first todo item"
action={{
label: 'Add Todo',
onClick: () => setShowAddForm(true)
}}
/>
);
}
return <TodoList todos={todos} />;
};
Filtered Empty States
Different Empty Messages
const FilteredTodoList: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const [searchTerm, setSearchTerm] = useState('');
// Apply filters
const filteredTodos = todos
.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
})
.filter(todo =>
todo.text.toLowerCase().includes(searchTerm.toLowerCase())
);
// Different messages based on context
const getEmptyMessage = () => {
if (todos.length === 0) {
return 'No todos yet. Add one to get started!';
}
if (searchTerm) {
return `No todos found matching "${searchTerm}"`;
}
if (filter === 'completed') {
return 'No completed todos yet. Keep working!';
}
if (filter === 'active') {
return 'All done! No active todos.';
}
return 'No todos found.';
};
return (
<div>
{filteredTodos.length === 0 ? (
<p style={{ textAlign: 'center', color: '#666', padding: '2rem' }}>
{getEmptyMessage()}
</p>
) : (
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)}
</div>
);
};
✅ Empty State Best Practices
- Always handle empty lists - Don't show blank space
- Be contextual - Different messages for different situations
- Provide guidance - Tell users what they can do
- Add visual interest - Use icons or illustrations
- Include actions - Make it easy to add first item
⚡ List Performance
For small lists (< 100 items), performance is rarely an issue. But let's learn optimization techniques for larger lists!
Why Keys Matter for Performance
React's Reconciliation Process
When your list changes, React uses keys to:
- Determine which elements changed
- Reuse existing DOM nodes when possible
- Only update what actually changed
With good keys:
// List before: [A, B, C]
<li key="a">A</li>
<li key="b">B</li>
<li key="c">C</li>
// Add D at position 1: [A, D, B, C]
<li key="a">A</li>
<li key="d">D</li> // ✅ New element created
<li key="b">B</li> // ✅ Reused, just moved
<li key="c">C</li> // ✅ Reused, just moved
With index as keys:
// List before: [A, B, C]
<li key="0">A</li>
<li key="1">B</li>
<li key="2">C</li>
// Add D at position 1: [A, D, B, C]
<li key="0">A</li> // ✅ Reused
<li key="1">D</li> // ⚠️ Updated (was B)
<li key="2">B</li> // ⚠️ Updated (was C)
<li key="3">C</li> // ⚠️ New element
Virtual Scrolling for Long Lists
Only Render Visible Items
For very long lists (1000+ items), use virtual scrolling libraries:
// Using react-window library
import { FixedSizeList } from 'react-window';
interface RowProps {
index: number;
style: React.CSSProperties;
}
const LargeList: React.FC = () => {
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);
const Row: React.FC<RowProps> = ({ index, style }) => (
<div style={style}>
{items[index]}
</div>
);
return (
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={35}
width="100%"
>
{Row}
</FixedSizeList>
);
};
This renders only the visible items, dramatically improving performance!
In 2026 the two go-to libraries are react-window (shown above, simple and battle-tested) and TanStack Virtual (headless and framework-agnostic, great when rows have variable heights). Either way, the core idea is the same: keep the DOM small no matter how big the data gets.
Memoization with React.memo
Prevent Unnecessary Re-renders
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
// Without memo: Re-renders even if props haven't changed
const TodoItem: React.FC<TodoItemProps> = ({ todo, onToggle, onDelete }) => {
console.log('TodoItem rendered:', todo.id);
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
{todo.text}
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
};
// With memo: Only re-renders if props change
const TodoItem = React.memo<TodoItemProps>(({ todo, onToggle, onDelete }) => {
console.log('TodoItem rendered:', todo.id);
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
{todo.text}
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});
🎨 Performance Impact of Keys
Performance Tips
| Tip | Why It Helps |
|---|---|
| Use stable keys (IDs) | React can efficiently track items |
| Extract list items to components | Easier to optimize individual items |
| Use React.memo for items | Prevents unnecessary re-renders |
| Virtualize very long lists | Only render visible items |
| Debounce search/filter | Reduces re-renders during typing |
| Paginate or lazy load | Load data as needed |
💡 When to Optimize
Don't optimize prematurely! Consider optimization when:
- List has 500+ items
- Items are complex components
- You notice lag when typing or scrolling
- Profiling shows list is a bottleneck
For most lists under 100 items, basic React is plenty fast!
🏋️ Hands-on Practice
Reading about lists is one thing; building them is where it clicks. These three exercises step up in difficulty and together exercise everything from this lesson — map, stable keys, immutable CRUD, derived filtering, and graceful empty states. Try each one before peeking at the hints, and lean on TypeScript's red squiggles as your safety net. 💪
🏋️ Exercise 1: User Directory
Goal: Build a searchable, sortable user list.
Requirements:
- Display list of users (name, email, role)
- Search by name or email
- Sort by name (A-Z, Z-A)
- Filter by role (Admin, User, Guest)
- Show empty state when no results
- Use proper keys
💡 Starter Data
interface User {
id: number;
name: string;
email: string;
role: 'Admin' | 'User' | 'Guest';
}
const users: User[] = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'User' },
{ id: 3, name: 'Charlie Brown', email: 'charlie@example.com', role: 'Guest' },
// Add more...
];
🏋️ Exercise 2: Shopping Cart
Goal: Build a shopping cart with quantity controls.
Requirements:
- Display cart items with image, name, price, quantity
- Increase/decrease quantity buttons
- Remove item button
- Calculate and display subtotal per item
- Calculate and display total
- Empty cart state with "Start Shopping" button
💡 Hint
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
imageUrl: string;
}
const updateQuantity = (id: string, change: number) => {
setCart(cart.map(item =>
item.id === id
? { ...item, quantity: Math.max(1, item.quantity + change) }
: item
));
};
🏋️ Exercise 3: Dynamic Task Board
Goal: Build a Kanban-style task board.
Requirements:
- Three columns: "To Do", "In Progress", "Done"
- Add tasks to "To Do"
- Move tasks between columns (buttons or drag)
- Delete tasks
- Show count of tasks in each column
- Each column has its own empty state
💡 Hint
type Status = 'todo' | 'inProgress' | 'done';
interface Task {
id: number;
text: string;
status: Status;
}
const moveTask = (id: number, newStatus: Status) => {
setTasks(tasks.map(task =>
task.id === id ? { ...task, status: newStatus } : task
));
};
const todoTasks = tasks.filter(t => t.status === 'todo');
✨ Best Practices
Follow these guidelines for clean, performant lists.
✅ 1. Always Use Keys
// ✅ Good: Unique, stable key
{items.map(item => <li key={item.id}>{item.name}</li>)}
// ❌ Bad: No key
{items.map(item => <li>{item.name}</li>)}
// ⚠️ Avoid: Index as key (unless list is truly static)
{items.map((item, i) => <li key={i}>{item.name}</li>)}
✅ 2. Extract List Items to Components
// ✅ Good: Clean and reusable
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
// ❌ Bad: Complex JSX inline
{users.map(user => (
<div key={user.id}>
<img src={user.avatar} />
<h3>{user.name}</h3>
<p>{user.email}</p>
<button>View Profile</button>
{/* 20 more lines... */}
</div>
))}
✅ 3. Handle Empty States
// ✅ Good: Helpful empty state
{items.length === 0 ? (
<EmptyState
title="No items yet"
description="Add your first item to get started"
/>
) : (
<ItemList items={items} />
)}
// ❌ Bad: Blank page
{items.map(item => <Item key={item.id} {...item} />)}
✅ 4. Type Your Lists
// ✅ Good: Fully typed
interface Todo {
id: number;
text: string;
completed: boolean;
}
const [todos, setTodos] = useState<Todo[]>([]);
// ❌ Bad: No types
const [todos, setTodos] = useState([]);
✅ 5. Use Derived State
// ✅ Good: Calculate filtered list
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState('all');
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
// ❌ Bad: Store filtered list in state
const [todos, setTodos] = useState<Todo[]>([]);
const [filteredTodos, setFilteredTodos] = useState<Todo[]>([]);
Lists Checklist
| Check | Done? |
|---|---|
| Every list item has a unique key | ☐ |
| Keys are stable (not random or index when list changes) | ☐ |
| Empty state is handled gracefully | ☐ |
| Complex list items extracted to components | ☐ |
| List and items are properly typed | ☐ |
| Using array methods correctly (map, filter, etc.) | ☐ |
| Filtered/sorted lists are derived, not stored | ☐ |
📚 Summary
Congratulations! You've mastered lists and keys in React - a fundamental skill for building dynamic applications.
What You Learned
📋 Rendering Lists
- Using map() to transform arrays into JSX
- Rendering arrays of objects
- Extracting list items to components
🔑 The Key Prop
- Why keys are critical for React's reconciliation
- How keys help React track elements
- Problems with using index as key
- When index keys are acceptable
🎯 Choosing Keys
- Best sources for keys (database IDs, UUIDs)
- Generating keys for new items
- Keys must be unique among siblings
- Keys must be stable and consistent
🔧 Array Methods
- map() for transforming data
- filter() for selecting items
- reduce() for aggregating values
- Chaining methods for complex operations
✏️ CRUD Operations
- Create: Adding items to lists
- Read: Displaying lists
- Update: Modifying items
- Delete: Removing items
📘 TypeScript
- Typing arrays and list components
- Type-safe array methods
- Generic list components
⚡ Performance
- How keys affect performance
- Virtual scrolling for long lists
- Memoization with React.memo
- When to optimize
🎯 Key Takeaways
- Keys are mandatory - React needs them to track list items
- Use stable IDs - Database IDs or generated UUIDs are best
- Index as key is risky - Only use for static lists
- map() is your friend - Transform data to JSX
- Handle empty states - Don't leave users confused
- Type everything - TypeScript makes lists safer
🚀 Next Steps
Now that you've mastered lists, you're ready for more advanced rendering patterns:
📖 Coming Up Next
Lesson 3.5: Conditional Rendering Patterns
- Advanced conditional rendering techniques
- Ternary operators vs logical && operators
- Switch statements in JSX
- Early returns and guard clauses
💪 Practice Projects
Build these to master lists:
- Contact Manager - CRUD for contacts with search and groups
- Expense Tracker - List of transactions with categories and totals
- Recipe Box - Recipes with ingredients lists and instructions
- Music Playlist - Songs with play counts and favorites
- Issue Tracker - Bugs/features with status, priority, and assignees
✨ Remember
Lists are everywhere in React applications. The patterns you learned here - proper keys, array methods, CRUD operations - are skills you'll use every single day as a React developer. Master these fundamentals, and you'll be able to build any kind of dynamic, data-driven interface!
📚 Additional Resources
🎉 Congratulations!
You've completed Lesson 3.4 and mastered lists and keys in React! You can now render dynamic lists, choose stable keys, perform immutable CRUD operations, type your data with TypeScript, and keep large lists fast. This is essential knowledge for building real-world applications. Excellent work! 🚀