Command Palette

Search for a command to run...

Hectal
PHASE 3Beginner ~30 min· topic 4 of 4

Topic 3.4

Nested and Grouped Lists

In one line

Lists inside lists are a map inside a map, with keys at every level; when data arrives flat, group it first (with Object.groupBy or reduce) and then render the groups, and always decide what an empty list or an empty group should show.

Think of it like this

A school register. The school has classes, and each class has students. To read out every name you go class by class, and inside each class student by student. Nobody gets confused that class 5A and class 5B both have a roll number 1, because a roll number only has to be unique inside its own class. Nested lists in React work exactly like this.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Nested list
A list whose items contain their own lists, like categories that each hold dishes.
Nested map
A map inside another map: the outer one for groups, the inner one for the items of each group.
Flat data
One plain array where each item names its group in a field, like status: "todo".
Grouping
Turning a flat array into an object of arrays, one array per group.
Object.groupBy
A built-in function (ES2024) that groups an array by whatever your function returns for each item.
reduce
An array method that walks the array and builds one result, such as an object of groups.
Empty state
What the page shows when a list has no items, instead of a blank space.
??
The nullish coalescing operator: a ?? b gives b when a is null or undefined.

Step by step

01See the shape of the data

Each category object holds its own dishes array. Two levels of data mean two levels of map.

menu.jswhole filejavascript
const menu = [
  { id: "starters", name: "Starters", dishes: [{ id: 1, name: "Samosa" }] },
  { id: "mains", name: "Mains", dishes: [{ id: 1, name: "Dal makhani" }, { id: 2, name: "Naan" }] },
];
See the shape of the datadiagram
Rendering diagram…

02Map the outer list, then the inner list

Each level gets its own key. The two dishes with key 1 don't clash, because they live in different <ul> lists.

Menu.jsxwhole filejsx
{menu.map((cat) => (
  <section key={cat.id}>
    <h2>{cat.name}</h2>
    <ul>
      {cat.dishes.map((dish) => (
        <li key={dish.id}>{dish.name}</li>
      ))}
    </ul>
  </section>
))}

03Give each level its own component

Move the inner part into Category. The outer map now reads like a sentence: one Category per category.

Menu.jsxwhole filejsx
function Category({ category }) {
  return (
    <section>
      <h2>{category.name}</h2>
      <ul>{category.dishes.map((d) => <li key={d.id}>{d.name}</li>)}</ul>
    </section>
  );
}

{menu.map((cat) => <Category key={cat.id} category={cat} />)}

04Flat data? Group it first

When the API sends one flat array, build the groups with JavaScript before the JSX. The JSX then only has to render groups and items.

Board.jsxwhole filejsx
const byStatus = Object.groupBy(tasks, (t) => t.status);
// { done: [...], doing: [...], todo: [...] }

// the same with reduce, for older environments:
const byStatus2 = tasks.reduce((groups, t) => {
  (groups[t.status] ??= []).push(t);
  return groups;
}, {});
Flat data? Group it firstdiagram
Rendering diagram…

05Decide what empty looks like

Check the empty cases before mapping. A whole empty list gets one helpful message; an empty group can show its own small message so the layout doesn't jump.

Column.jsxwhole filejsx
{list.length === 0 ? (
  <p className="empty">Nothing here yet.</p>
) : (
  <ul>{list.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
)}

Try it yourself

  1. 1

    Add a category

    In the menu, add a desserts category with two dishes whose ids are 1 and 2. Predict the console and whether React warns. (A third line, Desserts: 2 dishes, and no warning.)

  2. 2

    Show empty columns properly

    In the board, delete the task with status doing. The Doing column still appears with (0) because you map COLUMNS. Add {list.length === 0 && <p>Nothing here</p>} inside the column.

  3. 3

    Group with reduce

    In the board, replace Object.groupBy(...) with a reduce that builds the same object. The page and the console line must stay the same.

Code & diagrams

A list inside a list, keyed at each level New tab

Both categories have a dish with key `1`, and there is no warning: keys are compared only inside the same list.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Starters: 2 dishes
Mains: 3 dishes
Group flat data, then render the groups New tab

`Object.groupBy` gives the groups in the order it first met them (`done` first). Mapping `COLUMNS` instead puts the columns in the order a person expects.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

[ 'done', 'doing', 'todo' ]
Empty states: when nothing matches New tab

Type `a`, then `zz`. Groups appear and disappear with the data, cities stay in alphabetical order, and an empty result gets a message and a way out.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the key on the inner list

In the menu, remove key={dish.id} from the inner <li>.

terminal
$ Console
── what you'll see ──
Each child in a list should have a unique "key" prop.
 
Check the render method of `Category`. See https://react.dev/link/warning-keys for more information.

Break #2

Map an object as if it were an array

In the board, write byStatus.map(...) to render the groups directly.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: byStatus.map is not a function
An error occurred in the <Board> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.

Myth vs fact

Myth

Keys in nested lists must be unique across all levels.

Fact

Each list only needs unique keys among its own children. Dish 1 in Starters and dish 1 in Mains don't clash.

Myth

One key on the outer item covers everything inside it.

Fact

Each array needs its own keys. A key on <Category> identifies the category, not the dishes inside it.

Myth

Grouped data should be stored in state next to the flat data.

Fact

Groups can always be calculated from the flat array, so compute them during render. Storing both invites them to drift apart.

Myth

An empty list needs no design.

Fact

A blank area looks like a bug. Every list needs a deliberate empty state, and "empty" must look different from "loading" and "failed".

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Object.groupBy returns a null-prototype object (no inherited toString and friends), which makes it safe to use arbitrary strings as group names. Map.groupBy returns a Map and keeps insertion order with non-string keys such as dates or objects.

  • ▸

    Deeply nested state is awkward to update immutably (a reply inside a comment inside a post). Many apps normalise it instead: store each kind of item flat by id ({ postsById, commentsById }) and group or nest only for rendering. That's the idea behind Topic 6.3's state structure rules and Redux Toolkit's createEntityAdapter (Phase 15).

  • ▸

    Recursive data (a folder tree, threaded comments) is rendered by a component that renders itself for its children: function Folder({ folder }) { return <li>{folder.name}<ul>{folder.children.map((c) => <Folder key={c.id} folder={c} />)}</ul></li>; }. Keys work the same at every depth.

  • ▸

    Accessible grouped lists: give each group a real heading (<h2>/<h3>) or use <section aria-labelledby> so screen-reader users can jump between groups, and announce result counts or empty results in a live region when filtering (Phase 18).

Remember this

  1. 1

    Nested data is data where items contain their own arrays: a menu has categories and each category has dishes; a blog post has comments and each comment has replies. To render it, you map the outer array, and inside each outer item you map its inner array. That's all a nested list is: a map inside a map.

  2. 2

    Keys at each level. Every array you render needs its own keys. The outer map keys each category (key={cat.id}), the inner map keys each dish (key={dish.id}). Because keys only have to be unique among siblings, dish ids can repeat across categories: both Starters and Mains may have a dish with id 1. If inner ids are not unique within a group, combine fields: key={cat.id + "-" + dish.id}.

  3. 3

    Extract a component per level. A map inside a map inside JSX gets hard to read. Move the inner list into a component (<Category category={cat} /> renders its own dishes). Each component then has one map, one job and one obvious place for its key. The key for the category goes on <Category key={cat.id} /> in the outer map.

  4. 4

    Grouping before rendering. Data often arrives flat: one array where each item says which group it belongs to ({ title, status: "todo" }). Don't try to group inside the JSX. First turn it into groups with plain JavaScript, then render the groups. Object.groupBy(items, (t) => t.status) (ES2024, in all current browsers and Node 21+) returns an object such as { todo: [...], done: [...] }. In older environments use reduce: items.reduce((groups, t) => { (groups[t.status] ??= []).push(t); return groups; }, {}). This grouping is derived data, calculated during render like the filtered list in Topic 3.2.

  5. 5

    Group order. An object's keys come out in the order they were first added, which depends on the data. Columns on a board should appear in a fixed order (To do, Doing, Done) even when a group is empty, so keep a separate ordered list of groups (COLUMNS) and map that, reading byStatus[col.status] ?? [] for each. For groups that should be alphabetical, sort the group names: Object.keys(groups).sort().

  6. 6

    Empty states. An empty list should never look like a broken page. Decide on purpose what each case shows: the whole list is empty ("No contacts match 'zz'", often with a hint or a button such as Clear search), or one group is empty (show the column with "Nothing here", or hide the group). Use the conditional tools from Topic 3.1: list.length === 0 ? <Empty /> : <List />. Also keep "empty" separate from "still loading" and "failed to load": a list that hasn't arrived yet is not empty (Phase 14).

Explain it without notes

01

How do you render nested data in React, and what are the key rules at each level?

02

Your API returns a flat array of orders, each with a status. How do you render them grouped by status, in a fixed order?

03

What makes a good empty state, and why should it be different from a loading state?

04

Why extract a component for each level of a nested list?

Practice

01

Render const classes = [{ id: "5a", name: "5A", students: [{ roll: 1, name: "Isha" }, { roll: 2, name: "Kabir" }] }, { id: "5b", name: "5B", students: [{ roll: 1, name: "Neel" }] }] as headings with a list of students under each.

02

Given const words = ["apple", "avocado", "banana", "blueberry", "cherry"], render them grouped by first letter, letters in alphabetical order.

03

Write a ShoppingList that shows Your list is empty. when items is an empty array and a <ul> of item names otherwise.

Trade-offs

  • ↔

    Nesting data the way the UI shows it makes rendering easy but updates hard; flat, normalised data makes updates easy and needs a grouping step before rendering.

  • ↔

    Showing every group even when empty keeps the layout stable and predictable; hiding empty groups saves space. Pick per screen: boards usually show empty columns, search results usually hide empty groups.

Done when you can

  • I can render a list inside a list with a map inside a map.

  • I put keys on every level and know they only need to be unique among siblings.

  • I can group flat data with Object.groupBy or reduce before rendering.

  • I can render groups in a fixed or sorted order.

  • I design an empty state for the whole list and for empty groups.