The first version of the SeatFinder seating chart was one long flat list. Eighty guests, each row with a name and a table number in parentheses, sorted by table. It worked, technically. It also looked like a spreadsheet that had lost its will to live, and finding your own table meant scanning the whole page. A seating chart is not a list of guests. It is a list of tables, and each table has its own list of guests, and the page should look like that.
The fix was a map inside a map. It felt weird the first time I wrote it, like I was nesting something that should stay flat, and I spent a while wondering if there was a cleaner way. There is not, really. When the data is nested, the render is nested, and the cleanest version is the one that admits it.
the shape of the data decides the shape of the render
The data already had the answer in it. Each table is an object with a guests array inside it, because that is how a wedding actually works. Nobody thinks of a seating chart as eighty loose guests. They think of it as ten tables with eight people each.
seating-data.ts
type Guest = { id: string; name: string }; type Table = { id: string; name: string; guests: Guest[] }; const tables: Table[] = [ { id: 't1', name: 'Table 1', guests: [ { id: 'g1', name: 'Sarah Chen' }, { id: 'g2', name: 'Michael Okafor' }, ], }, { id: 't2', name: 'Table 2', guests: [ { id: 'g3', name: 'Amira Yusuf' }, { id: 'g4', name: 'Jon Park' }, ], }, ]; Once the data looks like that, the component almost writes itself. The outer map turns each table into a section, and the inner map turns each guest into a row inside that section. Two levels of data, two levels of map, and the JSX mirrors the shape of the thing it is showing.
SeatingChart.tsx
function SeatingChart({ tables }: { tables: Table[] }) { return ( <div> {tables.map((table) => ( <section key={table.id}> <h3>{table.name}</h3> <ul> {table.guests.map((guest) => ( <li key={guest.id}>{guest.name}</li> ))} </ul> </section> ))} </div> ); } That is the pattern, and it generalizes further than seating charts. A file browser is folders with files inside. A comment thread is comments with replies inside. A settings page is sections with options inside. Any time the screen shows groups with items in them, the render is a map inside a map, whether you write it that way or fight it with a flattened list and a bunch of conditional headers.
the key rule applies twice
The part I got wrong the first time was the key. I put one on the outer map and forgot the inner one, and React answered with the warning every React developer has seen at least once: each child in a list should have a unique key. The fix is obvious once you say it out loud. There are two lists here, so there are two key props, one on each level.
One detail that surprised me: an inner key only has to be unique among its siblings, not across the whole page. Two guests named Sarah at two different tables can both have the id g1 in their own lists and React is fine with it, because each map is its own list. In practice I still use globally unique ids, because a guest moves between tables and I want the id to follow the person, but the rule itself is per list, not per page.
The index-as-key trap is worse in nested lists than in flat ones, because there are two indexes to get wrong. If a guest moves from Table 1 to Table 2, and both lists are keyed by index, React sees the same keys in the same positions and keeps the old state on the wrong rows. I learned this one from a checkbox. A guest checked in at Table 1, I moved them to Table 2, and the checkmark stayed behind on the person who took their seat. The checkbox belonged to the row, not the guest, because the key said so. Stable ids on both levels fixed it, and it has stayed fixed.
Try it: nested maps, live, with real keys
Try it live on nayemalam.com.
Check a few guests in, then hit reverse table order. The checkmarks travel with the guests, because each row is keyed by a stable id, on both levels of the nesting. Key by index instead and the checkmarks would stay glued to their rows while the names moved around them.
when the inner map deserves its own component
A nested map reads fine at two levels. At three, the JSX starts to look like a staircase, and the indentation is telling you something. The usual fix is to pull the inner map out into its own component, which also gives the inner list a name, and named things are easier to think about than anonymous blocks.
SeatingChart.tsx
function TableGuests({ guests }: { guests: Guest[] }) { return ( <ul> {guests.map((guest) => ( <li key={guest.id}>{guest.name}</li> ))} </ul> ); } function SeatingChart({ tables }: { tables: Table[] }) { return ( <div> {tables.map((table) => ( <section key={table.id}> <h3>{table.name}</h3> <TableGuests guests={table.guests} /> </section> ))} </div> ); } I do this when the inner list has any logic of its own, sorting, a checked-in state, an empty state for a table with no guests yet. If the inner map is just turning items into rows, leaving it inline is fine. The rule I use is the same one I use everywhere else: extract when the block has a name and a reason to exist on its own, not before.
the flat alternative, for when the grouping is only visual
Sometimes the nesting is in the data but the screen does not need it. A search box over all guests, for example, does not care which table anyone sits at. Flattening first, then mapping once, is the honest version of that. flatMap is the method for it: map plus one level of flattening, so tables.flatMap((table) => table.guests) gives you one flat array of every guest, and a single map renders them.
all-guests.ts
const allGuests = tables.flatMap((table) => table.guests); console.log(allGuests.length); // 4, every guest from every table, in one flat list The choice is about what the screen is showing. Grouped on screen, nested map. One continuous list on screen, flatten first and map once. I reach for the nested version more often than I expected, because most grouped data wants to stay grouped where the user can see it.
What I took away from the seating chart is that the render should follow the data, not fight it. Nested data gets a nested map, a key on every level, and stable ids instead of indexes, especially when items can move between groups. The flat list I started with was not simpler, it was just the same complexity with the structure hidden, and the moment the page needed to look like a seating chart, the nested version was the one that already did.
Originally posted on nayemalam.com/blog.
