Building a treeview component on the web is complex. There is no semantic structure and tree traversal is hard. In this three-part series, I'll explain a simple treeview I have been working on that focuses on using the platform rather than rebuilding everything from scratch.
In part 1 (this post), we will
- Render our data hierarchically
- Make items collapsible
- And make items selectable.
Everything that is required to make a mouse interactive treeview.
In part 2, we will add keyboard navigation and ARIA attributes to make sure this component is accessible. Then in part 3, we will add some animations to bring our example to life.
Here is a sneak peek of what we will be creating in part 1.
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Rendering hierarchically
The treeview is a way of view'ing the data of a tree hierarchically. In plain English, that means each layer of the tree is nested underneath its related parent. This makes understanding the relationships in large amounts of data more intuitive.
To render hierarchically we need data, and before we create data we need a type.
Data type
Each node in a treeview can have n number of descendants, which means our data structure will be a n-ary tree. We can specify this in typescript with a recursive type:
We define children
to be an array reflecting the n
nature of descendants.
Component API
The API has two components Root
and Node
,
where nodes
is of type TreeNodeType
.
Root
The Root component holds an initial ul
wrapping the root nodes.
clsx
is an alternative toclassNames
and it is useful for deriving a list of classes from stateoverflow-auto
ensures that when this component has static widths it overflows nicely
Node
The Node
component contains the recursive rendering of tree nodes.
Two things are going on here:
-
We render the current
Node
's name -
And we recursively render descendant
Node
s within aul
The hierarchical structure comes from the padding(
pl-4
) on ourul
.
Then for the styles we use
flex flex-col
to remove default "bullet" on ourli
text-ellipsis whitespace-nowrap overflow-hidden
to nicely handle text overflow with an ellipsis...cursor-pointer select-none
to indicate interactivity and prevent text selectiononClick
And with that, we have hierarchical rendering.
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Why do we use value
and onChange
?
I am modeling our treeview as a controlled component, and these two props are the common way of accomplishing that.
The value
here is the selected tree item.
This can get confusing when use cases often include renameable items,
but at the end of the day value
is the selected tree item, and onChange
is how you select.
This node, that node
A node is an item in a treeview. Nodes can have children, and if they do they can be in an open or closed state. Nodes at the base of the tree are referred to as 'root nodes'. Nodes without children are referred to as 'leaf nodes.'
More info on treeview terms can be found here in the ARIA spec.
Why do we use ul
and li
?
There are no semantic treeview elements like <tree>
or <treeitem>
.
I am using ul
and li
, but this could very well be entirely div
s.
We will make this treeview accessible with ARIA attributes in the next part of this series. One problem at a time!
Collapsible content
On top of being hierarchical, treeviews are collapsible.
We need a way of storing the open state of each node.
A parent node can collapse and in react this would cause our Node
to be unmounted.
If we store this state locally then it will be lost when during this unmount.
The solution is to lift our state to a common ancestor component.
We can do this by putting the open state in context and providing it from our Root
.
Creating a context for storing open state
-
We create a type to describe our state.
TreeViewState
is aMap
of ids (string
) to open state (boolean
) of a particularNode
. -
We create an
enum
of "Action Types"This is a nice way of not using magic string when discriminating actions in our reducer.
-
We define the a union type of our "Actions"
-
We make a reducer that updates our state based on the two actions
-
We create the type for the context that includes the value and dispatch of our reducer
And create the context itself.
Updating Root
to provide state
At this point, we are almost there. We just need to provide our context in the Root
so that it is available to our Node
s.
Updating Node
to consume open state
And then we can update the Node
to consume the context, and conditionally render its children
-
We consume the context that our
Root
is providing -
Create an
onClick
handler for toggling the open state of theNode
-
and conditionally render based on whether the parent is in the open state
And with that, we can collapse each Node
:
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Open state indicator
Our Node
is collapsible, but it's hard to tell the difference between root nodes, leaf nodes, and nodes in between.
Let's add an arrow indicating the open state of our Node
.
- We set
origin-center
so that the rotation revolves around the center instead of the top left - We set
stroke="currentColor"
so that our icon will inherit its color from the surrounding text - And we toggle
rotate-$$$
classes based on whether or not theNode
is open
Next, we can add the Arrow
to our Node
component.
-
We move the open state out of JSX, since it is used in multiple places.
-
We conditionally render the Arrow icon based on whether it has children.
Since 0, null, or undefined are all falsy this optional chaining allows us to check if there are one or more children.
-
We add some styles (
flex items-center space-x-2
) to place content into a row and add spacing. -
And we wrap
{name}
in a span so that overflow has an ellipses -
We use
shrink-0
to prevent our icon from shrinking in the case of text overflow.
Arrows are now rendering and it is much easier to differentiate which Node
s are collapsible.
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Selection
The only thing left to get our treeview mouse interactive is selection.
Let's update our Root
component to be controlled.
We can then add the selectedId value and selectId callback to the context type.
And update our Node
to indicate which Node
is selected.
-
We update our useContext to destructure selectId and selectedId
-
We toggle our background class based on selection.
Something that tripped me up a lot when first using tailwind was doing something like:
I somehow thought that all tailwind classes have the same precedence, but this isn't possible. It goes against the very cascading nature of CSS.
Even if
bg-slate-200
takes precedence now I don't want to depend on the order Tailwind uses to define classes.The solution to this problem is toggle tailwind classes that set overlapping CSS properties.
-
And we call
selectId
in ouronClick
There you have it, a mouse interactive treeview component:
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
This three-part series has been a six-month journey. It started with me creating a treeview where all state and traversals were stored in react. And it ended with me unraveling the complexity to depend on the DOM APIs.
Part of that journey was discovering how Radix UI creates their roving tabindex. I wrote a post creating a reusable react roving tabindex that details how their roving tabindex works.
I recommend reading that post before you jump into part 2.
Regardless thanks for reading and if you have any feedback.