React treeview component (pt. 1)

Feb 26, 2023

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

  1. Render our data hierarchically
  2. Make items collapsible
  3. 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:

export type TreeNodeType = {
    id: string
    name: string
    children?: TreeNodeType[]
}

We define children to be an array reflecting the n nature 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>
    )
}
  1. clsx is an alternative to classNames and it is useful for deriving a list of classes from state
  2. overflow-auto ensures that when this component has static widths it overflows nicely

Node

The Node component contains the recursive rendering of tree nodes.

type NodeProps = {
    node: TreeNodeType
}

export function Node({ node: name, children }: 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'
                }
            >
                {name}
            </div>
            {children?.length && (
                <ul className="pl-4">
                    {children.map(node => (
                        <Node node={node} key={node.id} />
                    ))}
                </ul>
            )}
        </li>
    )
}

Two things are going on here:

  1. We render the current Node's name

    <div className={'...'}>{name}</div>
    
  2. And we recursively render descendant Nodes within a ul

    children?.length && (
        <ul className="pl-4">
            {children.map(node => (
                <Node node={node} key={node.id} />
            ))}
        </ul>
    )
    

    The hierarchical structure comes from the padding(pl-4) on our ul.

Then for the styles we use

  1. flex flex-col to remove default "bullet" on our li
  2. text-ellipsis whitespace-nowrap overflow-hidden to nicely handle text overflow with an ellipsis...
  3. cursor-pointer select-none to indicate interactivity and prevent text selection onClick

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.

More Info

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 divs.

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

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: () => {},
})
  1. 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.

  2. 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.

  3. We define the a union type of our "Actions"

    export type TreeViewActions =
        | {
              type: TreeViewActionTypes.OPEN
              id: string
          }
        | {
              type: TreeViewActionTypes.CLOSE
              id: string
          }
    
  4. 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')
        }
    }
    
  5. 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 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 Nodes.

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 we can update the Node to consume the context, and conditionally render its children

-export function Node({ node: { name, children } }: NodeProps) {
+export const Node = function TreeNode({
+    node: { id, name, children },
+}: 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(id)
+                        ? dispatch({
+                              id,
+                              type: TreeViewActionTypes.CLOSE,
+                          })
+                        : dispatch({
+                              id,
+                              type: TreeViewActionTypes.OPEN,
+                          })
+                }}
            >
                {name}
            </div>
-            {children?.length && (
+            {children?.length && open.get(id) && (
                <ul className="pl-4">
                    {children.map(node => (
                        <Node node={node} key={node.id} />
                    ))}
                </ul>
            )}
        </li>
    )
}
  1. We consume the context that our Root is providing

    const { open, dispatch } = useContext(TreeViewContext)
    
  2. Create an onClick handler for toggling the open state of the Node

    onClick={() => {
        open.get(id)
            ? dispatch({
                id,
                type: TreeViewActionTypes.CLOSE,
            })
            : dispatch({
                id,
                type: TreeViewActionTypes.OPEN,
            })
    }}
    
  3. and conditionally render based on whether the parent is in the open state

    -   {node.children?.length && (
    +   {node.children?.length && open.get(id) && (
    

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.

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>
    )
}
  1. We set origin-center so that the rotation revolves around the center instead of the top left
  2. We set stroke="currentColor" so that our icon will inherit its color from the surrounding text
  3. 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 const Node = function TreeNode({
    node: { id, name, children },
}: NodeProps) {
    const { open, dispatch } = useContext(TreeViewContext)
+    const isOpen = open.get(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" />
+                )}
-               {name}
+                <span className="text-ellipsis whitespace-nowrap overflow-hidden">{name}</span>
            </div>
            {/* ... */}
        </li>
    )
}
  1. We move the open state out of JSX, since it is used in multiple places.

    const isOpen = open.get(id)
    
  2. 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 or more children.

  3. We add some styles (flex items-center space-x-2) to place content into a row and add spacing.

  4. And we wrap {name} in a span so that overflow has an ellipses

    <span className="text-ellipsis whitespace-nowrap overflow-hidden">
        {name}
    </span>
    
  5. We use shrink-0 to prevent our icon from shrinking in the case of text overflow.

    <Arrow className="h-4 w-4 shrink-0" open={isOpen} />
    

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}
            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.

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(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 === id ? 'bg-slate-200' : 'bg-transparent',
                )}
                onClick={() => {
                    isOpen
                        ? dispatch({
                              id,
                              type: TreeViewActionTypes.CLOSE,
                          })
                        : dispatch({
                              id,
                              type: TreeViewActionTypes.OPEN,
                          })
+                    selectId(id)
                }}
            >

...

  1. We update our useContext to destructure selectId and selectedId

    const { open, dispatch, selectId, selectedId } = useContext(TreeViewContext)
    
  2. We toggle our background class based on selection.

    selectedId === 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 === id && "bg-slate-200")}
    

    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.

  3. And we call selectId in our onClick

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.

Subscribe to the newsletter

A monthly no filler update.

Contact me at