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
mapinside anothermap: 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 ?? bgivesbwhenaisnullorundefined.
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.
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" }] },
];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.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.
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.
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;
}, {});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.
{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
Add a category
In the menu, add a
dessertscategory with two dishes whose ids are1and2. Predict the console and whether React warns. (A third line,Desserts: 2 dishes, and no warning.) - 2
Show empty columns properly
In the board, delete the task with status
doing. The Doing column still appears with(0)because you mapCOLUMNS. Add{list.length === 0 && <p>Nothing here</p>}inside the column. - 3
Group with reduce
In the board, replace
Object.groupBy(...)with areducethat builds the same object. The page and the console line must stay the same.
Code & diagrams
Both categories have a dish with key `1`, and there is no warning: keys are compared only inside the same list.
Preview
Console
Nothing logged yet.Expected console output
Starters: 2 dishes
Mains: 3 dishes`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
Console
Nothing logged yet.Expected console output
[ 'done', 'doing', 'todo' ]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
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>.
Break #2
Map an object as if it were an array
In the board, write byStatus.map(...) to render the groups directly.
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.groupByreturns a null-prototype object (no inheritedtoStringand friends), which makes it safe to use arbitrary strings as group names.Map.groupByreturns aMapand 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'screateEntityAdapter(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
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
mapthe outer array, and inside each outer item youmapits inner array. That's all a nested list is: amapinside amap. - 2
Keys at each level. Every array you render needs its own keys. The outer
mapkeys each category (key={cat.id}), the innermapkeys 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 id1. If inner ids are not unique within a group, combine fields:key={cat.id + "-" + dish.id}. - 3
Extract a component per level. A
mapinside amapinside JSX gets hard to read. Move the inner list into a component (<Category category={cat} />renders its own dishes). Each component then has onemap, 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
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 usereduce: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
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, readingbyStatus[col.status] ?? []for each. For groups that should be alphabetical, sort the group names:Object.keys(groups).sort(). - 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
How do you render nested data in React, and what are the key rules at each level?
Your API returns a flat array of orders, each with a status. How do you render them grouped by status, in a fixed order?
What makes a good empty state, and why should it be different from a loading state?
Why extract a component for each level of a nested list?
Practice
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.
Given const words = ["apple", "avocado", "banana", "blueberry", "cherry"], render them grouped by first letter, letters in alphabetical order.
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
mapinside amap.I put keys on every level and know they only need to be unique among siblings.
I can group flat data with
Object.groupByorreducebefore rendering.I can render groups in a fixed or sorted order.
I design an empty state for the whole list and for empty groups.