React treeview component (Part 1)

Feb 26, 2023

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

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

  1. We render the current Node's name

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

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

More Info

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

More Info

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: () => {},
})
  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 nice way of not using Magic string when discriminating actions in our reducer.

  3. We define the Union 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 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>
    )
}
  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(node.id)
            ? dispatch({
                id: node.id,
                type: TreeViewActionTypes.CLOSE,
            })
            : dispatch({
                id: node.id,
                type: TreeViewActionTypes.OPEN,
            })
    }}
    
  3. 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>
    )
}
  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 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>
    )
}
  1. We move the open state out of JSX, since it is used in multiple places.

    const isOpen = open.get(node.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 ore more nodes in the children array.

  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">{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)
                }}
            >

...

  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 === 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")}
    

    When I was first using tailwind 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.

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

Subscribe to the newsletter

A monthly no filler update.

Contact me at

my reddit accountmy codepen accountmy twitter accountmy github account