Skip to main content

📜 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:

  1. Have an array of data
  2. Use .map() to transform each item
  3. Return JSX for each item
  4. Add a unique key prop

🔑 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.

graph TD A[Array Changes] --> B{React compares keys} B -->|Same key| C[Update element] B -->|New key| D[Create new element] B -->|Key removed| E[Delete element] B -->|Key moved| F[Move element] style C fill:#4CAF50,color:#fff style D fill:#2196F3,color:#fff style E fill:#f44336,color:#fff style F fill:#FF9800,color:#fff

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

Keys Tell React Which Items Changed ✅ With Stable Keys (IDs) Before: ["A", "B", "C"] key="a" → A key="b" → B key="c" → C Insert "D" at position 1 After: ["A", "D", "B", "C"] key="a" → A Reused ✓ key="d" → D NEW! ✓ key="b" → B Moved ✓ key="c" → C Moved ✓ Result: React understands! • Creates 1 new element (D) • Reuses A, moves B and C ⚠️ With Index as Key Before: ["A", "B", "C"] key=0 → A key=1 → B key=2 → C Insert "D" at position 1 After: ["A", "D", "B", "C"] key=0 → A OK ✓ key=1 → D Was B! ✗ key=2 → B Was C! ✗ key=3 → C NEW! ✗ Result: React is confused! • Updates 3 elements (B→D, C→B, new C) • State in B,C components may be wrong!
Figure: With stable id keys, React reuses row "a" and just moves "b"/"c" while inserting only the new "d". With index keys, positions 1–3 all look "changed," so React rewrites them and component state can bleed onto the wrong row.

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

Choosing the Right Key ✅ Good Keys Database IDs: key={user.id} UUIDs: key={crypto.randomUUID()} // on creation Unique Properties: key={email} // if guaranteed unique Composite Keys: key={`${category}-${id}`} ❌ Bad Keys Index (for dynamic lists): key={index} // changes when list changes! Random Values: key={Math.random()} // new key every render! Same Key for All: key="item" // duplicates cause bugs! Objects as Keys: key={{id: 1}} // must be string/number!
Figure: Reach for stable keys — database IDs, UUIDs generated on creation, guaranteed-unique properties, or composite keys. Avoid the array index on dynamic lists, random values, shared constants, and objects.

🔧 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

Keys & Performance: DOM Operations Comparison ✅ Stable Keys (IDs) Scenario: Insert item at position 1 in 1000-item list DOM Operations: ✓ 1 element created (new item) ✓ 999 elements unchanged ✓ Browser just inserts node Performance: ~1ms (fast!) ✓ Component state preserved Input values, scroll positions, etc. kept ⚠️ Index as Key Same scenario: Insert at position 1 DOM Operations: ✗ 999 elements updated (content changed) ✗ 1 element created at end ✗ Browser updates almost everything Performance: ~90ms (slow!) ✗ Component state LOST Inputs reset, forms cleared, bugs!
Figure: Inserting one item into a 1000-row list. Stable keys → one DOM insertion, component state preserved, ~1ms. Index keys → ~999 rows re-rendered, inputs/state lost, far slower.

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!

🎉 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! 🚀

← Previous Lesson 3.3: Forms in React Home Next → Lesson 3.5: Conditional Rendering