Build a file explorer step by step, as in a coding interview. A flat map of nodes, a component that renders itself, a reducer, and a tree that works with a keyboard.
This part starts a new stage. Until now, each part taught one idea. In this stage, each part builds one small app, using many earlier ideas together.
Many companies give tasks like this in an interview. It is often called a machine coding round. You get a task, and you write working code while the interviewer watches. A common task is a file explorer. That is the panel of folders and files at the side of a code editor.
We’ll build it in five steps. Each step is a full app that runs in the page, and each one grows from the one before. On the way we use objects and arrays in state (Part 5), keys (Part 8) and lifting state up (Part 9). We also use useReducer (Part 15), memo (Part 20), ARIA (Part 46) and arrow keys (Part 47).
Try this first
Read this code. Don’t press Run yet.
import { useState } from 'react'
type TreeNode = {
id: string
name: string
kind: 'folder' | 'file'
children: TreeNode[]
}
const project: TreeNode = {
id: 'root',
name: 'project',
kind: 'folder',
children: [
{
id: 'src',
name: 'src',
kind: 'folder',
children: [
{
id: 'ui',
name: 'components',
kind: 'folder',
children: [{ id: 'btn', name: 'Button.tsx', kind: 'file', children: [] }],
},
{ id: 'app', name: 'App.tsx', kind: 'file', children: [] },
],
},
{ id: 'pkg', name: 'package.json', kind: 'file', children: [] },
],
}
function Item({ node }: { node: TreeNode }) {
const [open, setOpen] = useState(false)
if (node.kind === 'file') {
return <li>{node.name}</li>
}
return (
<li>
<button onClick={() => setOpen(!open)}>
{open ? '▾' : '▸'} {node.name}
</button>
{open && (
<ul>
{node.children.map(child => (
<Item key={child.id} node={child} />
))}
</ul>
)}
</li>
)
}
export default function App() {
return (
<ul>
{project.children.map(child => (
<Item key={child.id} node={child} />
))}
</ul>
)
}
Look at Item. Inside it, there is another <Item>. A component that uses itself is called recursive. A function calling itself is called recursion. Make a guess. Will this page freeze, because Item keeps calling Item forever? Or will it show a tree?
Now press Run. Click src, then components.
It shows a tree, and it doesn’t freeze. At the start you see only src and package.json. Click src, and its two children appear under it. Click components, and Button.tsx appears.
The recursion stops for two reasons. A file returns a plain <li> with no <Item> in it. And a closed folder shows no children at all. Each of these is a base case: a case where a recursive function stops calling itself. Every recursive component needs one. You’ll see what happens without one under Common mistakes.
This is the core of the whole task. The rest of this part is about what an interviewer asks for next.
The task
Here is the task, the way an interviewer might give it:
- Show a tree of folders and files. A folder can hold files and other folders, as deep as you like.
- Click a folder to open or close it.
- Add a new file or folder inside the folder you picked.
- Give a file or a folder a new name.
- Delete a file or a folder. Deleting a folder deletes everything inside it.
- It should work with only a keyboard, and with a screen reader.
Before you write code, ask questions. Can two files in one folder share a name? Should folders show first? Does it save to a server? For this part, names may repeat, items keep the order they were added in, and nothing is saved.
Then plan the data. That choice makes everything after it easy or hard.
Step 1: the shape of the data
There are two common ways to hold a tree in state.
Nested: each folder holds its children
“Try this first” used a nested tree. Each folder has a children array, and each child is a whole object with its own children. It looks like the folders on the screen, so it is easy to read.
Now change the name of one file. In state, you can’t change an object in place (Part 5). You must make a new object for the file. You must also make a new object for each folder above it, up to the top. A recursive function does it in a few lines:
type TreeNode = {
id: string
name: string
kind: 'folder' | 'file'
children: TreeNode[]
}
function renameIn(node: TreeNode, id: string, name: string): TreeNode {
if (node.id === id) {
return { ...node, name }
}
return {
...node,
children: node.children.map(child => renameIn(child, id, name)),
}
}
It works, but look at what it costs. We gave Button.tsx a new name in the tree from “Try this first”. The function made 6 new node objects out of 6. Even package.json is a new object, though nothing in it changed.
A smarter version copies only the folders on the way down. In our test, that made 4 new nodes. But it is longer, and easy to get wrong in an interview. Finding a node is slow too. To find package.json, our walk looked at all 6 nodes.
Flat: a map from id to node
The other way is to keep every node at the same level. A map here is a plain object whose keys are ids. Each node keeps a list of its children’s ids, not the children themselves. React’s docs show this shape on their page about state structure, and call it “flat”.
type FileNode = {
id: string
name: string
kind: 'folder' | 'file'
parentId: string | null
childIds: string[]
}
type Nodes = Record<string, FileNode>
const nodes: Nodes = {
root: { id: 'root', name: 'project', kind: 'folder', parentId: null, childIds: ['src', 'pkg'] },
src: { id: 'src', name: 'src', kind: 'folder', parentId: 'root', childIds: ['ui', 'app'] },
ui: { id: 'ui', name: 'components', kind: 'folder', parentId: 'src', childIds: ['btn'] },
btn: { id: 'btn', name: 'Button.tsx', kind: 'file', parentId: 'ui', childIds: [] },
app: { id: 'app', name: 'App.tsx', kind: 'file', parentId: 'src', childIds: [] },
pkg: { id: 'pkg', name: 'package.json', kind: 'file', parentId: 'root', childIds: [] },
}
function rename(nodes: Nodes, id: string, name: string): Nodes {
return { ...nodes, [id]: { ...nodes[id], name } }
}
Record<string, FileNode> is TypeScript for “an object whose keys are text and whose values are FileNodes”. [id]: ... inside an object sets the key whose name is in id.
rename is now one line. We checked: it made 1 new node and 1 new map. The other 5 nodes were the same objects as before. Finding a node is nodes[id], with no walk at all.
Why does each node keep a parentId too? Some actions go up the tree. Delete must take the id out of the parent’s childIds. The Left arrow key moves to the parent, in Step 5. With parentId, the parent is one step away.
There is a cost, and an interviewer may ask about it. parentId and childIds say the same fact twice: “btn is in ui“. React’s docs warn against keeping the same data twice in state, because the two copies can disagree. So all changes go through a few small functions, and Step 4 tests that they keep both copies right.
One more cost. In the nested tree, a deleted folder takes its children with it. In the flat map, the children are separate keys. If you remove only the folder’s key, its children stay in the map. Nothing shows them, but they are still there. We tried it: after deleting only src, 5 keys were left, but only 2 could be reached from the root. React’s docs say “Ideally, you would also remove the deleted items (and their children!)”. Step 3 does that.
| Nested | Flat map | |
|---|---|---|
| Find a node by id | walk the tree | nodes[id] |
| Change one node’s name | copy every folder above it | copy one node |
| Find a node’s parent | walk the tree again | nodes[node.parentId] |
| Delete a folder | its children go with it | remove its children too |
| Move a node to another folder | remove it here, copy, add it there | change two childIds and one parentId |
Most interviewers like the flat map, and we use it for the rest of this part.
An everyday example
Think of a school. The nested way is a box for each class, with a card for each student inside. To change one card, you open every box on the way. The flat way is one drawer of cards, sorted by student number. Each card says the student’s class. To change one card, you pull it out by number.
The exact version
The drawer only works if the numbers never repeat. So every node needs an id that no other node has, and that never changes. Step 3 makes them.
Step 2: render the flat map with recursion
The recursive component looks almost the same. It gets an id, not a whole node, and looks the node up in the map:
import { useState } from 'react'
type FileNode = {
id: string
name: string
kind: 'folder' | 'file'
parentId: string | null
childIds: string[]
}
type Nodes = Record<string, FileNode>
const nodes: Nodes = {
root: { id: 'root', name: 'project', kind: 'folder', parentId: null, childIds: ['src', 'pkg'] },
src: { id: 'src', name: 'src', kind: 'folder', parentId: 'root', childIds: ['ui', 'app'] },
ui: { id: 'ui', name: 'components', kind: 'folder', parentId: 'src', childIds: ['btn'] },
btn: { id: 'btn', name: 'Button.tsx', kind: 'file', parentId: 'ui', childIds: [] },
app: { id: 'app', name: 'App.tsx', kind: 'file', parentId: 'src', childIds: [] },
pkg: { id: 'pkg', name: 'package.json', kind: 'file', parentId: 'root', childIds: [] },
}
function Item({ id }: { id: string }) {
const [open, setOpen] = useState(false)
const node = nodes[id]
if (node.kind === 'file') {
return <li>{node.name}</li>
}
return (
<li>
<button onClick={() => setOpen(!open)}>
{open ? '▾' : '▸'} {node.name}
</button>
{open && (
<ul>
{node.childIds.map(childId => (
<Item key={childId} id={childId} />
))}
</ul>
)}
</li>
)
}
export default function App() {
return (
<ul>
{nodes.root.childIds.map(id => (
<Item key={id} id={id} />
))}
</ul>
)
}
Run it and open the folders. It works the same as “Try this first”.
Three things to notice:
- The key is the node’s id. Ids never change and never repeat, as Part 8 asks of a key.
- The root is not shown.
Appstarts with the root’s children, as a code editor does. - Each folder keeps its own
openstate. That changes in the next step.
Step 3: add, rename and delete
Now the three actions. The map must now be state, because it changes. We also need to know which node is picked, which folders are open, and what is in the name box.
Where the state lives
When you add a file to a closed folder, the folder should open, so you can see the new file. So the code that adds the file must change the folder’s open state. But in Step 2, that state is inside the folder’s own component, and a parent can’t reach it.
This is Part 9‘s problem, and its answer: lift the state up. App now keeps one object, open, from folder id to true or false. It passes it down, and Item reads it.
Ids for new nodes
Each new node needs a new id. Part 8 gave two ways from React’s docs: a counter, like nextId++, or crypto.randomUUID(). That is a browser function that makes a long random id, which is almost sure never to repeat.
Can the playground use it? MDN says it works only “in secure contexts”. A page is a secure context when it comes over HTTPS, or from your own computer (localhost). A frame is secure only if the page around it is. The playground runs your code in a sandboxed frame: a closed box that can’t touch the page around it. So we checked.
We loaded this page from localhost in Chromium, Firefox and WebKit. In the playground frame, crypto.randomUUID was a function, and two calls gave two different ids. Then we loaded the same page over plain HTTP, from the computer’s network address. There, it was undefined in all three browsers, and calling it threw a TypeError. This site uses HTTPS, so by MDN’s rule it should work here too. We tested a copy of this page, not the live site. A counter works everywhere.
We make the id in the click handler, not later. You’ll see why in Step 4.
The app
import { useState } from 'react'
type FileNode = {
id: string
name: string
kind: 'folder' | 'file'
parentId: string | null
childIds: string[]
}
type Nodes = Record<string, FileNode>
type Open = Record<string, boolean>
const firstNodes: Nodes = {
root: { id: 'root', name: 'project', kind: 'folder', parentId: null, childIds: ['src', 'pkg'] },
src: { id: 'src', name: 'src', kind: 'folder', parentId: 'root', childIds: ['ui', 'app'] },
ui: { id: 'ui', name: 'components', kind: 'folder', parentId: 'src', childIds: ['btn'] },
btn: { id: 'btn', name: 'Button.tsx', kind: 'file', parentId: 'ui', childIds: [] },
app: { id: 'app', name: 'App.tsx', kind: 'file', parentId: 'src', childIds: [] },
pkg: { id: 'pkg', name: 'package.json', kind: 'file', parentId: 'root', childIds: [] },
}
// This node's id, and the ids of everything inside it.
function idsUnder(nodes: Nodes, id: string): string[] {
return [id, ...nodes[id].childIds.flatMap(childId => idsUnder(nodes, childId))]
}
type ItemProps = {
id: string
nodes: Nodes
open: Open
selectedId: string | null
onPick: (id: string) => void
}
function Item({ id, nodes, open, selectedId, onPick }: ItemProps) {
const node = nodes[id]
const isOpen = node.kind === 'folder' && open[id] === true
const mark = node.kind === 'file' ? '' : isOpen ? '▾ ' : '▸ '
return (
<li>
<button
onClick={() => onPick(id)}
style={{ fontWeight: id === selectedId ? 'bold' : 'normal' }}
>
{mark}
{node.name}
</button>
{isOpen && (
<ul>
{node.childIds.map(childId => (
<Item
key={childId}
id={childId}
nodes={nodes}
open={open}
selectedId={selectedId}
onPick={onPick}
/>
))}
</ul>
)}
</li>
)
}
export default function App() {
const [nodes, setNodes] = useState(firstNodes)
const [open, setOpen] = useState<Open>({})
const [selectedId, setSelectedId] = useState<string | null>(null)
const [name, setName] = useState('')
function pick(id: string) {
setSelectedId(id)
if (nodes[id].kind === 'folder') {
setOpen({ ...open, [id]: !open[id] })
}
}
function add(kind: 'folder' | 'file') {
if (name.trim() === '') return
// A new node goes into the picked folder, or next to the picked file.
let parentId = 'root'
if (selectedId !== null) {
const picked = nodes[selectedId]
parentId = picked.kind === 'folder' ? picked.id : (picked.parentId ?? 'root')
}
const parent = nodes[parentId]
const id = crypto.randomUUID()
setNodes({
...nodes,
[id]: { id, name: name.trim(), kind, parentId, childIds: [] },
[parentId]: { ...parent, childIds: [...parent.childIds, id] },
})
setOpen({ ...open, [parentId]: true })
setSelectedId(id)
setName('')
}
function rename() {
if (selectedId === null || name.trim() === '') return
setNodes({ ...nodes, [selectedId]: { ...nodes[selectedId], name: name.trim() } })
setName('')
}
function remove() {
if (selectedId === null) return
const node = nodes[selectedId]
if (node.parentId === null) return
const gone = idsUnder(nodes, node.id)
const next: Nodes = {}
for (const key in nodes) {
if (!gone.includes(key)) next[key] = nodes[key]
}
const nextOpen: Open = {}
for (const key in open) {
if (!gone.includes(key)) nextOpen[key] = open[key]
}
const parent = next[node.parentId]
next[parent.id] = { ...parent, childIds: parent.childIds.filter(c => c !== node.id) }
setNodes(next)
setOpen(nextOpen)
// The picked node is gone, so pick its parent.
setSelectedId(parent.id === 'root' ? null : parent.id)
}
return (
<div>
<input aria-label="Name" value={name} onChange={e => setName(e.target.value)} />
<button onClick={() => add('file')}>New file</button>
<button onClick={() => add('folder')}>New folder</button>
<button onClick={rename}>Rename</button>
<button onClick={remove}>Delete</button>
<ul>
{nodes.root.childIds.map(id => (
<Item
key={id}
id={id}
nodes={nodes}
open={open}
selectedId={selectedId}
onPick={pick}
/>
))}
</ul>
<p>Nodes in the map: {Object.keys(nodes).length}</p>
</div>
)
}
Run it, and try this:
- Click
srcto open it and pick it. - Type
libin the box and click “New folder”. It appears insidesrc, and it is picked. - Type
util.tsand click “New file”. It goes insidelib, andlibopens. - Click
srcagain. It closes, and it is picked. - Click “Delete”.
srcgoes, with everything in it.
The last line counts the keys in the map: 6 at the start, 8 after the two adds, and 2 after the delete. src and the 5 nodes inside it all left the map.
Here is how each action works.
- Pick. A click picks the node. On a folder, it also opens or closes it.
open[id]isundefinedfor a folder we never touched, and!undefinedistrue, so the first click opens it. - Add. The new node goes into the picked folder, or next to the picked file, or into the root. The new map has two changed keys: the new node, and its parent with one more id in
childIds. - Rename. It gives the picked node a new name: one new node in a new map, as in Step 1.
- Delete.
idsUndercollects the node’s id and every id inside it. It is recursive too. Its base case is a node with no children:flatMapover an empty array gives an empty array. The code copies every other key into a new map,next. Then it puts in a new parent without the deleted id. It also drops the deleted folders fromopen.
Is next[key] = nodes[key] a mutation? It changes next, which this function just made, and nothing else has seen. React’s docs call this “local mutation”, and it is fine. What you must not change is the old nodes.
Here is the delete, step by step:
A flat map, rendered with recursion, and a delete that removes a whole folder. Press play, or step through it.
- The map has six nodes. Each folder lists its children’s ids.
Apprenders the root’s children. EachItemrenders the children of its node, if it is an open folder. Heresrcandcomponentsare open. A file, or a closed folder, renders noItem: that is the base case.- You pick
srcand click “Delete”.idsUnderwalks down fromsrcand collects 4 ids:src,ui,btnandapp. - The new map keeps only the other keys. The root gets a new
childIdswithoutsrc. - React renders again from the root. Only
package.jsonis left.
Step 4: move the actions into a reducer
The App above has four handlers. add alone calls four set functions. That is where bugs hide. Part 15 had the answer: a reducer, one function that takes the state and an action and returns the next state. The handlers then only say what happened.
All the state goes into one object. Each action is a plain object with a type:
type FileNode = {
id: string
name: string
kind: 'folder' | 'file'
parentId: string | null
childIds: string[]
}
type Nodes = Record<string, FileNode>
type State = {
nodes: Nodes
open: Record<string, boolean>
selectedId: string | null
}
type Action =
| { type: 'selected'; id: string }
| { type: 'toggled'; id: string }
| { type: 'added'; id: string; parentId: string; name: string; kind: 'folder' | 'file' }
| { type: 'renamed'; id: string; name: string }
| { type: 'deleted'; id: string }
Look at added. It carries the new id. The click handler makes the id and puts it in the action. The reducer doesn’t make it.
A reducer must be pure: the same state and action must always give the same next state (Part 15). A random id breaks that. A counter is no better. We tried nextId++ inside the reducer of the Step 5 app. Strict Mode calls the reducer twice for each action and keeps one result. So three clicks on “New file” gave the ids 2, 4 and 6. With Strict Mode off, they were 1, 2 and 3.
The ids 2, 4 and 6 still don’t repeat, so nothing breaks yet. The skipped numbers are a sign, and only in development. They show that the reducer changes something outside itself. With the counter in the click handler, the ids were 1, 2 and 3 with Strict Mode on too.
There is a second reason. A test calls the reducer with an action. If the reducer made the id, the test couldn’t know it ahead of time. With the id in the action, the test picks it, as the checks below do with 'n1'.
Here is the reducer, with checks that call it as a plain function, as Part 15 did. No clicks, no browser.
type FileNode = {
id: string
name: string
kind: 'folder' | 'file'
parentId: string | null
childIds: string[]
}
type Nodes = Record<string, FileNode>
type State = {
nodes: Nodes
open: Record<string, boolean>
selectedId: string | null
}
type Action =
| { type: 'selected'; id: string }
| { type: 'toggled'; id: string }
| { type: 'added'; id: string; parentId: string; name: string; kind: 'folder' | 'file' }
| { type: 'renamed'; id: string; name: string }
| { type: 'deleted'; id: string }
function idsUnder(nodes: Nodes, id: string): string[] {
return [id, ...nodes[id].childIds.flatMap(childId => idsUnder(nodes, childId))]
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'selected': {
return { ...state, selectedId: action.id }
}
case 'toggled': {
return { ...state, open: { ...state.open, [action.id]: !state.open[action.id] } }
}
case 'added': {
const parent = state.nodes[action.parentId]
const node: FileNode = {
id: action.id,
name: action.name,
kind: action.kind,
parentId: parent.id,
childIds: [],
}
return {
nodes: {
...state.nodes,
[node.id]: node,
[parent.id]: { ...parent, childIds: [...parent.childIds, node.id] },
},
open: { ...state.open, [parent.id]: true },
selectedId: node.id,
}
}
case 'renamed': {
const node = state.nodes[action.id]
return { ...state, nodes: { ...state.nodes, [node.id]: { ...node, name: action.name } } }
}
case 'deleted': {
const node = state.nodes[action.id]
if (node.parentId === null) return state
const gone = idsUnder(state.nodes, node.id)
const nodes: Nodes = {}
for (const key in state.nodes) {
if (!gone.includes(key)) nodes[key] = state.nodes[key]
}
const open: Record<string, boolean> = {}
for (const key in state.open) {
if (!gone.includes(key)) open[key] = state.open[key]
}
const parent = nodes[node.parentId]
nodes[parent.id] = { ...parent, childIds: parent.childIds.filter(c => c !== node.id) }
let selectedId = state.selectedId
if (selectedId !== null && gone.includes(selectedId)) {
selectedId = parent.id === 'root' ? null : parent.id
}
return { nodes, open, selectedId }
}
default: {
const missing: never = action
throw new Error('Unknown action: ' + JSON.stringify(missing))
}
}
}
const start: State = {
nodes: {
root: { id: 'root', name: 'project', kind: 'folder', parentId: null, childIds: ['src', 'pkg'] },
src: { id: 'src', name: 'src', kind: 'folder', parentId: 'root', childIds: ['ui', 'app'] },
ui: { id: 'ui', name: 'components', kind: 'folder', parentId: 'src', childIds: ['btn'] },
btn: { id: 'btn', name: 'Button.tsx', kind: 'file', parentId: 'ui', childIds: [] },
app: { id: 'app', name: 'App.tsx', kind: 'file', parentId: 'src', childIds: [] },
pkg: { id: 'pkg', name: 'package.json', kind: 'file', parentId: 'root', childIds: [] },
},
open: {},
selectedId: null,
}
const results: string[] = []
function check(name: string, ok: boolean) {
results.push((ok ? 'pass: ' : 'FAIL: ') + name)
}
const added = reducer(start, { type: 'added', id: 'n1', parentId: 'ui', name: 'Card.tsx', kind: 'file' })
check('a new file is in the map', added.nodes.n1.name === 'Card.tsx')
check('its parent lists it last', added.nodes.ui.childIds.join() === 'btn,n1')
check('it knows its parent', added.nodes.n1.parentId === 'ui')
check('the parent folder opens', added.open.ui === true)
check('the old state did not change', start.nodes.ui.childIds.join() === 'btn')
const renamed = reducer(start, { type: 'renamed', id: 'btn', name: 'Card.tsx' })
check('rename makes one new node', renamed.nodes.btn !== start.nodes.btn)
check('the other nodes are the same objects', renamed.nodes.app === start.nodes.app)
const deleted = reducer(start, { type: 'deleted', id: 'src' })
check('delete removes the folder and all 3 inside it', Object.keys(deleted.nodes).join() === 'root,pkg')
check('the parent no longer lists it', deleted.nodes.root.childIds.join() === 'pkg')
check('the root cannot be deleted', reducer(start, { type: 'deleted', id: 'root' }) === start)
const busy: State = { ...start, open: { src: true, ui: true }, selectedId: 'pkg' }
const cleaned = reducer(busy, { type: 'deleted', id: 'src' })
check('delete forgets the open folders it removed', Object.keys(cleaned.open).length === 0)
check('a picked node outside the folder stays picked', cleaned.selectedId === 'pkg')
export default function App() {
return (
<ul>
{results.map(r => (
<li key={r}>{r}</li>
))}
</ul>
)
}
Run it. All twelve checks say “pass”.
Now break it on purpose. Press Edit, find the deleted case, and change const gone = idsUnder(state.nodes, node.id) to const gone = [node.id]. Run it again. Two checks fail: “delete removes the folder and all 3 inside it”, and “delete forgets the open folders it removed”. That is the “children stay in the map” bug from Step 1, caught without a click.
Three things are worth saying out loud in an interview:
- Each
casemakes new objects only for what changed. A check proves thatrenamedkeeps the other nodes. deletedreturns the samestatefor the root, which tells React nothing changed. For any other node, it also drops the open flags of the deleted folders. It moves the pick to the parent only if the picked node was deleted.addedsets the new node’sparentIdand the parent’schildIdsin one place. That keeps the two copies right.
The default case is Part 15’s exhaustive check. We took out the renamed case, and TypeScript said: Type '{ type: "renamed"; id: string; name: string; }' is not assignable to type 'never'. You see that in an editor. The playground doesn’t check types, but the throw still helps there. In the Step 5 app with no renamed case, a click on “Rename” stopped the app with Unknown action: {"type":"renamed","id":"pkg","name":"b.json"}.
Step 5: a real tree, for keyboards and screen readers
The app works with a mouse. But to a screen reader, every row is just a button. Nothing says that src is a folder, that it is open, or how deep it is. And every row is a Tab stop, so a long tree takes many presses of Tab to get past.
The W3C’s ARIA guide, the APG, from Part 39, has a pattern for this: the tree view. It is a list with levels inside levels.
The roles
These are the APG’s rules for the roles, in short:
- The whole tree is in an element with role
tree. It needs a name, fromaria-labeloraria-labelledby. - Each node has role
treeitem. - A folder’s children are in an element with role
group, inside the folder’streeitem. - An open folder has
aria-expanded="true", and a closed one has"false". A file has noaria-expandedat all. The APG says that otherwise, screen readers would describe a file as if it had children. - In a tree where you pick one item, the picked one has
aria-selected="true", and the others have"false".
What about aria-level, which says how deep an item is? The APG asks for it, with aria-setsize and aria-posinset, when some nodes are missing from the page. Its words are “due to dynamic loading as the user moves focus in or scrolls the tree”. In our tree, every visible node is on the page, inside its parent’s group. So the browser can work out the level. We checked Chromium’s accessibility tree: src had level=1, components had level=2, and Button.tsx had level=3. Our code has no aria-level.
That is Chromium only. The APG’s examples say browsers “can, but are not required to” work out these values. We didn’t check Firefox’s or WebKit’s tree.
The group does one more job. It keeps the children’s names out of the folder’s name. The APG’s file tree example says it “prevents browsers from including the content of the nodes in the group”. In Chromium, the open src folder was named just src.
The arrow in front of a folder’s name is only a picture. It sits in a <span aria-hidden="true">, so it isn’t part of the name. Before we added that, Chromium named the folder ▾ src.
So each row is now the treeitem itself, an <li>, not a button. The treeitem takes the focus, so a button inside it would be a second focus stop on the same row.
The keys
These are the APG’s keys for a tree, for a tree whose items go down the page:
| Key | What it does |
|---|---|
| Down arrow | Moves to the next item you can see. It doesn’t open or close anything. |
| Up arrow | Moves to the item above. |
| Right arrow | On a closed folder, opens it, and the focus stays. On an open folder, moves to its first child. On a file, does nothing. |
| Left arrow | On an open folder, closes it. On a file or a closed folder, moves to its parent. On a top-level file or closed folder, does nothing. |
| Home | Moves to the first item. |
| End | Moves to the last item you can see. |
| Enter | Does the item’s main action. For a folder, the APG says that can be to open or close it. |
| A letter | Moves to the next item whose name starts with it. This is type-ahead, from Part 47. The APG recommends it “for all trees”. |
The APG’s file tree example adds that the Down arrow on the last item “does nothing”. It doesn’t wrap to the top, as Part 39’s tabs did. Our app has every key but type-ahead. Practice 3 adds it.
One Tab stop: a roving tabindex
Only one treeitem has tabIndex={0}. All the others have -1. This is the roving tabindex from Part 39. The 0 moves with the focus, so the whole tree is one Tab stop. The APG’s example does the same.
Which one? The picked item. With nothing picked, the first item, as the APG says: “If none of the nodes are selected before the tree receives focus, focus is set on the first node.”
In our tree, the focus also picks the item. The APG calls this “selection follows focus”. It is fine here, because picking only marks where “New file” or “Delete” will act. If picking opened the file, every arrow press would open a file. The APG’s own example keeps focus and picking apart.
The list of items you can see
The Up and Down arrows need the items you can see, from top to bottom. One more recursive function gives that list:
type FileNode = { id: string; kind: 'folder' | 'file'; childIds: string[] }
type State = {
nodes: Record<string, FileNode>
open: Record<string, boolean>
}
function visibleIds(state: State, id: string): string[] {
return state.nodes[id].childIds.flatMap(childId =>
state.open[childId] ? [childId, ...visibleIds(state, childId)] : [childId],
)
}
For each child, it gives the child’s id. If the child is an open folder, it also gives what is visible inside it. A file or a closed folder is a base case. With src open, visibleIds(state, 'root') gave src, ui, app, pkg.
The full app
This is the final app. The reducer is the one from Step 4, without the checks.
import { memo, useReducer, useRef, useState, type Dispatch, type KeyboardEvent } from 'react'
type FileNode = {
id: string
name: string
kind: 'folder' | 'file'
parentId: string | null
childIds: string[]
}
type Nodes = Record<string, FileNode>
type State = {
nodes: Nodes
open: Record<string, boolean>
selectedId: string | null
}
type Action =
| { type: 'selected'; id: string }
| { type: 'toggled'; id: string }
| { type: 'added'; id: string; parentId: string; name: string; kind: 'folder' | 'file' }
| { type: 'renamed'; id: string; name: string }
| { type: 'deleted'; id: string }
function idsUnder(nodes: Nodes, id: string): string[] {
return [id, ...nodes[id].childIds.flatMap(childId => idsUnder(nodes, childId))]
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'selected': {
return { ...state, selectedId: action.id }
}
case 'toggled': {
return { ...state, open: { ...state.open, [action.id]: !state.open[action.id] } }
}
case 'added': {
const parent = state.nodes[action.parentId]
const node: FileNode = {
id: action.id,
name: action.name,
kind: action.kind,
parentId: parent.id,
childIds: [],
}
return {
nodes: {
...state.nodes,
[node.id]: node,
[parent.id]: { ...parent, childIds: [...parent.childIds, node.id] },
},
open: { ...state.open, [parent.id]: true },
selectedId: node.id,
}
}
case 'renamed': {
const node = state.nodes[action.id]
return { ...state, nodes: { ...state.nodes, [node.id]: { ...node, name: action.name } } }
}
case 'deleted': {
const node = state.nodes[action.id]
if (node.parentId === null) return state
const gone = idsUnder(state.nodes, node.id)
const nodes: Nodes = {}
for (const key in state.nodes) {
if (!gone.includes(key)) nodes[key] = state.nodes[key]
}
const open: Record<string, boolean> = {}
for (const key in state.open) {
if (!gone.includes(key)) open[key] = state.open[key]
}
const parent = nodes[node.parentId]
nodes[parent.id] = { ...parent, childIds: parent.childIds.filter(c => c !== node.id) }
let selectedId = state.selectedId
if (selectedId !== null && gone.includes(selectedId)) {
selectedId = parent.id === 'root' ? null : parent.id
}
return { nodes, open, selectedId }
}
default: {
const missing: never = action
throw new Error('Unknown action: ' + JSON.stringify(missing))
}
}
}
function visibleIds(state: State, id: string): string[] {
return state.nodes[id].childIds.flatMap(childId =>
state.open[childId] ? [childId, ...visibleIds(state, childId)] : [childId],
)
}
const firstState: State = {
nodes: {
root: { id: 'root', name: 'project', kind: 'folder', parentId: null, childIds: ['src', 'pkg'] },
src: { id: 'src', name: 'src', kind: 'folder', parentId: 'root', childIds: ['ui', 'app'] },
ui: { id: 'ui', name: 'components', kind: 'folder', parentId: 'src', childIds: ['btn'] },
btn: { id: 'btn', name: 'Button.tsx', kind: 'file', parentId: 'ui', childIds: [] },
app: { id: 'app', name: 'App.tsx', kind: 'file', parentId: 'src', childIds: [] },
pkg: { id: 'pkg', name: 'package.json', kind: 'file', parentId: 'root', childIds: [] },
},
open: {},
selectedId: null,
}
type ItemProps = {
id: string
state: State
focusId: string
level: number
dispatch: Dispatch<Action>
}
function TreeItem({ id, state, focusId, level, dispatch }: ItemProps) {
const node = state.nodes[id]
const isFolder = node.kind === 'folder'
const isOpen = isFolder && state.open[id] === true
const isSelected = state.selectedId === id
return (
<li
role="treeitem"
data-id={id}
aria-expanded={isFolder ? isOpen : undefined}
aria-selected={isSelected}
tabIndex={id === focusId ? 0 : -1}
onFocus={e => {
if (e.target === e.currentTarget) dispatch({ type: 'selected', id })
}}
>
<div
onClick={() => {
dispatch({ type: 'selected', id })
if (isFolder) dispatch({ type: 'toggled', id })
}}
style={{
paddingLeft: (level - 1) * 16 + 4,
background: isSelected ? 'lightblue' : undefined,
cursor: 'pointer',
}}
>
{isFolder && <span aria-hidden="true">{isOpen ? '▾ ' : '▸ '}</span>}
{node.name}
</div>
{isOpen && (
<ul role="group" style={{ listStyle: 'none', padding: 0 }}>
{node.childIds.map(childId => (
<MemoTreeItem
key={childId}
id={childId}
state={state}
focusId={focusId}
level={level + 1}
dispatch={dispatch}
/>
))}
</ul>
)}
</li>
)
}
const MemoTreeItem = memo(TreeItem)
export default function App() {
const [state, dispatch] = useReducer(reducer, firstState)
const [name, setName] = useState('')
const treeRef = useRef<HTMLUListElement>(null)
const shown = visibleIds(state, 'root')
const focusId = state.selectedId ?? shown[0]
function moveTo(id: string) {
treeRef.current?.querySelector<HTMLElement>(`[data-id="${id}"]`)?.focus()
}
function handleKeyDown(e: KeyboardEvent<HTMLUListElement>) {
const node = state.nodes[focusId]
const isOpen = node.kind === 'folder' && state.open[focusId] === true
const at = shown.indexOf(focusId)
if (e.key === 'ArrowDown') {
if (at < shown.length - 1) moveTo(shown[at + 1])
} else if (e.key === 'ArrowUp') {
if (at > 0) moveTo(shown[at - 1])
} else if (e.key === 'Home') {
moveTo(shown[0])
} else if (e.key === 'End') {
moveTo(shown[shown.length - 1])
} else if (e.key === 'ArrowRight') {
if (node.kind === 'folder' && !isOpen) dispatch({ type: 'toggled', id: focusId })
else if (isOpen && node.childIds.length > 0) moveTo(node.childIds[0])
} else if (e.key === 'ArrowLeft') {
if (isOpen) dispatch({ type: 'toggled', id: focusId })
else if (node.parentId !== null && node.parentId !== 'root') moveTo(node.parentId)
} else if (e.key === 'Enter') {
if (node.kind === 'folder') dispatch({ type: 'toggled', id: focusId })
} else {
return
}
e.preventDefault()
}
function add(kind: 'folder' | 'file') {
if (name.trim() === '') return
let parentId = 'root'
if (state.selectedId !== null) {
const picked = state.nodes[state.selectedId]
parentId = picked.kind === 'folder' ? picked.id : (picked.parentId ?? 'root')
}
dispatch({ type: 'added', id: crypto.randomUUID(), parentId, name: name.trim(), kind })
setName('')
}
function rename() {
if (state.selectedId === null || name.trim() === '') return
dispatch({ type: 'renamed', id: state.selectedId, name: name.trim() })
setName('')
}
function remove() {
if (state.selectedId !== null) dispatch({ type: 'deleted', id: state.selectedId })
}
return (
<div>
<style>{`
[role="treeitem"] { outline: none; }
[role="treeitem"]:focus-visible > div { outline: 2px solid blue; outline-offset: -2px; }
`}</style>
<input aria-label="Name" value={name} onChange={e => setName(e.target.value)} />
<button onClick={() => add('file')}>New file</button>
<button onClick={() => add('folder')}>New folder</button>
<button onClick={rename}>Rename</button>
<button onClick={remove}>Delete</button>
<ul
role="tree"
aria-label="Files"
ref={treeRef}
onKeyDown={handleKeyDown}
style={{ listStyle: 'none', padding: 0, width: 260 }}
>
{state.nodes.root.childIds.map(id => (
<MemoTreeItem
key={id}
id={id}
state={state}
focusId={focusId}
level={1}
dispatch={dispatch}
/>
))}
</ul>
</div>
)
}
Run it. Click src. Then use the arrow keys, Home, End and Enter. Then press Tab, and the focus leaves the tree.
We ran it in headless Chromium, Firefox and WebKit, with a real keyboard. Headless means the browser runs with no window, under a program’s control. All three gave the same results:
- Tab from the “Delete” button went to
src, the first item, because nothing was picked. It also pickedsrc: it hadaria-selected="true". One more Tab left the tree and the playground. - The Right arrow on
srcopened it, and the focus stayed. The Right arrow again moved tocomponents. - The Down arrow went to
App.tsx, thenpackage.json. Onpackage.json, the last item, it did nothing. - The Up arrow went back to
App.tsx. The Left arrow there went to its parent,src. The Left arrow again closedsrc. - Home went to
src, and End topackage.json. - A click on the name
App.tsxput the focus on itstreeitem. - After each move, exactly one item had
tabindex="0": the one with the focus. - The focus ring was around the name only, 24 pixels tall, even on the open
srcfolder. That<li>was 72 pixels tall, with its children inside.
Here is how the code does it.
focusId is the item that gets tabIndex={0}: the picked one, or the first one you can see.
handleKeyDown sits on the tree, not on each item. Key events bubble up (Part 6), so a key on any item reaches it. It finds the focused node and its place in shown, then follows the table above. Any other key reaches return before e.preventDefault(), so Tab still works.
moveTo calls focus() on the item, as Part 39 did. It finds the item by its data-id. The item is always on the page already, because the keys only move to items you can see.
onFocus on each <li> picks the item that got the focus. So Tab, the arrow keys and a click all pick it the same way. Focus events bubble up in React, so the src item also hears when App.tsx inside it gets the focus. The check e.target === e.currentTarget means “only if the focus landed on me”. We took the check out, opened src and focused App.tsx. Then src stayed picked, not App.tsx.
The focus ring. A browser draws the ring around the whole focused element. For an open folder, the <li> holds all its children, so the ring went around all of them. The <style> tag turns that ring off, and draws one around the name’s <div> instead. It uses :focus-visible, as in Part 47, so a mouse click shows no ring. We checked: after a click on App.tsx, there was no ring.
The click goes on the <div> with the name, not on the <li>. The <li> of src holds the <li> of App.tsx, so a click on App.tsx bubbles up to both. We moved the click handler to the <li> to check. Then one click on App.tsx picked src and closed it. On the <div>, that can’t happen. The click still focuses the <li>. The browser gives the focus to the nearest element around the click that can take it.
TreeItem is wrapped in memo. That needs its own section.
Is it fast enough?
Type one letter in the “Name” box. name is state in App, so App renders again. Without memo, every TreeItem would render again too, though nothing in the tree changed.
We counted TreeItem calls with src open, so 4 items were on the page. Without memo, one key press in the box called TreeItem 8 times: 4 items, twice each because of Strict Mode. With memo, it was 0. memo compares each prop with the last one (Part 20), and all five props were the same.
Inside TreeItem, the children are MemoTreeItem too, so every level can skip. And TreeItem gets dispatch, which never changes (Part 15). We tried adding an onPick={() => {}} prop instead. That is a new function on every render, so one key press called TreeItem 8 times again, memo or not.
A tree action is different. One click on package.json called TreeItem 8 times, with or without memo. Every item gets state, and each action makes a new state. To skip more, each item would need props that change only when that item changes. But a recursive item also renders its children, so it needs their data too. That takes a different shape, such as one flat list of rows. For a few hundred items, you don’t need to. For many thousands, it helps more to put only the visible rows on the page (Part 35).
What an interviewer looks for
Interviewers watch for these things:
- Questions first. You asked about names, order and saving before you wrote code.
- A data shape you can defend. You can say why flat, and what it costs.
- A base case. You can point at it.
- State in the right place. Open folders and the picked item live in one place.
- Updates that don’t mutate. New objects for what changed, the same objects for the rest.
- Real keys. Ids, not the index.
- A tree a keyboard can use. Arrow keys, one Tab stop, and the right roles.
Then come follow-up questions. You should be able to say how, without coding them all.
Folders first, then by name
Don’t sort the state. Sort when you render: copy childIds, then sort the copy. Put folders first, then compare names with localeCompare, which knows the order of letters in many languages. The arrow keys must use the same order, so visibleIds sorts too. Practice 1 does it.
Search and filter
Show only the nodes whose name has the search text, plus every folder above them, open. Without those folders, a deep match would have nowhere to show. With parentId, you find them by walking up from each match. Keep the search text in state, and work out the matches while rendering (Part 9).
Drag a node to another folder
Moving is three changes in a flat map. Take the id out of the old parent’s childIds, add it to the new parent’s, and change the node’s parentId. That is one new moved action. There is a trap. A folder must never move into itself, or into a folder inside it. idsUnder(nodes, id).includes(targetId) finds that case. Part 54 builds drag and drop.
Save it
A flat map turns into text with JSON.stringify, and back with JSON.parse. Save it to localStorage, or send it to a server. In the playground, localStorage is kept in memory and is emptied on every Run.
A tree with 100,000 nodes
Two answers. First, load a folder’s children only when it opens. Second, put only the rows that fit on the screen on the page, as in Part 35. visibleIds already gives the list of rows it needs. Then some items of a folder are not on the page. That is the APG’s case for aria-level, aria-setsize and aria-posinset on each item.
Common mistakes
Changing the state in place
This adds a file by changing the map that is already in state:
import { useState } from 'react'
type FileNode = {
id: string
name: string
kind: 'folder' | 'file'
parentId: string | null
childIds: string[]
}
const firstNodes: Record<string, FileNode> = {
root: { id: 'root', name: 'project', kind: 'folder', parentId: null, childIds: ['src'] },
src: { id: 'src', name: 'src', kind: 'folder', parentId: 'root', childIds: [] },
}
export default function App() {
const [nodes, setNodes] = useState(firstNodes)
const [clicks, setClicks] = useState(0)
function addFile() {
const id = 'f' + Object.keys(nodes).length
nodes[id] = { id, name: 'new.txt', kind: 'file', parentId: 'src', childIds: [] }
nodes.src.childIds.push(id)
setNodes(nodes)
}
return (
<div>
<button onClick={addFile}>New file</button>
<button onClick={() => setClicks(clicks + 1)}>Something else ({clicks})</button>
<p>Files in src: {nodes.src.childIds.length}</p>
</div>
)
}
Run it and click “New file”. The page still says Files in src: 0. Now click “Something else”. Suddenly it says 1.
setNodes(nodes) passed the same object that is already in state. React compares the old and new state with Object.is. They are the same object, so React ignored the update. The file was in the map, but nothing showed it. “Something else” changed other state, so App rendered, and read the changed map.
Moving the change into an updater function, like setNodes(n => ...), doesn’t fix it. We tried n.src.childIds.push(id) inside the updater, and returned a new map. One click gave Files in src: 2, because Strict Mode ran the updater twice, and both runs pushed into the same array. With Strict Mode off, it gave 1. But both times, firstNodes, the map the app started with, had changed too.
The fix is Step 3’s add: a new map, a new parent, and a new childIds array.
The index as the key
Here each folder keeps its own open state, as in Step 2. The list uses the index as the key:
import { useState } from 'react'
function Folder({ name }: { name: string }) {
const [open, setOpen] = useState(false)
return (
<li>
<button onClick={() => setOpen(!open)}>
{open ? '▾' : '▸'} {name}
</button>
{open && <p>The files in {name}</p>}
</li>
)
}
export default function App() {
const [folders, setFolders] = useState(['docs', 'music', 'photos'])
return (
<div>
<button onClick={() => setFolders(folders.slice(1))}>Delete the first folder</button>
<ul>
{folders.map((name, index) => (
<Folder key={index} name={name} />
))}
</ul>
</div>
)
}
Run it. Open “docs”, then delete the first folder. “docs” is gone, but now “music” is open. You never opened it.
The open state belonged to key 0. After the delete, “music” is at index 0, so it got key 0 and the open state with it. This is the bug from Part 8. The fix is a key from the data. Here key={name} works, because the names are different. In the file explorer, use the id.
A recursion that never stops
Each recursive function in this part stops at a node with no children. But what if a bug, or a bad move, puts a folder inside itself?
type Nodes = Record<string, { childIds: string[] }>
const nodes: Nodes = {
root: { childIds: ['a'] },
a: { childIds: ['a'] },
}
function idsUnder(nodes: Nodes, id: string): string[] {
return [id, ...nodes[id].childIds.flatMap(childId => idsUnder(nodes, childId))]
}
idsUnder(nodes, 'root')
The base case is never reached. idsUnder calls itself until JavaScript gives up. We ran it, and it threw RangeError: Maximum call stack size exceeded.
A recursive component can be worse. We put components inside itself in two of our apps, with src and components open at the start. The Step 5 app threw the same RangeError at once, because visibleIds met the loop first. The Step 3 app has no visibleIds. React never finished rendering. In our test, after 20 seconds, we stopped it. That is why this example has no Run button.
The fix is to never let a loop into the data. The “move” action must refuse it, as the follow-up above says. Data from a server can be checked when it arrives.
Practice
Press Edit on the app in Step 5 and try these.
- Show folders before files, and each group in name order. Make the arrow keys follow the same order.
- Under the tree, show how many files are in the picked folder. Count the files in the folders inside it too.
- Add type-ahead. When you press a letter, the focus moves to the next visible item whose name starts with it. Handle one letter at a time, as Part 47’s listbox did.
- Add
console.log('reducer', action.type)as the first line ofreducer. Then clickpackage.jsononce. How many lines does the Console show?
Answers
1. Write a function that sorts a copy of the ids:
type FileNode = { id: string; name: string; kind: 'folder' | 'file' }
function sorted(nodes: Record<string, FileNode>, ids: string[]): string[] {
return [...ids].sort((a, b) => {
const x = nodes[a]
const y = nodes[b]
if (x.kind !== y.kind) return x.kind === 'folder' ? -1 : 1
return x.name.localeCompare(y.name)
})
}
Use sorted(state.nodes, ...) around the ids in four places. Those are visibleIds, the map in TreeItem, the map in App, and the Right arrow’s first child. If you sort in only some places, the arrow keys jump around. To see the order change, open src and add a folder named “a”. It shows first, above components.
2. Use idsUnder, and keep only the files: idsUnder(state.nodes, id).filter(x => state.nodes[x].kind === 'file').length. For src in the starting tree, it gives 2: Button.tsx and App.tsx. Work it out while rendering, and don’t store it in state.
3. Write a function that looks for the next match after the focused item, going round to the top:
type FileNode = { name: string }
function nextByLetter(nodes: Record<string, FileNode>, shown: string[], from: string, letter: string) {
const at = shown.indexOf(from)
const order = [...shown.slice(at + 1), ...shown.slice(0, at + 1)]
return order.find(id => nodes[id].name.toLowerCase().startsWith(letter.toLowerCase()))
}
Then add one more else if to handleKeyDown, before the last else. For a key with e.key.length === 1 and no Ctrl, Meta or Alt, call nextByLetter(state.nodes, shown, focusId, e.key). If it finds an id, call moveTo with it. If not, return, so the key is left alone. With src open and the focus on src, “p” goes to package.json, “a” to App.tsx, and “c” to components. “x” does nothing.
4. Two lines, both reducer selected. A click on a file dispatches only selected. Strict Mode calls the reducer twice for each action, in development only. A click on a folder dispatches selected and toggled, so it shows four lines.
Interview questions
Try to answer each one out loud before you open the answer.
How do you render a tree whose depth you don’t know?
With a recursive component: a component that renders itself for each child. TreeItem shows one node. If the node is an open folder, it renders a TreeItem for each child. The base case is a file or a closed folder, which renders no TreeItem. A strong answer names the base case without being asked. It also gives each child a stable key, such as its id.
Would you keep the tree nested or flat in state? Why?
Flat: a map from id to node. Each node lists its children’s ids and its parent’s id. Changing one node then means copying one node and the map. In a nested tree, you copy every folder above the node too. Finding a node is nodes[id], not a walk. React’s docs say “Avoid deeply nested state” and show a flat shape.
A strong answer also says what flat costs. parentId and childIds say the same thing twice, so one reducer must keep them right, with tests. React’s own example avoids that: it stores only childIds, and passes each item its parentId as a prop. And deleting a folder must also delete everything inside it, or those nodes stay in the map.
Where does the “open” state of each folder live?
It can start in each folder’s own component. That breaks when something outside the folder must open or close it. Adding a file should open its folder. The Left arrow closes a folder. A search opens the folders above a match. So the state moves up to the component that holds the tree, as one object from id to open. A strong answer puts it next to the nodes in one reducer. Then “add a file and open its folder” is one action.
How do you delete a folder and everything in it?
Collect the ids with a recursive function: the folder’s id, plus the ids under each child. Build a new map without those ids. Then give the parent a new childIds without the folder’s id. Never change the old map. A strong answer also cleans up what points at deleted nodes. It drops their open flags. If the picked node was deleted, it picks the parent.
Why should the reducer not make the new id?
A reducer must be pure: the same state and action must give the same result. A random id breaks that. In development, Strict Mode calls the reducer twice, and the two calls would make different ids. Even a counter skips numbers. So make the id in the event handler, and put it in the action. Then a test can pass any id it likes.
What ARIA roles and keys does a tree view need?
Role tree on the outside, with a name, and treeitem on each node. Each folder’s children go in a group. Folders get aria-expanded, and files don’t. Use aria-selected when items can be picked. Up and Down move between the items you can see. Right opens a folder or goes into it. Left closes it or goes to the parent. Home and End go to the first and last item. Only one item is a Tab stop: a roving tabindex.
A strong answer adds that the browser works out aria-level from the nested groups. You only set it when some nodes are not on the page.
How would you handle a tree with 100,000 files?
Don’t put 100,000 rows on the page. Load a folder’s children when it opens, and render only the rows that fit on the screen. A flat list of visible ids makes the second part easy. With rows missing from the page, set aria-level, aria-setsize and aria-posinset on each item. A strong answer also measures before and after.
Sources
- Tree View Pattern, W3C APG: the roles,
aria-expandedonly on parent nodes, whenaria-levelis needed, the keys, “selection follows focus”, and the*key. - File Directory Treeview Example Using Computed Properties, W3C APG: one
tabindex="0", the Down arrow on the last node, and what thegroupdoes for names and levels. - Choosing the State Structure, react.dev: “avoid deeply nested state”, the flat
childIdsshape, and removing deleted items and their children. - Updating Objects in State, react.dev: copying each level of nested state.
- useState, react.dev: React ignores an update when the next state is the same object, by
Object.is. - Extracting State Logic into a Reducer and useReducer, react.dev: pure reducers, and Strict Mode calling them twice.
- Keeping Components Pure, react.dev: local mutation.
- Rendering Lists, react.dev: where keys come from, including
crypto.randomUUID(). - memo, react.dev: how
memocompares props. - Crypto: randomUUID() and Secure contexts, MDN:
randomUUIDonly in secure contexts. - The object counts, the reducer checks, the ids, the render counts, the loop and the mistakes come from running React 19.3.0 for this post. The keys, focus and accessibility tree come from headless Chromium, Firefox and WebKit.
- This part follows the File Explorer (Recursive) kata in react-katas. The kata shows the recursive component. This part adds the flat map, the actions, the reducer and the keyboard.