React Tree Viewer: Event Flow, State Design, and Reusability


If you’ve ever tried to build a Tree Viewer in React, you know it’s like trying to herd cats with children. It starts innocent enough: a list of folders, maybe some nested files. Then someone says, “Can you make it expandable?“ Then: “We need to drag and drop,“ and finally: “Let’s reuse this in five different places.“ Oh. Oh no.
This post is about how to architect a Tree Viewer in React so it doesn’t become an unmaintainable nightmare. We’ll walk through:
Recursive component structure
Local vs global state decisions
Handling events that bubble from leaf to root
How to keep it reusable, predictable, and production-ready
Let’s go.
Why a Tree UI is Tricky in React
Trees are inherently recursive, but React components don’t magically recurse. You have to do the recursion yourself — rendering the same component inside itself.
That leads to questions like:
Where does state live?
How do you handle click events for a deeply nested node?
How do you make sure each node can be controlled independently and communicate upwards?
Step 1: Recursive Component Structure
Here’s the minimal structure for a Tree Viewer:
type TreeNode = {
id: string;
name: string;
children?: TreeNode[];
};
const TreeItem: React.FC<{ node: TreeNode }> = ({ node }) => {
const [expanded, setExpanded] = useState(false);
const toggle = () => setExpanded(!expanded);
return (
<div className="ml-4">
<div onClick={toggle}>
{node.name} {node.children && (expanded ? "[-]" : "[+]")}
</div>
{expanded && node.children?.map((child) => (
<TreeItem key={child.id} node={child} />
))}
</div>
);
};
What this does well:
Elegant recursion
Independent
expanded
state per nodeWorks out of the box for small trees
What it doesn’t do well:
No event bubbling
State isn’t liftable
Can’t control nodes from outside
Not testable or reusable in more advanced scenarios
Step 2: Designing Event Flow Like a Pro
We want every node to be able to:
Tell it’s parent that it was clicked
Receive props to control its state externally, if needed
Be fully reusable without knowing about the whole tree
So we’ll lift state and events upward using a map structure:
type TreeState = Record<string, boolean>; // id -> expanded state
We’ll keep this in a parent component, and pass down props;
const TreeItem: React.FC<{
node: TreeNode;
expandedMap: TreeState;
onToggle: (id: string) => void;
}> = ({ node, expandedMap, onToggle }) => {
const isExpanded = expandedMap[node.id] || false;
return (
<div className="ml-4">
<div onClick={() => onToggle(node.id)}>
{node.name} {node.children && (isExpanded ? "[-]" : "[+]")}
</div>
{isExpanded && node.children?.map((child) => (
<TreeItem
key={child.id}
node={child}
expandedMap={expandedMap}
onToggle={onToggle}
/>
))}
</div>
);
};
And then control all the logic from the top:
const TreeViewer = ({ tree }: { tree: TreeNode[] }) => {
const [expandedMap, setExpandedMap] = useState<TreeState>({});
const handleToggle = (id: string) => {
setExpandedMap((prev) => ({
...prev,
[id]: !prev[id],
}));
};
return tree.map((node) => (
<TreeItem
key={node.id}
node={node}
expandedMap={expandedMap}
onToggle={handleToggle}
/>
));
};
Result?
One source of truth
All state is centralized and testable
Every node is dumb and reusable
Step 3: Adding Reusability and Extensibility
To make this Tree Viewer production-level, we need:
Custom renderers (
renderLabel
,renderIcon
, etc.)Optional behaviors (expand on click, lazy load children, etc.)
Full control for consumers
Here’s how we can make each TreeItem
more reusable:
interface TreeItemProps {
node: TreeNode;
expandedMap: TreeState;
onToggle: (id: string) => void;
renderLabel?: (node: TreeNode, expanded: boolean) => React.ReactNode;
}
const TreeItem: React.FC<TreeItemProps> = ({
node,
expandedMap,
onToggle,
renderLabel,
}) => {
const isExpanded = expandedMap[node.id] || false;
return (
<div className="ml-4">
<div onClick={() => onToggle(node.id)}>
{renderLabel ? renderLabel(node, isExpanded) : node.name}
</div>
{isExpanded && node.children?.map((child) => (
<TreeItem
key={child.id}
node={child}
expandedMap={expandedMap}
onToggle={onToggle}
renderLabel={renderLabel}
/>
))}
</div>
);
};
Now the consumer of TreeViewer
can fully customize the label rendering:
<TreeViewer
tree={myData}
renderLabel={(node, expanded) => (
<span>
{expanded ? "📂" : "📁"} {node.name}
</span>
)}
/>
Clean, testable, reusable.
State Design Lessons from Tree UIs
Building a Tree Viewer teaches you:
Lift state if you need control, don’t fear prop-drilling
Push logic upward, make components dumb
Recursive components ≠complex code, if you decouple rendering from logic
Extensibility requires of control: let the caller customize, don’t assume
Final Thoughts
If your Tree Viewer isn’t testable, predictable, or reusable, it’s not ready for production—no matter how cute the icons are. Architecting it properly gives you control, consistency, and that sweet “I did this right“ feeling.
And remember: recursive problems aren’t hard—just misunderstood.
Subscribe to my newsletter
Read articles from Faiaz Khan directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Faiaz Khan
Faiaz Khan
Hey! I'm Faiaz — a frontend developer who loves writing clean, efficient, and readable code (and sometimes slightly chaotic code, but only when debugging). This blog is my little corner of the internet where I share what I learn about React, JavaScript, modern web tools, and building better user experiences. When I'm not coding, I'm probably refactoring my to-do list or explaining closures to my cat. Thanks for stopping by — hope you find something useful or mildly entertaining here.