Nested Tabs From a Flat Array
by Pratik Rai
Read the full write-upYou are given a flat array of items, each with an id, a name and a parentId.
Render them as a nested structure: every item appears under its parent, at any depth. Selecting one shows its panel.
Requirements
Build a tree from the flat array — a
parentIdofnullmeans a top-level item.Render children nested under their parent, to arbitrary depth.
Selecting an item highlights it and updates the panel below.
Items with children can be expanded and collapsed; leaves cannot.
Each label carries
role="tab"witharia-selected, and each twisty carriesaria-expanded.
Notes
The data is deliberately more than two levels deep:
Cachingsits underData fetching, which sits underGuides.The styling is written —
.level-nestedhandles indentation and.label-activethe selected state.
Hints
Nested Tabs From a Flat Array (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState, useMemo } from 'react';
2
3const ITEMS = [
4 { id: 1, name: 'Getting started', parentId: null },
5 { id: 2, name: 'Installation', parentId: 1 },
6 { id: 3, name: 'Project layout', parentId: 1 },
7 { id: 4, name: 'Guides', parentId: null },
8 { id: 5, name: 'Data fetching', parentId: 4 },
9 { id: 6, name: 'Caching', parentId: 5 },
10 { id: 7, name: 'Deploying', parentId: 4 },
11 { id: 8, name: 'Reference', parentId: null },
12];
13
14/**
15 * Flat list to tree, in two passes over a lookup.
16 *
17 * The lookup is built first so linking never depends on the input order — a
18 * child listed before its parent still finds it.
19 */
20function buildTree(items) {
21 const byId = new Map();
22 items.forEach((item) => byId.set(item.id, { ...item, children: [] }));
23
24 const roots = [];
25 items.forEach((item) => {
26 const node = byId.get(item.id);
27 const parent = item.parentId === null ? undefined : byId.get(item.parentId);
28 if (parent) parent.children.push(node);
29 else roots.push(node);
30 });
31
32 return roots;
33}
34
35export default function App() {
36 const [activeId, setActiveId] = useState(1);
37 // The shape only depends on ITEMS, so it is built once rather than on every
38 // keystroke of state elsewhere in the component.
39 const tree = useMemo(() => buildTree(ITEMS), []);
40
41 const active = ITEMS.find((item) => item.id === activeId);
42
43 return (
44 <div className="docs">
45 <h1>Documentation</h1>
46
47 <Level nodes={tree} activeId={activeId} onSelect={setActiveId} />
48
49 <div className="panel" role="tabpanel">
50 <h2>{active ? active.name : 'Nothing selected'}</h2>
51 <p>Panel content for section {activeId}.</p>
52 </div>
53 </div>
54 );
55}
56
57function Level({ nodes, activeId, onSelect, nested }) {
58 return (
59 <ul className={'level' + (nested ? ' level-nested' : '')} role={nested ? undefined : 'tablist'}>
60 {nodes.map((node) => (
61 <Item key={node.id} node={node} activeId={activeId} onSelect={onSelect} />
62 ))}
63 </ul>
64 );
65}
66
67function Item({ node, activeId, onSelect }) {
68 const [open, setOpen] = useState(true);
69 const hasChildren = node.children.length > 0;
70 const isActive = node.id === activeId;
71
72 return (
73 <li className="item">
74 <div className="row">
75 {hasChildren ? (
76 <button
77 className="twisty"
78 onClick={() => setOpen((v) => !v)}
79 aria-expanded={open}
80 aria-label={(open ? 'Collapse ' : 'Expand ') + node.name}
81 >
82 {open ? '▾' : '▸'}
83 </button>
84 ) : (
85 <span className="leaf-spacer" />
86 )}
87
88 <button
89 className={'label' + (isActive ? ' label-active' : '')}
90 onClick={() => onSelect(node.id)}
91 role="tab"
92 aria-selected={isActive}
93 >
94 {node.name}
95 </button>
96 </div>
97
98 {hasChildren && open && (
99 <Level nodes={node.children} activeId={activeId} onSelect={onSelect} nested />
100 )}
101 </li>
102 );
103}How it works
The whole problem is recognising that it is two problems. Trying to render nesting directly from a flat array leads to filtering the array once per level, which is both quadratic and impossible to write for unknown depth. Transform, then render.
The transform is the same one as the standalone Flat List to Tree problem: build the id → node lookup first, then link. Doing it in two passes is what makes input order irrelevant, and it is worth saying that out loud in an interview because the single-pass version needs extra handling for forward references.
Rendering is then mutual recursion — Level renders Items, Item renders a Level — and depth stops being something the code knows about.
useMemo around buildTree is not premature here: the tree depends only on the input, and without it every selection rebuilds the whole structure. On a documentation sidebar of eight items that is irrelevant; on a few thousand it is not, and the reasoning is the same either way.
The accessibility detail worth mentioning: a real tab list also moves selection with the arrow keys, and a genuinely nested navigation is usually better served by role="tree" than by tabs. Knowing which pattern you are actually building is the follow-up.