Build a file explorer: a nested folder hierarchy where clicking a folder expands or collapses its contents. The data is a tree — folders contain children, which may themselves be folders — and the natural way to render a tree is recursion: a component that renders a node and calls itself for each child.
type FileNode = { name: string; children?: FileNode[] };
// A self-contained component. No props.
function App(): JSX.Element;
A node with a children array is a folder; one without is a file.
▸ src click ▸ src ▾ src
▸ public → ▸ components
📄 package.json 📄 index.ts
▸ public
📄 package.json
expanding "src" then "components" reveals Button.tsx and Modal.tsx,
each one level deeper than its parent.
Set; a folder is open iff its path is in the set.children present → folder (expandable); absent → leaf file.A tree of folders is rendered by a function that renders one node and calls itself for each child. The only state is which folders are open — a set of paths — and a folder shows its children exactly when its path is in that set.
The data nests to arbitrary depth: a folder holds files and folders, which hold more of the same. You can't write a fixed number of loops for an unknown depth — but you don't need to. Recursion handles any depth with one rule: "to render a node, render its label, and if it's an open folder, render each of its children the same way." The expand/collapse behaviour is just a lookup: is this folder's path in the open set?
Two parts. Rendering is a recursive TreeNode: a file renders as a leaf; a folder renders a clickable label plus, when open, a nested list of TreeNodes for its children — the recursive step. State is a single Set<string> of open folder paths, owned at the top. Each node receives its path (its parent's path plus its name), so paths are unique even when two folders share a name. Clicking a folder toggles its path in the set.
A common first attempt gives each folder its own useState for open/closed:
function TreeNode({ node }) {
const [open, setOpen] = useState(false); // state scattered per node
// …
}
This works visually, but the open/closed state is now scattered across dozens of component instances. You can't answer "which folders are open?", you can't expand-all or persist the view, and remounting a node (e.g. after the data refreshes) silently resets it. Lifting the open paths into one Set at the top makes the whole tree's state inspectable and controllable from a single place.
import { useState } from 'react';
import './styles.css';
type FileNode = { name: string; children?: FileNode[] };
const TREE: FileNode = {
name: 'root',
children: [
{
name: 'src',
children: [
{
name: 'components',
children: [{ name: 'Button.tsx' }, { name: 'Modal.tsx' }],
},
{ name: 'index.ts' },
],
},
{ name: 'public', children: [{ name: 'logo.svg' }, { name: 'favicon.ico' }] },
{ name: 'package.json' },
],
};
function TreeNode({
node,
path,
open,
toggle,
}: {
node: FileNode;
path: string;
open: Set<string>;
toggle: (path: string) => void;
}) {
const isFolder = Array.isArray(node.children);
if (!isFolder) {
return <li className="file">📄 {node.name}</li>;
}
const isOpen = open.has(path);
return (
<li className="folder">
<button className="folder-label" onClick={() => toggle(path)}>
<span className="caret">{isOpen ? '▾' : '▸'}</span> 📁 {node.name}
</button>
{isOpen && (
<ul>
{node.children!.map((child) => (
<TreeNode
key={child.name}
node={child}
path={`${path}/${child.name}`}
open={open}
toggle={toggle}
/>
))}
</ul>
)}
</li>
);
}
export default function App() {
const [open, setOpen] = useState<Set<string>>(new Set());
function toggle(path: string) {
setOpen((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}
return (
<main className="container">
<h1>File Explorer</h1>
<ul className="tree">
{TREE.children!.map((node) => (
<TreeNode
key={node.name}
node={node}
path={node.name}
open={open}
toggle={toggle}
/>
))}
</ul>
</main>
);
}
TreeNode is the recursion: a file returns a leaf <li>; a folder returns its label and, when open.has(path), a nested <ul> that maps each child back through TreeNode — the self-call. App owns the single Set of open paths and the toggle, which copies the set before mutating so React sees a new reference and re-renders. Each child's path is built from its parent's, so src/components and public/components would never collide.
Open set starts empty.
App maps the top-level children, each with path = node.name. src is a folder; open.has('src') is false, so it shows ▸ 📁 src and no children. package.json is a file leaf.toggle('src') adds 'src' to a fresh set. Re-render: open.has('src') is now true → caret flips to ▾ and a nested <ul> renders TreeNodes for components (path = 'src/components') and index.ts (path = 'src/index.ts').toggle('src/components'). That node re-renders open, recursing once more into Button.tsx and Modal.tsx leaves — two levels deep, same code.toggle('src') removes it; the whole subtree unmounts in one step. The inner open paths stay in the set but render nothing while their ancestor is closed.useState. Scatters state; no expand-all, no inspection, resets on remount. Fix: one Set at the top.prev.add(path) keeps the same reference → no re-render. Fix: new Set(prev) then mutate.components collide. Fix: build path from the parent's path.node.children && … to detect folders. An empty folder (children: []) is falsy-ish under truthiness checks of .length. Fix: Array.isArray(node.children).role="tree", treeitem, aria-expanded — File Explorer II.Set, "expand all" is just adding every folder path at once.An array of paths replaces the Set, and a reducer owns the toggle transition. The recursive component still receives the complete model, so every depth renders from the same centralized state.
import { useReducer } from 'react';
import './styles.css';
type FileNode = { name: string; children?: FileNode[] };
const TREE: FileNode = {
name: 'root',
children: [
{ name: 'src', children: [
{ name: 'components', children: [
{ name: 'Button.tsx' }, { name: 'Modal.tsx' },
] },
{ name: 'index.ts' },
] },
{ name: 'public', children: [
{ name: 'logo.svg' }, { name: 'favicon.ico' },
] },
{ name: 'package.json' },
],
};
function openPathsReducer(paths: string[], path: string) {
return paths.includes(path)
? paths.filter((candidate) => candidate !== path)
: [...paths, path];
}
function TreeNode({ node, path, openPaths, toggle }: {
node: FileNode;
path: string;
openPaths: string[];
toggle: (path: string) => void;
}) {
if (!Array.isArray(node.children)) {
return <li className="file">📄 {node.name}</li>;
}
const isOpen = openPaths.includes(path);
return (
<li className="folder">
<button className="folder-label" onClick={() => toggle(path)}>
<span className="caret">{isOpen ? '▾' : '▸'}</span> 📁 {node.name}
</button>
{isOpen && (
<ul>
{node.children.map((child) => (
<TreeNode key={child.name} node={child}
path={`${path}/${child.name}`}
openPaths={openPaths} toggle={toggle} />
))}
</ul>
)}
</li>
);
}
export default function App() {
const [openPaths, toggle] = useReducer(openPathsReducer, []);
return (
<main className="container">
<h1>File Explorer</h1>
<ul className="tree">
{TREE.children!.map((node) => (
<TreeNode key={node.name} node={node} path={node.name}
openPaths={openPaths} toggle={toggle} />
))}
</ul>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a file explorer: a nested folder hierarchy where clicking a folder expands or collapses its contents. The data is a tree — folders contain children, which may themselves be folders — and the natural way to render a tree is recursion: a component that renders a node and calls itself for each child.
type FileNode = { name: string; children?: FileNode[] };
// A self-contained component. No props.
function App(): JSX.Element;
A node with a children array is a folder; one without is a file.
▸ src click ▸ src ▾ src
▸ public → ▸ components
📄 package.json 📄 index.ts
▸ public
📄 package.json
expanding "src" then "components" reveals Button.tsx and Modal.tsx,
each one level deeper than its parent.
Set; a folder is open iff its path is in the set.children present → folder (expandable); absent → leaf file.A tree of folders is rendered by a function that renders one node and calls itself for each child. The only state is which folders are open — a set of paths — and a folder shows its children exactly when its path is in that set.
The data nests to arbitrary depth: a folder holds files and folders, which hold more of the same. You can't write a fixed number of loops for an unknown depth — but you don't need to. Recursion handles any depth with one rule: "to render a node, render its label, and if it's an open folder, render each of its children the same way." The expand/collapse behaviour is just a lookup: is this folder's path in the open set?
Two parts. Rendering is a recursive TreeNode: a file renders as a leaf; a folder renders a clickable label plus, when open, a nested list of TreeNodes for its children — the recursive step. State is a single Set<string> of open folder paths, owned at the top. Each node receives its path (its parent's path plus its name), so paths are unique even when two folders share a name. Clicking a folder toggles its path in the set.
A common first attempt gives each folder its own useState for open/closed:
function TreeNode({ node }) {
const [open, setOpen] = useState(false); // state scattered per node
// …
}
This works visually, but the open/closed state is now scattered across dozens of component instances. You can't answer "which folders are open?", you can't expand-all or persist the view, and remounting a node (e.g. after the data refreshes) silently resets it. Lifting the open paths into one Set at the top makes the whole tree's state inspectable and controllable from a single place.
import { useState } from 'react';
import './styles.css';
type FileNode = { name: string; children?: FileNode[] };
const TREE: FileNode = {
name: 'root',
children: [
{
name: 'src',
children: [
{
name: 'components',
children: [{ name: 'Button.tsx' }, { name: 'Modal.tsx' }],
},
{ name: 'index.ts' },
],
},
{ name: 'public', children: [{ name: 'logo.svg' }, { name: 'favicon.ico' }] },
{ name: 'package.json' },
],
};
function TreeNode({
node,
path,
open,
toggle,
}: {
node: FileNode;
path: string;
open: Set<string>;
toggle: (path: string) => void;
}) {
const isFolder = Array.isArray(node.children);
if (!isFolder) {
return <li className="file">📄 {node.name}</li>;
}
const isOpen = open.has(path);
return (
<li className="folder">
<button className="folder-label" onClick={() => toggle(path)}>
<span className="caret">{isOpen ? '▾' : '▸'}</span> 📁 {node.name}
</button>
{isOpen && (
<ul>
{node.children!.map((child) => (
<TreeNode
key={child.name}
node={child}
path={`${path}/${child.name}`}
open={open}
toggle={toggle}
/>
))}
</ul>
)}
</li>
);
}
export default function App() {
const [open, setOpen] = useState<Set<string>>(new Set());
function toggle(path: string) {
setOpen((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}
return (
<main className="container">
<h1>File Explorer</h1>
<ul className="tree">
{TREE.children!.map((node) => (
<TreeNode
key={node.name}
node={node}
path={node.name}
open={open}
toggle={toggle}
/>
))}
</ul>
</main>
);
}
TreeNode is the recursion: a file returns a leaf <li>; a folder returns its label and, when open.has(path), a nested <ul> that maps each child back through TreeNode — the self-call. App owns the single Set of open paths and the toggle, which copies the set before mutating so React sees a new reference and re-renders. Each child's path is built from its parent's, so src/components and public/components would never collide.
Open set starts empty.
App maps the top-level children, each with path = node.name. src is a folder; open.has('src') is false, so it shows ▸ 📁 src and no children. package.json is a file leaf.toggle('src') adds 'src' to a fresh set. Re-render: open.has('src') is now true → caret flips to ▾ and a nested <ul> renders TreeNodes for components (path = 'src/components') and index.ts (path = 'src/index.ts').toggle('src/components'). That node re-renders open, recursing once more into Button.tsx and Modal.tsx leaves — two levels deep, same code.toggle('src') removes it; the whole subtree unmounts in one step. The inner open paths stay in the set but render nothing while their ancestor is closed.useState. Scatters state; no expand-all, no inspection, resets on remount. Fix: one Set at the top.prev.add(path) keeps the same reference → no re-render. Fix: new Set(prev) then mutate.components collide. Fix: build path from the parent's path.node.children && … to detect folders. An empty folder (children: []) is falsy-ish under truthiness checks of .length. Fix: Array.isArray(node.children).role="tree", treeitem, aria-expanded — File Explorer II.Set, "expand all" is just adding every folder path at once.An array of paths replaces the Set, and a reducer owns the toggle transition. The recursive component still receives the complete model, so every depth renders from the same centralized state.
import { useReducer } from 'react';
import './styles.css';
type FileNode = { name: string; children?: FileNode[] };
const TREE: FileNode = {
name: 'root',
children: [
{ name: 'src', children: [
{ name: 'components', children: [
{ name: 'Button.tsx' }, { name: 'Modal.tsx' },
] },
{ name: 'index.ts' },
] },
{ name: 'public', children: [
{ name: 'logo.svg' }, { name: 'favicon.ico' },
] },
{ name: 'package.json' },
],
};
function openPathsReducer(paths: string[], path: string) {
return paths.includes(path)
? paths.filter((candidate) => candidate !== path)
: [...paths, path];
}
function TreeNode({ node, path, openPaths, toggle }: {
node: FileNode;
path: string;
openPaths: string[];
toggle: (path: string) => void;
}) {
if (!Array.isArray(node.children)) {
return <li className="file">📄 {node.name}</li>;
}
const isOpen = openPaths.includes(path);
return (
<li className="folder">
<button className="folder-label" onClick={() => toggle(path)}>
<span className="caret">{isOpen ? '▾' : '▸'}</span> 📁 {node.name}
</button>
{isOpen && (
<ul>
{node.children.map((child) => (
<TreeNode key={child.name} node={child}
path={`${path}/${child.name}`}
openPaths={openPaths} toggle={toggle} />
))}
</ul>
)}
</li>
);
}
export default function App() {
const [openPaths, toggle] = useReducer(openPathsReducer, []);
return (
<main className="container">
<h1>File Explorer</h1>
<ul className="tree">
{TREE.children!.map((node) => (
<TreeNode key={node.name} node={node} path={node.name}
openPaths={openPaths} toggle={toggle} />
))}
</ul>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.