This post is the first in a series on creating a Treeview in React. Until the series is complete, I am not pushing out this post and the level of polish will not be as high as usual. You are welcome to read it if you like though.
I love feedback, especially on draft posts. if you have any!
The classic example of a treeview is the native file manager. You can select files, organize them into folders, etc.
If you have ever tried to build one on the web, you probably found that it is no small task. This post is the first in a series where I will be breaking down the treeview into:
- Intro / Mouse interactions (this post !)
- Keyboard navigation
- Accessibility
- Animations
if there is interest I might also cover
- Async Nodes
- Drag and Drop Moving / Sorting
- Performance for large data sets
- and Multi Selection
In this first part though my goal is to just
- Render our data hierarchically
- Make nodes collapsible
- And make them selectable
By the end of this post we will have a mouse interactive treeview:
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} > {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 it's related parent This makes large amounts of data much easier to understand because we can intuitively understand their relationships.
Before we think about this nested rendering, let's define some types and create a basic component structure.
Data types
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:
export type TreeNodeType = {
id: string
name: string
children?: TreeNodeType[]
}
children
is an array to reflect the unknown number of descendants.
Component API
The API has two components Root
and Node
,
const [selected, select] = useState<string | null>(null)
return (
<Treeview.Root value={selected} onChange={select}>
{nodes.map(node => (
<Treeview.Node node={node} key={node.id} />
))}
</Treeview.Root>
)
where nodes
is of type TreeNodeType
.
Root
The Root component holds an initial ul
wrapping the root nodes.
type RootProps = {
children: ReactNode | ReactNode[]
className?: string
}
export function Root({ children, className }: RootProps) {
return (
<ul className={clsx('flex flex-col overflow-auto', className)}>
{children}
</ul>
)
}
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 nodes.
type NodeProps = {
node: TreeNodeType
}
export function Node({ node }: NodeProps) {
return (
<li className="flex flex-col cursor-pointer select-none">
<div
className={
'font-mono font-medium rounded-sm px-1 text-ellipsis whitespace-nowrap overflow-hidden'
}
>
{node.name}
</div>
{node.children?.length && (
<ul className="pl-4">
{node.children.map(node => (
<Node node={node} key={node.id} />
))}
</ul>
)}
</li>
)
}
Two things are going on here:
-
We render the current
Node
's name<div className={'...'}>{node.name}</div>
-
And we recursively render descendant
Node
s within aul
node.children?.length && ( <ul className="pl-4"> {node.children.map(node => ( <Node node={node} key={node.id} /> ))} </ul> )
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} > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Why do we use value
and onChange
?
I am modeling Treeview as a controlled component, and these two props are the common way of accomplishing that.
The value
here is the selected node.
This can get confusing when use cases often include renameable nodes,
but at the end of the day value
is the current selection, and onChange
is how you select.
This node, that node
A Node
is an item in a Treeview.
Node
's can have children, and if they do they can be in an Open
or Closed
state.
Node
's at the base of the tree are referred to as Root Nodes
.
Node
's without children are referred to as Leaf Nodes
Why do we use ul
and li
?
The Treeview doesn't have a native implementation or a sanctioned DOM structure.
I am using ul and li, but this could very well be entirely divs.
To make our Treeview accessible we will add ARIA attributes in a future part to 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. If we store this state locally then it will be lost when a Node is unmounted.
The answer to this problem is lifting 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
export type TreeViewState = Map<string, boolean>
export enum TreeViewActionTypes {
OPEN = 'OPEN',
CLOSE = 'CLOSE',
}
export type TreeViewActions =
| {
type: TreeViewActionTypes.OPEN
id: string
}
| {
type: TreeViewActionTypes.CLOSE
id: string
}
export function treeviewReducer(
state: TreeViewState,
action: TreeViewActions,
): TreeViewState {
switch (action.type) {
case TreeViewActionTypes.OPEN:
return new Map(state).set(action.id, true)
case TreeViewActionTypes.CLOSE:
return new Map(state).set(action.id, false)
default:
throw new Error('Tree Reducer received an unknown action')
}
}
export type TreeViewContextType = {
open: TreeViewState
dispatch: Dispatch<TreeViewActions>
}
export const TreeViewContext = createContext<TreeViewContextType>({
open: new Map<string, boolean>(),
dispatch: () => {},
})
-
We create a type to describe our state.
export type TreeViewState = Map<string, boolean>
TreeViewState is a Map of ids (string) to open state (boolean) of a particular node.
-
We create an Enum of "Action Types"
export enum TreeViewActionTypes { OPEN = 'OPEN', CLOSE = 'CLOSE', }
This is a nice way of not using Magic string when discriminating actions in our reducer.
-
We define the Union of our Actions
export type TreeViewActions = | { type: TreeViewActionTypes.OPEN id: string } | { type: TreeViewActionTypes.CLOSE id: string }
-
We make a reducer that updates our state based on the two actions
export function treeviewReducer( state: TreeViewState, action: TreeViewActions, ): TreeViewState { switch (action.type) { case TreeViewActionTypes.OPEN: return new Map(state).set(action.id, true) case TreeViewActionTypes.CLOSE: return new Map(state).set(action.id, false) default: throw new Error('Tree Reducer received an unknown action') } }
-
We create the type for the context that includes the value and dispatch of our reducer
export type TreeViewContextType = { open: TreeViewState dispatch: Dispatch<TreeViewActions> }
And create the context itself.
export const TreeViewContext = createContext<TreeViewContextType>({ open: new Map<string, boolean>(), dispatch: () => {}, })
Updating Root to provide Open state
At this point, we are almost there. We just need to provide our context in the Root so that it is accessible to our Node.
export function Root({ children, className }: RootProps) {
+ const [open, dispatch] = useReducer(treeviewReducer, new Map<string, boolean>())
+
return (
+ <TreeViewContext.Provider
+ value={{
+ open,
+ dispatch,
+ }}
+ >
<ul className={clsx('flex flex-col overflow-auto', className)}>{children}</ul>
+ </TreeViewContext.Provider>
)
}
Updating Node to consume Open state
and then finally we can update the Node to consume the context, and conditionally render it's children
export function Node({ node }: NodeProps) {
+ const { open, dispatch } = useContext(TreeViewContext)
return (
<li className="flex flex-col cursor-pointer select-none">
<div
className={
'font-mono font-medium rounded-sm px-1 text-ellipsis whitespace-nowrap overflow-hidden'
}
+ onClick={() => {
+ open.get(node.id)
+ ? dispatch({
+ id: node.id,
+ type: TreeViewActionTypes.CLOSE,
+ })
+ : dispatch({
+ id: node.id,
+ type: TreeViewActionTypes.OPEN,
+ })
+ }}
>
{node.name}
</div>
- {node.children?.length && (
+ {node.children?.length && open.get(node.id) && (
<ul className="pl-4">
{node.children.map(node => (
<Node node={node} key={node.id} />
))}
</ul>
)}
</li>
)
}
-
We consume the context that our Root is providing
const { open, dispatch } = useContext(TreeViewContext)
-
Create an onClick handler for toggling the open state of the node
onClick={() => { open.get(node.id) ? dispatch({ id: node.id, type: TreeViewActionTypes.CLOSE, }) : dispatch({ id: node.id, type: TreeViewActionTypes.OPEN, }) }}
-
and Update the conditional rendering of the children based on whether the parent is in the 'open' state
- {node.children?.length && ( + {node.children?.length && open.get(node.id) && (
And with that, we can collapse each node.
Try it for yourself:
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} > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Adding indicator of Open state
Our nodes are 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 each node.
type IconProps = { open?: boolean; className?: string }
export function Arrow({ open, className }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className={clsx(
'origin-center',
open ? 'rotate-90' : 'rotate-0',
className,
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
)
}
- 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 the node is open
Next, we can add the Arrow to our Node component.
export function Node({ node }: NodeProps) {
const { open, dispatch } = useContext(TreeViewContext)
+ const isOpen = open.get(node.id)
return (
<li className="flex flex-col cursor-pointer select-none">
<div
className={
- 'font-mono font-medium rounded-sm px-1 text-ellipsis whitespace-nowrap overflow-hidden'
+ 'flex items-center space-x-2 font-mono font-medium rounded-sm px-1'
}
{/* ... */}
>
+ {node.children?.length ? (
+ <Arrow className="h-4 w-4 shrink-0" open={isOpen} />
+ ) : (
+ <span className="h-4 w-4 shrink-0" />
+ )}
- {node.name}
+ <span className="text-ellipsis whitespace-nowrap overflow-hidden">{node.name}</span>
</div>
{/* ... */}
</li>
)
}
-
We move the open state out of JSX, since it is used in multiple places.
const isOpen = open.get(node.id)
-
We conditionally render the Arrow icon based on whether it has children.
node.children?.length
Since 0, null, or undefined are all falsy this optional chaining allows us to check if there are one ore more nodes in the children array.
-
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<span className="text-ellipsis whitespace-nowrap overflow-hidden"> {node.name} </span>
Arrows are now rendering and it is much easier to differentiate which nodes 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} > {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.
type RootProps = {
children: ReactNode | ReactNode[]
className?: string
+ value: string | null
+ onChange: (id: string) => void
}
- export function Root({ children, className }: RootProps) {
+ export function Root({ children, className, value, onChange }: RootProps) {
const [open, dispatch] = useReducer(treeviewReducer, new Map<string, boolean>())
return (
<TreeViewContext.Provider
value={{
open,
dispatch,
+ selectedId: value,
+ selectId: onChange,
}}
>
<ul className={clsx('flex flex-col overflow-auto', className)}>{children}</ul>
</TreeViewContext.Provider>
)
}
We can then add the selectedId value and selectId callback to the context type.
export type TreeViewContextType = {
open: TreeViewState
dispatch: Dispatch<TreeViewActions>
+ selectedId: string | null
+ selectId: (id: string) => void
}
export const TreeViewContext = createContext<TreeViewContextType>({
open: new Map<string, boolean>(),
dispatch: () => {},
+ selectedId: null,
+ selectId: () => {},
})
And update our Node to indicate which node is selected.
export function Node({ node }: NodeProps) {
- const { open, dispatch } = useContext(TreeViewContext)
+ const { open, dispatch, selectId, selectedId } = useContext(TreeViewContext)
const isOpen = open.get(node.id)
return (
<li className="flex flex-col cursor-pointer select-none">
<div
className={clsx(
'flex items-center space-x-2 font-mono font-medium rounded-sm px-1 text-ellipsis whitespace-nowrap overflow-hidden',
+ selectedId === node.id ? 'bg-slate-200' : 'bg-transparent',
)}
onClick={() => {
isOpen
? dispatch({
id: node.id,
type: TreeViewActionTypes.CLOSE,
})
: dispatch({
id: node.id,
type: TreeViewActionTypes.OPEN,
})
+ selectId(node.id)
}}
>
...
-
We update our useContext to destructure selectId and selectedId
const { open, dispatch, selectId, selectedId } = useContext(TreeViewContext)
-
We toggle our background class based on selection.
selectedId === node.id ? 'bg-slate-200' : 'bg-transparent'
Something that tripped me up a lot when first using tailwind was doing something like
className={clsx("bg-transparent", selectedId === node.id && "bg-slate-200")}
I somehow thought that all their 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 to 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} > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
I hope you learned something from this post — it's the foundational piece to understanding the keyboard navigation, ARIA attributes, and animations to come in the next few parts.
Love hearing feedback so if you have any .