Lesson 6

Lists & keys

Home › Courses › React › Lesson 6
⚛️ Lesson 6 · 15 minutes

Lists & keys.
Render arrays. Get keys right.

Every app has lists. Search results, messages, notifications, cart items, comments, users. Rendering arrays of components is the most-used pattern in React — and the one with the most subtle bugs. The key prop is small, but getting it wrong causes state to attach to the wrong items, inputs to lose their values, and animations to misfire. This lesson teaches the correct pattern and every mistake that breaks lists.

~15 minutes Intermediate React 19
React path ← Lesson 5 .map() Keys Index as key Stable keys Filter & sort Challenge

Render lists with .map(): {items.map(item => )}. Every item needs a key — a stable, unique ID from your data. Never use array index as a key unless the list is static. Filter and sort before mapping (.filter().sort().map()) — both return new arrays. Keys must be unique among siblings, not globally.

Before this lesson: complete Lesson 5. You need to understand state, props, and rendering.

🔁 .map() — the render pattern

Transform each array item into JSX. React renders the array.

The .map() method takes an array of data and returns an array of JSX. React accepts an array of elements as valid children. This is the entire pattern — you've already used it in JavaScript, and React just extends it to JSX.

Basic list rendering
function FruitList() {
const fruits = ['apple', 'banana', 'cherry'];

return (
    {fruits.map((fruit) => (
  • {fruit}
  • ))}
); } // React renders: //
    //
  • apple
  • //
  • banana
  • //
  • cherry
  • //
// // Note: 'key' is not rendered to the DOM. // It's a special React-only prop used for // reconciliation. You can't access it as a prop // inside the child component either.
// ───────────────────────────────────────── // Array of objects — the real-world case function UserList() { const users = [ { id: 1, name: 'Alice', email: 'alice@x.com' }, { id: 2, name: 'Bob', email: 'bob@x.com' }, { id: 3, name: 'Priya', email: 'priya@x.com' }, ]; return (
    {users.map((user) => (
  • {user.name} — {user.email}
  • ))}
); } // key={user.id} — the stable, unique identifier. // Without it: React warning "Each child in a list // should have a unique 'key' prop" and potential // reconciliation bugs when the list changes.

.map() returns an array

React renders an array of elements just like it renders a single element. This is why .map() works — it returns [

  • ,
  • ,
  • ] and React renders each.

  • No for loops

    You can't use a for loop to build JSX — loops don't return values from an expression. .map() does. Use .map() for rendering, for loops for side effects.

    key is special

    key is a React-internal prop. It's not passed to the child, not rendered to the DOM, and can't be read as props.key. It's used only by React's reconciliation algorithm.

    Spread for props

    passes every field of the item as props. Cleaner than name={item.name} email={item.email} when the item has many fields.

    You can't use a for loop to build a list in JSX. This is the first thing that trips up developers coming from vanilla JavaScript. JSX only accepts expressions, and a for loop is a statement. .map() is the expression version — it returns an array of JSX that JSX can render. Same for .filter(), .reduce(), and any other array method that returns a value.

    🔑 Keys — React's reconciliation primitive

    Why keys exist, what they do, and why React warns without them.

    When a list re-renders, React needs to know which items are the same as before, which are new, and which were removed. It uses the key prop to match. Without keys, React falls back to the array index — which works for static lists but breaks when items move. With stable keys, React matches each item to its previous render and preserves state, DOM nodes, and animations.

    Why keys matter
    // Imagine a list of items with a checkbox each.
    // Initial state:
    // [ ] Item A
    // [ ] Item B
    // [x] Item C ← user checked this one
    
    // The array of items:
    const items = [
    { id: 'a', text: 'Item A', checked: false },
    { id: 'b', text: 'Item B', checked: false },
    { id: 'c', text: 'Item C', checked: true },
    ];
    
    // ─────────────────────────────────────────
    // ❌ BROKEN — using index as key
    {items.map((item, index) => (
    
    ))}
    
    // User deletes "Item A". The new array:
    const items = [
    { id: 'b', text: 'Item B', checked: false }, // index 0
    { id: 'c', text: 'Item C', checked: true }, // index 1
    ];
    
    // React sees:
    // - index 0: was "Item A", now "Item B" → reuse the SAME DOM element
    // - index 1: was "Item B", now "Item C" → reuse the SAME DOM element
    // - index 2: removed
    //
    // The DOM element that held Item B's checkbox now shows
    // "Item C" — but the checked state is preserved on the
    // WRONG item! The user sees "Item C" unchecked.
    
    // ─────────────────────────────────────────
    // ✅ CORRECT — using stable id as key
    {items.map((item) => (
    
    ))}
    
    // React sees:
    // - key 'b': Item B (unchanged, keep DOM)
    // - key 'c': Item C (unchanged, keep DOM)
    // - key 'a': removed (delete DOM)
    //
    // Each item keeps its own DOM node, its own checked state,
    // its own anything. Deleting Item A doesn't affect the others.
    // The user's checkmark stays on Item C.

    Keys match across renders

    React compares each key to the previous render's keys. Same key = same item = preserve DOM/state. New key = new item. Missing key = removed item.

    Uniqueness among siblings

    Keys must be unique within the same array — not globally. Two different lists can use the same keys. Only siblings conflict.

    Not a prop

    You can't read props.key in the child component. React strips it. If you need the key value inside the child, pass it as a separate prop: id={item.id}.

    Required in .map()

    Every element returned from .map() needs a key. React will warn loudly if you forget. The warning is a feature — it catches silent reconciliation bugs early.

    Index-as-key bugs are silent. React doesn't error. The list renders. But state, DOM nodes, and animations attach to the wrong items when the list changes. The symptom: "my input loses focus," "my checkbox state moves to a different row," "my animation plays on the wrong item." If your list has any of these bugs, the first thing to check is whether you used index as a key.

    ⚠️ Index as key — when it breaks

    Sometimes it's fine. Often it's a silent bug. Know the difference.

    When index keys are safe
    // ✅ SAFE — static list, never changes
    function Menu() {
    const links = ['Home', 'About', 'Contact'];
    
    return (
    
    );
    }
    // The array is hardcoded. Items never reorder, insert, or delete.
    // Index keys are fine here — they'll never be reassigned.
    
    // ─────────────────────────────────────────
    
    // ❌ BROKEN — dynamic list with inputs
    function TodoList() {
    const [todos, setTodos] = useState([
    { id: 1, text: 'Buy milk' },
    { id: 2, text: 'Walk dog' },
    { id: 3, text: 'Read book' },
    ]);
    
    function removeFirst() {
    setTodos(todos.slice(1));
    }
    
    return (
    
      {todos.map((todo, i) => (
    • {/* ❌ index as key */} "checkbox" /> {todo.text}
    • ))}
    ); } // User checks "Buy milk" (index 0), then clicks remove. // "Walk dog" moves from index 1 to index 0. // React reuses the DOM element at index 0 — the one that // was checked. Now "Walk dog" appears checked! // The checkmark "moved" from the deleted item to the next. // Fix: key={todo.id} — each item keeps its own DOM. // ───────────────────────────────────────── // ❌ BROKEN — items reorder // A sortable list with index keys: sorting reorders the // underlying DOM nodes. React uses indices to match, so // input values move to the wrong rows. Fix: stable keys. // ───────────────────────────────────────── // ✅ FIX — always use stable IDs {todos.map((todo) => (
  • "checkbox" /> {todo.text}
  • ))}

    Safe: static lists

    Hardcoded arrays that never reorder, insert, or delete. A nav menu, a list of fixed options, a footer links list.

    Dangerous: dynamic lists

    Anything with state, reordering, filtering, or deletion. To-dos, search results, filters, sorts. These need real keys.

    Dangerous: inputs

    Any list where each item has an input (checkbox, text field). Uncontrolled inputs especially — they lose focus/value on reorder.

    The rule

    If the list can change (add, remove, reorder, filter, sort), use stable keys. If it's truly static, index is fine but unnecessary. Stable keys are always safe — index keys are only sometimes safe.

    Default to stable IDs — they're always safe. Index keys require you to reason about whether the list can ever change. That's mental overhead on every list you write. Stable IDs work unconditionally. If your data doesn't have IDs, add them at the source: { id: crypto.randomUUID(), text } when the item is created (not during render). One habit, zero bugs.

    ✅ Getting stable keys

    Where to get them, and what to do when your data doesn't have IDs.

    Key sources
    // 1. BEST — database ID from your backend
    const users = await res.json();
    // [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
    {users.map((u) => )}
    
    // ─────────────────────────────────────────
    
    // 2. GOOD — stable unique field (email, slug, uuid)
    const users = [{ email: 'alice@x.com', name: 'Alice' }];
    {users.map((u) => )}
    // Only safe if email is truly unique in the data.
    // If emails can repeat, use a different field.
    
    // ─────────────────────────────────────────
    
    // 3. GOOD — generate on creation, not on render
    const [items, setItems] = useState([]);
    
    function addItem(text) {
    setItems([...items, {
    id: crypto.randomUUID(), // ← generated ONCE, when added
    text,
    }]);
    }
    
    // ❌ BROKEN — generating in the render
    {items.map((item) => (
    randomUUID()} {...item} />
    ))}
    // New UUID every render → every item is "new" → React
    // re-creates every DOM node on every render. Slower,
    // loses state, animations replay. Never do this.
    
    // ─────────────────────────────────────────
    
    // 4. LAST RESORT — index (only for static lists)
    // Acceptable for a hardcoded array. Never for dynamic.
    
    // ─────────────────────────────────────────
    
    // 5. Combine fields when no single unique field exists
    const events = [
    { date: '2026-10-26', type: 'login' },
    { date: '2026-10-26', type: 'logout' },
    { date: '2026-10-27', type: 'login' },
    ];
    {events.map((e) => (
    ``${e.date}-${e.type}`} {...e} />
    ))}
    // Safe if (date, type) is unique. Verify with data.

    Prefer server IDs

    Backend-generated id fields are the gold standard — guaranteed unique, stable, and present in the data you already have.

    uuid at creation

    For client-generated data (new to-dos, drafts), generate a UUID when the item is added. Store it on the item. Don't generate during render.

    Composite keys

    No single unique field? Combine fields: date + '-' + type, userId + '-' + productId. Safe if the combination is unique.

    Duplicate detection

    If you get a "duplicate key" warning, two items share a key. That's a data bug — deduplicate or use a more specific key. React renders inconsistently with duplicates.

    🔎 Filter, sort, then map

    Chain the array methods. Each returns a new array. Never mutate the original.

    Filter and sort before render
    // Filter — keep only active items
    {items
    .filter((item) => item.active)
    .map((item) => )}
    
    // Sort — by name, then map
    {[...items]
    .sort((a, b) => a.name.localeCompare(b.name))
    .map((item) => )}
    // Note the [...items] spread — .sort() mutates the array
    // it's called on! Spreading first creates a copy so the
    // original state isn't modified. Without the spread,
    // you'd be sorting state in place — a React bug.
    
    // Filter + sort + map — the full chain
    {items
    .filter((item) => item.active)
    .sort((a, b) => b.score - a.score)
    .map((item) => )}
    
    // ─────────────────────────────────────────
    
    // ❌ BROKEN — sort mutates the state array
    items.sort((a, b) => a.name - b.name);
    {items.map((item) => )}
    // .sort() mutates the array in place. React doesn't
    // detect the change (same array reference) → no re-render.
    // Even worse: the state is now sorted in the wrong order,
    // and every subsequent render sees the mutated array.
    // Fix: [...items].sort(...) or items.slice().sort(...)
    
    // ─────────────────────────────────────────
    
    // ❌ BROKEN — .sort() with no comparator
    items.sort();
    // Converts everything to strings and sorts alphabetically.
    // [1, 2, 10, 20] → [1, 10, 2, 20].
    // Always pass a comparator for numbers:
    // items.sort((a, b) => a - b)
    
    // ─────────────────────────────────────────
    
    // Empty state — when the filtered list is empty
    {filtered.length === 0 ? (
    

    No items match your filters.

    ) : (
      {filtered.map((item) => )}
    )} // Always handle the empty case. "Nothing renders" // looks like a bug — show a message instead.

    .filter() returns new

    Safe to chain. Doesn't mutate the original. Returns only the items where the callback returns true.

    .sort() mutates

    .sort() sorts the array in place. On state, this is a bug. Always spread first: [...items].sort(...). Or use .toSorted() (ES2023) which returns a copy.

    Always pass a comparator

    items.sort() compares as strings: [10, 9, 1] sorts to [1, 10, 9]. For numbers: (a, b) => a - b. For strings: (a, b) => a.localeCompare(b).

    Handle empty

    When the filtered array has zero items, render an empty-state message. "Nothing appears" is a bug report. "No results found" is UX.

    .sort() mutating state is one of the most common React bugs. items.sort(...) modifies the array in place — same reference — so React doesn't detect the change and doesn't re-render. The UI stays stale while the state silently changes. Worse: the mutated array now has a different order for every subsequent render, so future updates are based on wrong data. Always spread before sorting: [...items].sort(...). Or use .toSorted() which is built for this.

    Fragments need keys too. If you need to return multiple elements per item without a wrapper div (like

    and
    in a definition list), wrap them in a Fragment with a key:
    ...
    ...
    . Regular <> shorthand can't take a key. This is the only place you need the explicit Fragment import.

    🏆 Challenge

    Three tasks. Each exercises a different list pattern.

    Task 1 — Filterable todo list

    Build a todo list with:

    1. State array of todos (each with id, text, done)
    2. Three filter buttons: All / Active / Done
    3. State for the current filter
    4. Filter the array before mapping
    5. Each todo has a checkbox to toggle done + a delete button
    6. Delete removes from state with .filter()

    Use stable key={todo.id}. Test by checking a todo, deleting another, and switching filters — the checkboxes should never move to wrong items.

    Task 2 — Sortable table

    Build a table of users with:

    1. Columns: Name, Email, Signup Date
    2. Clicking a column header sorts by that column
    3. Clicking again toggles asc/desc
    4. Show a ↓ or ↑ indicator on the active sort column
    5. Map the sorted array with key={user.id}

    This is a real production pattern. Notice you need [...users].sort() — not users.sort() — because sort mutates and would break React.

    Task 3 — Reproduce the index-key bug

    Build two versions of a todo list side by side:

    Version A: uses key={index}. Each todo has an uncontrolled checkbox and a text input for notes.
    Version B: uses key={todo.id}. Same components.

    Add a "delete first item" button. Test:

    1. Type in the note field of the 3rd item
    2. Click "delete first item"
    3. Observe: in Version A, the typed note appears to jump to the wrong row. In Version B, it stays with its item.

    This is the exercise that makes keys click forever. Once you see the bug with your own eyes, you'll never forget why stable keys matter.

    ⚠️ Common mistakes

    The list and key bugs that catch every developer.

    1. Forgetting keys

    {items.map(item => )} — no key. React warns loudly. Fix: . The warning is a feature.

    2. Using index as key on dynamic lists

    Checkboxes and inputs attach to wrong items when the list changes. Silent bug. Fix: stable ID from data. Index only for static lists.

    3. Generating keys during render

    key={Math.random()} or key={crypto.randomUUID()} in the map. New key every render = React re-creates every item. Fix: generate once at data creation.

    4. Duplicate keys

    Two items share a key — React renders them inconsistently. Fix: ensure uniqueness. Combine fields if needed.

    5. Mutating array in map

    items.map(item => { item.x = 1; return item }) — mutates state items. Fix: items.map(item => ({ ...item, x: 1 })).

    6. Sorting state in place

    items.sort(...) mutates the array. Fix: [...items].sort(...) or items.toSorted(...).

    7. No empty state

    Filtered list is empty, page looks blank. Fix: {items.length === 0 &&

    No results.

    }.

    8. Fragment without key

    <>... can't take a key. Fix: ... when returning multiple elements per item.

    🎓 React track complete

    Six lessons. One complete foundation. Here's what you can do now.

    You can build React apps

    Components, JSX, props, state, events, forms, effects, and lists. Every pattern you need for real applications.

    You understand the mental model

    UI = function of state. Data flows down via props, events flow up via callbacks. Re-renders are snapshots. This is the mental model that separates "I can write React" from "I understand React."

    You can debug React bugs

    Stale state, wrong keys, mutation, missing cleanup, infinite effects — you've seen all of them and know the fixes.

    You're ready for routing and data

    React Router, data fetching libraries (React Query, SWR), and state management (Zustand, Context) all build on this foundation.

    What to build before moving to advanced React. Build one complete app: a blog with routing, a to-do app with localStorage, a weather app that fetches and caches. Ship it. Then add routing (React Router) and API integration. That one project teaches more than the next ten tutorials — because the bugs only become real when you hit them.

    ❓ Frequently asked questions

    How do I render a list in React?

    Use .map() to transform an array of data into an array of JSX elements: {items.map(item => )}. React renders each element in order. Always include a key prop with a stable, unique value. Never use a for loop to build JSX.

    Why does React need keys?

    Keys help React identify which items have changed, been added, or been removed when the list re-renders. Without keys, React has to guess based on position, which causes bugs when items reorder or are removed.

    Should I use array index as a key?

    Almost never. Index keys cause bugs when items reorder, insert, or delete — React attaches state to the wrong item. Use a stable ID from your data instead. Index keys are safe only for static lists that never change.

    What should I use as a key?

    A stable unique identifier from your data. Usually an id field from the database (item.id). For static data without IDs, generate one with crypto.randomUUID() when the data is created — but don't generate it inside the map.

    Why does my list re-render every item on change?

    Because the key changes on every render, forcing React to treat each item as new. Common causes: generating keys inside .map(), using Math.random() as a key, or wrapping the list in an inline object that changes identity. Fix: use stable keys from your data.

    Can I use the same key twice in one list?

    No. Keys must be unique among siblings — not globally, but within the same array. Duplicate keys cause React to silently skip or overwrite items, producing wrong output.

    How do I filter or sort a list in React?

    Filter and sort before mapping: {items.filter(x => x.active).sort((a,b) => a.name.localeCompare(b.name)).map(item => )}. Both .filter() and .sort() return new arrays — never mutate the original. For sort, always pass a comparator.

    How do I render a list of fragments or multiple elements per item?

    Wrap the multiple elements in a Fragment with a key: {items.map(item =>

    {item.term}
    {item.def}
    )}. Fragments accept keys but not other props.

    🔗 Continue your React path

    🌐 External resources

    ← Back to React track
    You're in the React track · Lesson 6 · Track complete