File tree (hierarchical tree)
Displaying File Tree Component
This page demonstrates how to integrate and display the a File Tree component within your React site.
Root
Root
user
user
document
document
photos
photos
system
system
programs
programs
files
files
workspace
workspace
project_a
project_a
project_b
project_b
What is File Tree?
At its core, a file tree (also often referred to as a directory tree) is a visual representation of a hierarchical structure. It displays data in a parent-child relationship, where folders (or directories) can contain other folders and individual files. Think of it like an upside-down tree, with a root directory at the top branching out into subdirectories and ultimately ending in individual files.
Key characteristics of a file tree include:
- Hierarchy: Clear parent-child relationships between items.
- Nesting: Folders can be nested to multiple levels, creating a deep structure.
- Visual Indication of Structure: Indentation and visual cues (like folder and file icons) help users understand the organization.
- Expand/Collapse Functionality: Users can typically expand folders to reveal their contents and collapse them to hide details, making navigation easier.
What are the Use Cases of a File Tree?
A file tree UI component isn't just for mimicking operating systems! It has a wide range of valuable applications on the web, including:
- File Management Systems: For applications that allow users to manage files and folders, a file tree provides an intuitive way to navigate through directories.
- Skill Organization: You can categorize your skills into different folders (e.g., "Frontend," "Backend," "Tools") with individual skills listed as "files."
- Document Management: For websites dealing with documentation, a hierarchical tree provides an intuitive way to browse through different sections and topics.
- Navigation Menus: For complex websites with many sections, a hierarchical tree can serve as a dynamic and organized navigation menu.
- Configuration Panels: Some applications use hierarchical trees to represent configuration settings, allowing users to navigate through different categories.
- Data Visualization: For certain types of hierarchical data (e.g., organizational charts, family trees), a hierarchical tree structure can be adapted for visualization.
How to implement a File Tree in react?
Here is the simplifid concept of the code that we are going to implement in this tutorial.
const FileTreeItem = ({ name, children }) => {
return (
<details>
<summary>
<span>{name}</span>
</summary>
{children != null && children.length > 0
? children.map((child, index: number) => (
<FileThreeContent key={tag} node={child} />
))
: null}
</details>
);
};
This tutorial demonstrates how to build a dynamic and interactive file tree component in React. It leverages the HTML <details> and <summary> tags for native collapsible functionality and incorporates features like adding new files/folders, renaming items, and context menus for enhanced user interaction. The implementation utilizes React Context API (via @radix-ui/react-context-menu) for the context menu and Redux Toolkit for state management.
Core Concepts:
- Collapsible Structure with
<details>and<summary>:
- The
<details>HTML element creates a disclosure widget that the user can open and close. - The
<summary>element defines a heading for the content of the<details>element. This is what the user clicks to open or close the details. - By nesting
<details>components recursively, we can represent the hierarchical structure of a file system, where each folder can contain sub-folders and files that can be expanded or collapsed.
- Recursive Rendering:
- The FileThreeContent component is designed to render both files and folders.
- If a node in the file tree has children, the FileThreeContent component recursively calls itself for each child, passing the child node as a prop. This creates the nested structure of the file tree.
- State Management with Redux Toolkit:
- fileTreeSlice: This Redux slice manages the application's state related to the file tree. It includes:
- node: The root node of the file tree data structure (BasicThree).
- selected: The tag (unique identifier) of the currently selected file or folder, potentially with a :rename suffix to indicate a renaming action.
- openedFolders: An array of tags of the folders that are currently open.
- renameName: The current value in the rename input field.
- Actions: The slice defines actions like setSelected, setNode, setOpenedFolders, setRenameName, addItem, and renameNode to update the state.
- Selectors: Selectors like selectSelected, selectOpenedFolders, selectRenameName, and selectNode are used to easily access specific parts of the file tree state within React components.
- useAppDispatch and useAppSelector: These hooks from @/lib/hooks (likely wrappers around Redux's useDispatch and useSelector) are used to interact with the Redux store.
- Data Structure (BasicThree and BasicTreeHelper):
- BasicThree (likely defined in src/types/types.ts): This interface or type likely represents a node in the file tree. It minimally contains:
- tag: A unique string identifier for the node (often a path-like string).
- name: The display name of the file or folder.
- children: An optional array of BasicThree nodes, representing the contents of a folder, or null for a file.
- BasicTreeHelper (in src/lib/helpers/BasicThreeHelper.ts): This class provides utility functions to manipulate the BasicThree data structure:
- makeThree: Creates a new BasicThree node.
- addChild: Adds a child node to a parent node.
- removeChild: Removes a child node.
- getChild: Finds a child node by name.
- setName: Updates the name and potentially the tag of a node.
- getChildren: Returns the children of a node.
- getParent: Finds the parent node of a given node.
- findByid: Finds a node by its tag.
- Context Menu with @radix-ui/react-context-menu:
- ContextMenu, ContextMenuTrigger, and ContextMenuContent components from @radix-ui/react-context-menu are used to implement a right-click context menu for file and folder interactions.
- ContextMenuTrigger wraps the element that will trigger the menu (in this case, the entire file tree container).
- ContextMenuContent contains the menu items (ContextMenuItem).
- The menu provides options to create new folders, new files, and rename the selected item.
- Dynamic Folder Opening/Closing:
- The openedFolders state in Redux keeps track of which folders are currently expanded.
- The handleOpenFolder function updates this state when a folder's expand/collapse icon (PlusIcon/MinusIcon) is clicked.
- The open prop of the
<details>tag is dynamically bound to whether the folder's tag is present in the openedFolders array.
- Item Selection:
- The selected state in Redux stores the tag of the currently selected item.
- The handleSelect function updates this state when a file or folder name is clicked.
- Visual feedback (background color change) is provided to indicate the selected item.
- Adding New Items:
-
The handleAddItem function dispatches the addItem action to the Redux store.
-
The addItem reducer in the fileTreeSlice creates a new BasicThree node and adds it as a child to the currently selected folder (or the root if nothing is selected).
-
After adding a new item, the state is updated to select the new item in "rename" mode. Renaming Items:
-
When the "Rename" option is clicked in the context menu, the setSelected action is dispatched to set the selected state to node.tag:rename, indicating that the selected item is being renamed.
-
An
<input>field appears with the current renameName. -
The onChange handler of the input updates the renameName state.
-
The onBlur handler dispatches the renameNode action when the input loses focus, which updates the name of the corresponding node in the fileTreeSlice state.
Step 1: Create the BasicThree Type (src/types/types.ts)
First, define the type for your tree node structure. Create a file named types.ts inside your src folder (or a types subdirectory if you prefer).
export interface BasicThree {
tag: string;
name: string;
children?: BasicThree[] | null;
}
This interface defines the structure of each node in your file tree. Each node has a unique tag, a name displayed to the user, and an optional children array, which can either be an array of other BasicThree nodes (for folders) or null (for files).
Step 2: Implement the BasicTreeHelper (src/lib/helpers/BasicThreeHelper.ts)
Create a helper class to manage operations on your BasicThree data structure. Create a folder named lib inside src, then a folder named helpers inside lib, and finally a file named BasicThreeHelper.ts inside helpers.
import { BasicThree as BasicTree } from "@/types/types";
export class BasicTreeHelper {
static makeThree = ({
name,
children,
}: {
name: string;
children?: BasicTree[] | null;
}) => {
const tree: BasicTree = {
tag: name,
name,
};
if (children) {
children.forEach((child) => BasicTreeHelper.addChild(tree, child));
} else {
tree.children = null;
}
return tree;
};
static addChild = (node: BasicTree, child: BasicTree): BasicTree => {
if (!node.children) {
node.children = [];
}
const childToPush = { ...child, tag: `${node.tag}/${child.tag}` };
node.children.push(childToPush);
return childToPush;
};
static removeChild = (node: BasicTree, child: BasicTree) => {
if (!node.children) {
return;
}
node.children = node.children.filter((c) => c !== child);
};
static getChild = (node: BasicTree, name: string) => {
if (!node.children) {
return;
}
return node.children.find((c) => c.name === name);
};
static setName = (node: BasicTree, name: string): BasicTree | void => {
node.name = name;
node.tag = node.tag.split("/").slice(0, -1).join("/") + "/" + name;
return node;
};
static getChildren = (node: BasicTree) => {
return node.children;
};
static getParent = (node: BasicTree, target: BasicTree) => {
const parentId: string = target.tag.split("/").slice(0, -1).join("/");
return BasicTreeHelper.findByid(node, parentId);
};
static getRoot = (node: BasicTree): BasicTree | void => {
return BasicTreeHelper.findByid(node, "/");
};
static getDepth = (node: BasicTree): number => {
return node.tag.split("/").length;
};
static findByid = (node: BasicTree, tag: string): BasicTree | void => {
if (node.tag === tag) {
return node;
}
if (!node.children) {
return;
}
for (const child of node.children) {
const result = this.findByid(child, tag);
if (result) {
return result;
}
}
};
}
This helper class provides static methods for:
- makeThree: Creating a new BasicThree node.
- addChild: Adding a child node to a parent node, updating the child's tag.
- removeChild: Removing a child node.
- getChild: Finding a child node by name.
- setName: Renaming a node and updating its tag.
- getChildren: Getting the children of a node.
- getParent: Getting the parent node of a given node.
- getRoot: Getting the root node (though not directly used in this component).
- getDepth: Getting the depth of a node in the tree.
- findByid: Finding a node by its tag.
Step 3: Create the File Tree Slice (src/lib/features/file-tree/fileTreeSlice.ts)
Now, set up your Redux slice to manage the state of the file tree. Create a folder named features inside lib, then a folder named file-tree inside features, and finally a file named fileTreeSlice.ts inside file-tree.
import { createAppSlice } from "@/lib/createAppSlice";
import type { AppThunk } from "@/lib/store";
import type { PayloadAction } from "@reduxjs/toolkit";
import { BasicThree } from "@/types/types";
import { BasicTreeHelper } from "@/lib/helpers/BasicThreeHelper";
interface FileThreeSliceState {
node: BasicThree;
selected: string;
openedFolders: string[];
renameName: string;
}
const placeHolderNode = BasicTreeHelper.makeThree({
name: "Root",
children: [
BasicTreeHelper.makeThree({
name: "user",
children: [
BasicTreeHelper.makeThree({ name: "document", children: [] }),
BasicTreeHelper.makeThree({ name: "photos", children: [] }),
],
}),
BasicTreeHelper.makeThree({
name: "system",
children: [
BasicTreeHelper.makeThree({ name: "programs" }),
BasicTreeHelper.makeThree({ name: "files" }),
],
}),
BasicTreeHelper.makeThree({
name: "workspace",
children: [
BasicTreeHelper.makeThree({ name: "project_a", children: [] }),
BasicTreeHelper.makeThree({ name: "project_b", children: [] }),
],
}),
],
});
const initialState: FileThreeSliceState = {
node: placeHolderNode,
selected: "",
openedFolders: [],
renameName: "",
};
export const fileTreeSlice = createAppSlice({
name: "fileThree",
initialState,
reducers: (create) => ({
setSelected: create.reducer((state, action: PayloadAction<string>) => {
state.selected = action.payload;
}),
setNode: create.reducer((state, action: PayloadAction<BasicThree>) => {
state.node = action.payload;
}),
setOpenedFolders: create.reducer(
(state, action: PayloadAction<string[]>) => {
state.openedFolders = action.payload;
}
),
setRenameName: create.reducer((state, action: PayloadAction<string>) => {
state.renameName = action.payload;
}),
addItem: create.reducer(
(state, action: PayloadAction<{ name: string; type: string }>) => {
const target = BasicTreeHelper.findByid(state.node, state.selected);
if (target) {
if (state.openedFolders.indexOf(target.tag) === -1) {
state.openedFolders = [...state.openedFolders, target.tag];
}
if (BasicTreeHelper.getChildren(target) !== null) {
const addedNode = BasicTreeHelper.addChild(
target,
BasicTreeHelper.makeThree({
name: action.payload.name,
children: action.payload.type === "Folder" ? [] : null,
})
);
state.selected = `${addedNode.tag}:rename`;
} else {
const parent = BasicTreeHelper.getParent(state.node, target);
if (parent) {
const addedNode = BasicTreeHelper.addChild(
parent,
BasicTreeHelper.makeThree({
name: action.payload.name,
children: action.payload.type === "Folder" ? [] : null,
})
);
state.selected = `${addedNode.tag}:rename`;
}
}
}
}
),
renameNode: create.reducer((state, action: PayloadAction<string>) => {
const target = BasicTreeHelper.findByid(state.node, action.payload);
if (target) {
if (state.openedFolders.indexOf(target.tag) !== -1) {
state.openedFolders.filter((tag) => tag !== target.tag);
BasicTreeHelper.setName(target, state.renameName);
state.openedFolders = [...state.openedFolders, target.tag];
} else {
BasicTreeHelper.setName(target, state.renameName);
}
state.renameName = "";
state.selected = target.tag;
}
}),
}),
selectors: {
selectSelected: (fileThree) => fileThree.selected,
selectOpenedFolders: (fileThree) => fileThree.openedFolders,
selectRenameName: (fileThree) => fileThree.renameName,
selectNode: (fileThree) => fileThree.node,
},
});
export const {
setSelected,
setOpenedFolders,
setNode,
setRenameName,
addItem,
renameNode,
} = fileTreeSlice.actions;
export const {
selectSelected,
selectOpenedFolders,
selectRenameName,
selectNode,
} = fileTreeSlice.selectors;
This slice defines the Redux state for your file tree:
node: The root node of the file tree (BasicThree type). selected: The tag of the currently selected node (or an empty string if none is selected). It can also include :rename suffix when a node is being renamed. openedFolders: An array of tags of the folders that are currently open. renameName: The current value being entered when renaming a node. It also defines reducers (functions that update the state):
setSelected: Sets the selected node. setNode: Sets the root node of the tree. setOpenedFolders: Sets the array of openedFolders. setRenameName: Sets the renameName. addItem: Adds a new file or folder to the selected node. renameNode: Renames the selected node. Finally, it exports the actions generated by the reducers and selectors to access the state values.
Step 4: Implement the FileThree Component (src/components/ui-components/FileTree.tsx)
Now, create the main FileThree component. Create a folder named components inside src, then a folder named ui-components inside components, and finally a file named FileTree.tsx inside ui-components.
import {
MouseEvent,
MutableRefObject,
ReactNode,
useEffect,
useRef,
} from "react";
import {
ContextMenu,
ContextMenuItem,
ContextMenuTrigger,
} from "@radix-ui/react-context-menu";
import { ContextMenuContent } from "../ui/context-menu";
import { File, Folder, FolderOpen, MinusIcon, PlusIcon } from "lucide-react";
import { useAppDispatch, useAppSelector } from "@/lib/hooks";
import {
addItem,
renameNode,
selectNode,
selectOpenedFolders,
selectRenameName,
selectSelected,
setNode,
setOpenedFolders,
setRenameName,
setSelected,
} from "@/lib/features/file-tree/fileTreeSlice";
import { BasicThree } from "@/types/types";
import { BasicTreeHelper } from "@/lib/helpers/BasicThreeHelper";
export default function FileThree({
node,
className,
}: {
node: BasicThree;
className?: string;
}) {
const dispatch = useAppDispatch();
const sliceNode = useAppSelector(selectNode);
const hasMounted: MutableRefObject<boolean> = useRef(false);
useEffect(() => {
if (!hasMounted.current) {
if (node) {
dispatch(setNode(node));
}
hasMounted.current = true;
}
}, [node, dispatch]);
useEffect(() => {}, []);
const Menu = ({ children }: { children: ReactNode }) => {
const dispatch = useAppDispatch();
const selected = useAppSelector(selectSelected);
const openedFolders = useAppSelector(selectOpenedFolders);
const sliceNode = useAppSelector(selectNode);
const handleAddItem = (
name: string,
type: "Folder" | "File"
) => {
if (selected !== "") {
dispatch(addItem({ name, type }));
} else {
alert("Please select an item first");
}
};
const handleCreateFolder = () => {
handleAddItem("New Folder", "Folder");
};
const handleCreateFile = () => {
handleAddItem("New File", "File");
};
const handleRename = () => {
if (selected !== "") {
const target = BasicTreeHelper.findByid(sliceNode, selected);
if (target) {
dispatch(setSelected(`${target.tag}:rename`));
}
} else {
alert("Please select an item first");
}
};
return (
<ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleCreateFolder}>
Create new folder
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateFile}>
Create new file
</ContextMenuItem>
<ContextMenuItem onClick={handleRename}>Rename</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
const FileThreeContent = ({
className,
node,
}: {
className?: string;
node: BasicThree;
}) => {
const dispatch = useAppDispatch();
const selected = useAppSelector(selectSelected);
const openedFolders = useAppSelector(selectOpenedFolders);
const renameName = useAppSelector(selectRenameName);
const sliceNode = useAppSelector(selectNode);
const children = BasicTreeHelper.getChildren(node);
const handleOpenFolder = (e: MouseEvent) => {
dispatch(
setOpenedFolders(
openedFolders.indexOf(node.tag) !== -1
? openedFolders.filter((tag) => tag !== node.tag)
: [...openedFolders, node.tag]
)
);
};
const handleSelect = (e: MouseEvent) => {
!selected?.split(":")[1] &&
(selected !== node.tag
? dispatch(setSelected(node.tag))
: dispatch(setSelected("")));
};
const isOpen = openedFolders.indexOf(node.tag) !== -1;
const isFolder = BasicTreeHelper.getChildren(node) !== null;
return (
<>
<details
open={isOpen}
onClick={(e) => {
e.preventDefault();
}}
className={`list-none ${className}`}
>
<summary
className={`list-none flex gap-2 items-center transition duration-300 ease-in-out transform
${
selected?.split(":")[0] === node.tag
? "dark:bg-lime-900 dark:text-lime-200 bg-slate-900 text-slate-200 scale-100"
: "bg-transparent"
}`}
>
{isFolder ? (
isOpen ? (
<MinusIcon size={16} onClick={handleOpenFolder} />
) : (
<PlusIcon size={16} onClick={handleOpenFolder} />
)
) : (
<div className="w-4"></div>
)}
<div className="flex gap-1 items-center" onClick={handleSelect}>
{isFolder ? isOpen ? <FolderOpen /> : <Folder /> : <File />}
{selected?.split(":")[0] === node.tag &&
selected?.split(":")[1] === "rename" ? (
<input
autoFocus
className="px-2 w-fit my-px text-slate-600 dark:text-emerald-300 block border border-slate-400 dark:border-emerald-400 animate-pulse"
type="text"
value={renameName}
onChange={(e) => {
e.preventDefault();
dispatch(setRenameName(e.target.value));
}}
onBlur={(e) => {
if (
BasicTreeHelper.getParent(
sliceNode,
node
)?.children?.some((e) => e.name === renameName)
) {
return alert("Another item with a same name exist");
}
if (renameName) {
dispatch(renameNode(node.tag));
} else {
alert("please entere a name");
e.target.focus();
}
}}
/>
) : (
<p>{node.name}</p>
)}
</div>
</summary>
{children != null && children.length > 0
? children.map((child, index: number) => (
<FileThreeContent
key={child.tag}
node={child}
className="pl-4"
/>
))
: null}
</details>
</>
);
};
return (
<Menu>
<div className="w-full h-80 overflow-scroll p-4 bg-slate-100 dark:bg-slate-900 rounded-lg">
<FileThreeContent node={sliceNode} className={className} />
</div>
</Menu>
);
}
Conclusion:
In this step-by-step tutorial, you've successfully implemented a dynamic and interactive file tree component in React using Redux Toolkit for state management and Radix UI for a seamless context menu experience. We covered the essential aspects, including:
- Defining the data structure (BasicThree): Establishing a clear and recursive way to represent the hierarchical file system.
- Creating a helper class (BasicTreeHelper): Encapsulating the logic for manipulating the tree data, such as adding children, renaming nodes, and traversing the structure.
- Setting up the Redux slice (fileTreeSlice): Managing the component's state, including the tree data, selected node, opened folders, and renaming state, along with the actions to modify this state.
- Building the FileThree component: Utilizing React's functional components, hooks (like useRef, useEffect, useAppDispatch, and useAppSelector), and the Radix UI ContextMenu to render the tree structure, handle user interactions (selection, opening/closing folders), and provide context-sensitive actions.
- Implementing the recursive rendering (FileThreeContent): Creating a component that calls itself to efficiently render nested levels of the file tree.
This implementation provides a solid foundation for a more complex file management interface. You can further extend this component by adding features like:
- Drag and drop functionality for moving files and folders.
- More sophisticated context menu options (e.g., delete, copy, paste).
- Asynchronous operations for fetching and updating file system data.
- Visual enhancements and customization options.
- Error handling and user feedback for various actions.
By understanding the principles demonstrated in this tutorial, you can adapt and expand this file tree component to meet the specific needs of your application. Remember to keep your state management organized with Redux Toolkit and leverage the accessibility and styling capabilities of UI libraries like Radix UI for a robust and user-friendly experience.