← Home

Build a simple React tree view component using HTML <details>

I seem to have a penchant for making tree view components. Here's a rough sketch of how to make a quick one in React using native HTML summary/details elements.

tree view screenshot

You need a <TreeViewNode/> which renders its children recursively, and a parent <TreeView/> component for some niceties. Toggling functionality is thanks to the splendid native HTML <details/> and <summary/> elements. You can learn more about them on the MDN docs website.

import cx from "classnames";
 
export interface TreeViewNodeProps
  extends React.DetailsHTMLAttributes<HTMLDetailsElement> {
  id: string;
  title: string;
  children: TreeViewNodeProps[];
}
 
export const TreeViewNode = ({
  title,
  children,
  className,
  ...delegated
}: TreeViewNodeProps) => {
  return (
    <details {...delegated} className={cx(className)}>
      {/* toggle */}
      <summary>{title}</summary>
      {/* content */}
      {children.map((t) => (
        <TreeViewNode {...t} key={t.id} className="pl-4" />
      ))}
    </details>
  );
};
 
interface TreeViewProps {
  roots: TreeViewNodeProps[];
}
 
export const TreeView = ({ roots }: TreeViewProps) => {
  return (
    <div>
      {roots.map((r) => (
        <TreeViewNode {...r} key={r.id} />
      ))}
    </div>
  );
};

Lifting state up and controlling nodes from a parent component

Here's how you might go a little further:

import cx from "classnames";
import { useState } from "react";
 
export interface TreeViewNodeProps
  extends React.DetailsHTMLAttributes<HTMLDetailsElement> {
  id: string;
  title: string;
  children: TreeViewNodeProps[];
  // utilities to open/close nodes
  isNodeOpen?: (nodeId: string) => boolean;
  doNodeOpen?: (nodeId: string) => void;
  doNodeClose?: (nodeId: string) => void;
  // when node selected
  onNodeSelect?: (
    e: React.MouseEvent<HTMLDivElement, MouseEvent>,
    id: string
  ) => void;
}
 
export const TreeViewNode = ({
  id,
  title,
  children,
  className,
  isNodeOpen,
  doNodeOpen,
  doNodeClose,
  onNodeSelect,
  ...delegated
}: TreeViewNodeProps) => {
  return (
    <details
      {...delegated}
      className={cx(className)}
      open={isNodeOpen && isNodeOpen(id)}
    >
      {/* toggle */}
      <summary className="flex items-center gap-2">
        {/* hide toggle when no children */}
        {children.length > 0 ? (
          <div
            onClick={(e) => {
              // https://github.com/facebook/react/issues/15486#issuecomment-488028431
              e.preventDefault();
              if (isNodeOpen && isNodeOpen(id)) {
                doNodeClose && doNodeClose(id);
              } else {
                doNodeOpen && doNodeOpen(id);
              }
            }}
          >
            {isNodeOpen && isNodeOpen(id) ? "⬇️ " : "➡️"}
          </div>
        ) : (
          <div className="invisible">⬇️</div>
        )}
        {/* title */}
        <div
          onClick={(e) => {
            e.preventDefault();
            onNodeSelect && onNodeSelect(e, id);
          }}
        >
          {title}
        </div>
      </summary>
      {/* content */}
      {children.map((t) => (
        <TreeViewNode
          className="pl-6"
          // props unique to this child
          {...t}
          key={t.id}
          // props/utilities that are passed thru from upper levels
          onNodeSelect={onNodeSelect}
          isNodeOpen={isNodeOpen}
          doNodeOpen={doNodeOpen}
          doNodeClose={doNodeClose}
        />
      ))}
    </details>
  );
};
 
interface TreeViewProps {
  roots: TreeViewNodeProps[];
  onNodeSelect?: (
    e: React.MouseEvent<HTMLDivElement, MouseEvent>,
    id: string
  ) => void;
}
 
export const TreeView = ({ roots, onNodeSelect }: TreeViewProps) => {
  // utilities to open/close nodes
  const [currentlyOpenIds, setCurrentlyOpenIds] = useState(new Set<string>());
  const isNodeOpen = (nodeId: string) => currentlyOpenIds.has(nodeId);
  const doNodeOpen = (nodeId: string) =>
    setCurrentlyOpenIds((prev) => {
      const next = new Set(prev);
      next.add(nodeId);
      return next;
    });
  const doNodeClose = (nodeId: string) =>
    setCurrentlyOpenIds((prev) => {
      const next = new Set(prev);
      next.delete(nodeId);
      return next;
    });
 
  return (
    <div>
      {roots.map((r) => (
        <TreeViewNode
          {...r}
          key={r.id}
          // when a node is selected
          onNodeSelect={onNodeSelect}
          // control which nodes are open
          isNodeOpen={isNodeOpen}
          doNodeOpen={doNodeOpen}
          doNodeClose={doNodeClose}
        />
      ))}
    </div>
  );
};

We could raise the state up even higher and make the opened nodes fully controlled from the consumer of our tree view (kind of like how the onNodeSelect is passed in from the consumer).

Also notice how I use a Set to figure out which nodes are open. You could use an array instead if that's simpler.

Btw if you need test data here is the data from the screenshot:

const items: TreeViewNodeProps[] = [
  {
    id: "tree-title-customization-item-1",
    title: "one",
    children: [
      {
        id: "tree-title-customization-item-2",
        title: "one one",
        children: [
          {
            id: "tree-title-customization-item-3",
            title: "one one one",
            children: [],
          },
        ],
      },
    ],
  },
  {
    id: "tree-title-customization-item-4",
    title: "two",
    children: [],
  },
];

Oh also if you already have a nested tree structure but converting the data type to the above is a pain, you can use a helper like below, which builds up a transformed output tree while traversing your input tree:

export const transformTagTreeNodeToTreeViewNode = (
  input: TagTreeNode[],
  output: TreeViewNodeProps[] = []
): TreeViewNodeProps[] => {
  input.forEach((tagTreeNode) => {
    output.push({
      id: tagTreeNode.id,
      title: tagTreeNode.name,
      children: transformTagTreeNodeToTreeViewNode(tagTreeNode.children),
    });
  });
  return output;
};

Ok, hopefully that's all I have to say about tree view components for a very long time! All this recursion is making my brain go a little crazy.