Menu

Building a Folder Navigation Sidebar with React MUI TreeView

As a front-end developer, creating intuitive navigation systems is a critical part of many applications. File explorers, document management systems, and category browsers all rely on hierarchical navigation that's both functional and user-friendly. MUI's TreeView component offers a powerful solution for implementing these interfaces in React applications.

In this guide, I'll walk you through building a complete folder navigation sidebar using MUI's TreeView component. We'll start with the basics and progressively enhance our implementation with advanced features like drag-and-drop, context menus, and optimized rendering for large datasets.

What You'll Learn

By the end of this article, you'll be able to:

  • Implement a fully functional folder navigation sidebar using MUI TreeView
  • Customize the appearance and behavior of TreeView nodes
  • Handle complex user interactions like expanding/collapsing folders
  • Implement drag-and-drop functionality for reorganizing items
  • Optimize performance for large folder structures
  • Integrate the TreeView with other parts of your application

Understanding MUI TreeView

The TreeView component is part of MUI's lab components, which means it's still under active development but ready for production use. It provides a way to display hierarchical data in a tree structure, making it perfect for folder navigation.

Installation and Basic Setup

To get started, you'll need to install the TreeView component and its dependencies:

npm install @mui/lab @mui/material @mui/icons-material

The TreeView component is the container for your tree structure, while TreeItem components represent individual nodes in the tree. Let's look at the basic structure:

import React from 'react';
import TreeView from '@mui/lab/TreeView';
import TreeItem from '@mui/lab/TreeItem';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';

function FolderNavigation() {
  return (
    <TreeView
      defaultCollapseIcon={<ExpandMoreIcon />}
      defaultExpandIcon={<ChevronRightIcon />}
    >
      <TreeItem nodeId="1" label="Documents">
        <TreeItem nodeId="2" label="Work Files" />
        <TreeItem nodeId="3" label="Personal">
          <TreeItem nodeId="4" label="Vacation Photos" />
          <TreeItem nodeId="5" label="Budget.xlsx" />
        </TreeItem>
      </TreeItem>
      <TreeItem nodeId="6" label="Downloads" />
    </TreeView>
  );
}

export default FolderNavigation;

This basic example demonstrates a simple folder structure with nested items. The nodeId prop is a unique identifier for each node, and the label prop defines what text is displayed for the node.

Core TreeView Props

The TreeView component comes with several important props that control its behavior:

PropTypeDefaultDescription
defaultCollapseIconnodeundefinedThe icon used to collapse nodes
defaultEndIconnodeundefinedThe icon displayed next to leaf nodes (nodes without children)
defaultExpandIconnodeundefinedThe icon used to expand nodes
defaultExpandedarray[]Array of node IDs that should be expanded by default
defaultSelectedstringnullThe ID of the node that should be selected by default
expandedarrayundefinedArray of node IDs that are currently expanded (controlled mode)
multiSelectboolfalseWhether multiple nodes can be selected
onNodeSelectfuncundefinedCallback fired when a node is selected
onNodeTogglefuncundefinedCallback fired when a node is expanded or collapsed
selectedstring | arrayundefinedThe ID(s) of the currently selected node(s) (controlled mode)

TreeItem Props

The TreeItem component also has several important props:

PropTypeDefaultDescription
nodeIdstringrequiredUnique identifier for the node
labelnoderequiredContent of the tree item
iconnodeundefinedIcon element displayed before the label
expandIconnodeundefinedIcon displayed when the node can be expanded
collapseIconnodeundefinedIcon displayed when the node can be collapsed
endIconnodeundefinedIcon displayed next to a leaf node
disabledboolfalseIf true, the node will be disabled

Building a Folder Navigation Sidebar Step by Step

Now that we understand the basics, let's build a complete folder navigation sidebar with MUI TreeView. We'll take a progressive approach, starting with a simple implementation and adding features as we go.

Step 1: Set Up the Project Structure

First, let's create the basic structure for our folder navigation component:

// src/components/FolderNavigation.js
import React, { useState } from 'react';
import { styled } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TreeView from '@mui/lab/TreeView';
import TreeItem from '@mui/lab/TreeItem';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';

// We'll define our component here

export default FolderNavigation;

This imports all the necessary components and icons we'll need. We're using:

  • TreeView and TreeItem from MUI Lab for the tree structure
  • Box and Typography from MUI for layout and text
  • Various icons for folders and files
  • The styled API for custom styling

Step 2: Define Sample Data Structure

Before implementing the component, let's define a sample data structure for our folders and files:

// Sample folder structure data
const folderData = [
  {
    id: 'root',
    name: 'Project Files',
    type: 'folder',
    children: [
      {
        id: 'docs',
        name: 'Documents',
        type: 'folder',
        children: [
          {
            id: 'work',
            name: 'Work Files',
            type: 'folder',
            children: [
              { id: 'report', name: 'Annual Report.pdf', type: 'file' },
              { id: 'presentation', name: 'Quarterly Update.pptx', type: 'file' }
            ]
          },
          {
            id: 'personal',
            name: 'Personal',
            type: 'folder',
            children: [
              { id: 'budget', name: 'Budget.xlsx', type: 'file' },
              { id: 'vacation', name: 'Vacation Photos', type: 'folder', children: [] }
            ]
          }
        ]
      },
      {
        id: 'downloads',
        name: 'Downloads',
        type: 'folder',
        children: [
          { id: 'software', name: 'Software.zip', type: 'file' },
          { id: 'image', name: 'Image.jpg', type: 'file' }
        ]
      },
      { id: 'notes', name: 'Notes.txt', type: 'file' }
    ]
  }
];

This data structure represents a typical file system with folders and files. Each item has:

  • A unique id
  • A display name
  • A type (either 'folder' or 'file')
  • Optional children for folders

Step 3: Create Custom Styled TreeItems

Let's enhance the visual appearance of our TreeView by creating styled versions of the TreeItem component:

// Custom styled TreeItem
const StyledTreeItem = styled(TreeItem)(({ theme }) => ({
  '& .MuiTreeItem-content': {
    padding: theme.spacing(0.5, 1),
    borderRadius: theme.shape.borderRadius,
    '&:hover': {
      backgroundColor: theme.palette.action.hover,
    },
    '&.Mui-selected': {
      backgroundColor: theme.palette.action.selected,
      '&:hover': {
        backgroundColor: theme.palette.action.selected,
      },
    },
  },
  '& .MuiTreeItem-group': {
    marginLeft: theme.spacing(2),
    paddingLeft: theme.spacing(1),
    borderLeft: `1px dashed ${theme.palette.divider}`,
  },
}));

// Custom TreeItem with icons based on item type
const FileTreeItem = (props) => {
  const { nodeId, type, label, ...other } = props;
  
  const getIcon = () => {
    if (type === 'file') {
      return <InsertDriveFileIcon color="action" fontSize="small" />;
    }
    return null; // Folder icons will be handled by TreeView's default icons
  };

  return (
    <StyledTreeItem
      nodeId={nodeId}
      label={
        <Box sx={{ display: 'flex', alignItems: 'center', py: 0.5 }}>
          {getIcon()}
          <Typography variant="body2" sx={{ ml: 1 }}>
            {label}
          </Typography>
        </Box>
      }
      {...other}
    />
  );
};

Here, we've created:

  1. A StyledTreeItem that enhances the default TreeItem with hover effects, better spacing, and a dashed line connecting parent and child nodes
  2. A FileTreeItem component that displays different icons based on whether the item is a file or folder

Step 4: Implement the Recursive Rendering Function

Now, let's create a function to recursively render our folder structure:

// Recursive function to render the tree
const renderTree = (nodes) => {
  return nodes.map((node) => (
    <FileTreeItem
      key={node.id}
      nodeId={node.id}
      type={node.type}
      label={node.name}
    >
      {node.children && node.children.length > 0 ? renderTree(node.children) : null}
    </FileTreeItem>
  ));
};

This function takes an array of nodes and maps through them, creating a FileTreeItem for each node. If a node has children, it recursively calls itself to render those children.

Step 5: Create the Main Component

Now, let's put everything together to create our FolderNavigation component:

// Main component
const FolderNavigation = () => {
  const [expanded, setExpanded] = useState(['root']);
  const [selected, setSelected] = useState([]);

  const handleToggle = (event, nodeIds) => {
    setExpanded(nodeIds);
  };

  const handleSelect = (event, nodeIds) => {
    setSelected(nodeIds);
  };

  return (
    <Box sx={{ minHeight: 300, flexGrow: 1, maxWidth: 300, overflowY: 'auto' }}>
      <TreeView
        aria-label="folder navigation"
        defaultCollapseIcon={<FolderOpenIcon color="primary" />}
        defaultExpandIcon={<FolderIcon color="primary" />}
        defaultEndIcon={<InsertDriveFileIcon color="action" fontSize="small" />}
        expanded={expanded}
        selected={selected}
        onNodeToggle={handleToggle}
        onNodeSelect={handleSelect}
        multiSelect={false}
        sx={{ 
          height: '100%',
          flexGrow: 1,
          overflowY: 'auto',
          '& .MuiTreeItem-root': {
            '&.Mui-selected > .MuiTreeItem-content .MuiTreeItem-label': {
              backgroundColor: 'transparent',
            },
          },
        }}
      >
        {renderTree(folderData)}
      </TreeView>
    </Box>
  );
};

In this component:

  1. We use React's useState hook to manage the expanded and selected state of the tree nodes
  2. We define handlers for toggle and select events
  3. We render the TreeView with appropriate icons for different node states
  4. We call our renderTree function to generate the tree structure from our data

Step 6: Implement Controlled Expansion and Selection

Let's enhance our component to handle controlled expansion and selection, which gives us more control over the behavior:

const FolderNavigation = () => {
  const [expanded, setExpanded] = useState(['root']);
  const [selected, setSelected] = useState([]);

  const handleToggle = (event, nodeIds) => {
    setExpanded(nodeIds);
  };

  const handleSelect = (event, nodeIds) => {
    setSelected(Array.isArray(nodeIds) ? nodeIds : [nodeIds]);
    
    // You can add additional logic here, like fetching file contents
    // when a file is selected
    const selectedNode = findNodeById(folderData, nodeIds);
    if (selectedNode && selectedNode.type === 'file') {
      console.log(`File selected: ${selectedNode.name}`);
      // You could dispatch an action or call a function here
    }
  };

  // Helper function to find a node by ID
  const findNodeById = (nodes, id) => {
    for (const node of nodes) {
      if (node.id === id) {
        return node;
      }
      if (node.children && node.children.length > 0) {
        const foundInChildren = findNodeById(node.children, id);
        if (foundInChildren) {
          return foundInChildren;
        }
      }
    }
    return null;
  };

  // ... rest of the component
}

This enhancement adds a helper function to find a node by its ID, which is useful for handling selection events. We also added logic to detect when a file is selected, which could be extended to load file contents or perform other actions.

Step 7: Add Context Menu Functionality

Let's add a context menu to our folder navigation to enable actions like creating, renaming, and deleting items:

import React, { useState, useRef } from 'react';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';

// Inside the FolderNavigation component
const FolderNavigation = () => {
  // Previous state variables...
  const [contextMenu, setContextMenu] = useState(null);
  const [rightClickedNode, setRightClickedNode] = useState(null);
  
  const handleContextMenu = (event, nodeId) => {
    event.preventDefault();
    event.stopPropagation();
    
    setRightClickedNode(nodeId);
    setContextMenu({
      mouseX: event.clientX,
      mouseY: event.clientY,
    });
  };

  const handleContextMenuClose = () => {
    setContextMenu(null);
  };

  const handleContextMenuAction = (action) => {
    const node = findNodeById(folderData, rightClickedNode);
    
    switch (action) {
      case 'new':
        console.log(`Create new item in ${node.name}`);
        // Logic to create a new item
        break;
      case 'rename':
        console.log(`Rename ${node.name}`);
        // Logic to rename an item
        break;
      case 'delete':
        console.log(`Delete ${node.name}`);
        // Logic to delete an item
        break;
      case 'copy':
        console.log(`Copy ${node.name}`);
        // Logic to copy an item
        break;
      case 'cut':
        console.log(`Cut ${node.name}`);
        // Logic to cut an item
        break;
      case 'paste':
        console.log(`Paste into ${node.name}`);
        // Logic to paste an item
        break;
      default:
        break;
    }
    
    handleContextMenuClose();
  };

  // Modify the renderTree function to add context menu event
  const renderTree = (nodes) => {
    return nodes.map((node) => (
      <FileTreeItem
        key={node.id}
        nodeId={node.id}
        type={node.type}
        label={node.name}
        onContextMenu={(event) => handleContextMenu(event, node.id)}
      >
        {node.children && node.children.length > 0 ? renderTree(node.children) : null}
      </FileTreeItem>
    ));
  };

  return (
    <Box sx={{ minHeight: 300, flexGrow: 1, maxWidth: 300, overflowY: 'auto' }}>
      <TreeView
        // Previous props...
      >
        {renderTree(folderData)}
      </TreeView>
      
      <Menu
        open={contextMenu !== null}
        onClose={handleContextMenuClose}
        anchorReference="anchorPosition"
        anchorPosition={
          contextMenu !== null
            ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
            : undefined
        }
      >
        <MenuItem onClick={() => handleContextMenuAction('new')}>
          <ListItemIcon>
            <AddIcon fontSize="small" />
          </ListItemIcon>
          <ListItemText>New</ListItemText>
        </MenuItem>
        <MenuItem onClick={() => handleContextMenuAction('rename')}>
          <ListItemIcon>
            <EditIcon fontSize="small" />
          </ListItemIcon>
          <ListItemText>Rename</ListItemText>
        </MenuItem>
        <MenuItem onClick={() => handleContextMenuAction('delete')}>
          <ListItemIcon>
            <DeleteIcon fontSize="small" />
          </ListItemIcon>
          <ListItemText>Delete</ListItemText>
        </MenuItem>
        <MenuItem onClick={() => handleContextMenuAction('copy')}>
          <ListItemIcon>
            <ContentCopyIcon fontSize="small" />
          </ListItemIcon>
          <ListItemText>Copy</ListItemText>
        </MenuItem>
        <MenuItem onClick={() => handleContextMenuAction('cut')}>
          <ListItemIcon>
            <ContentCutIcon fontSize="small" />
          </ListItemIcon>
          <ListItemText>Cut</ListItemText>
        </MenuItem>
        <MenuItem onClick={() => handleContextMenuAction('paste')}>
          <ListItemIcon>
            <ContentPasteIcon fontSize="small" />
          </ListItemIcon>
          <ListItemText>Paste</ListItemText>
        </MenuItem>
      </Menu>
    </Box>
  );
};

This enhancement adds a context menu that appears when right-clicking on a tree node. The menu includes common file operations like new, rename, delete, copy, cut, and paste. The handleContextMenuAction function is a placeholder where you would implement the actual logic for these operations.

Step 8: Implement Drag and Drop Functionality

Let's add drag-and-drop functionality to allow users to reorganize the folder structure:

import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

// DraggableTreeItem component
const DraggableTreeItem = (props) => {
  const { nodeId, type, label, onMoveNode, ...other } = props;
  
  const [{ isDragging }, drag] = useDrag({
    type: 'TREE_ITEM',
    item: { id: nodeId, type },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });
  
  const [{ isOver, canDrop }, drop] = useDrop({
    accept: 'TREE_ITEM',
    drop: (item) => {
      if (item.id !== nodeId) {
        onMoveNode(item.id, nodeId);
      }
    },
    canDrop: (item) => {
      // Only allow dropping into folders
      return type === 'folder' && item.id !== nodeId;
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop(),
    }),
  });
  
  // Combine drag and drop refs
  const ref = useRef(null);
  drag(drop(ref));
  
  return (
    <div 
      ref={ref} 
      style={{ 
        opacity: isDragging ? 0.5 : 1,
        backgroundColor: isOver && canDrop ? 'rgba(0, 255, 0, 0.1)' : 'transparent',
      }}
    >
      <FileTreeItem
        nodeId={nodeId}
        type={type}
        label={label}
        {...other}
      />
    </div>
  );
};

// Update the FolderNavigation component
const FolderNavigation = () => {
  // Previous state variables...
  
  const handleMoveNode = (sourceId, targetId) => {
    // This is where you would implement the logic to move a node
    // For a real application, you would likely update your state or make an API call
    console.log(`Moving node ${sourceId} to ${targetId}`);
    
    // Example implementation (not complete):
    // 1. Find the source node and its parent
    // 2. Find the target node (which should be a folder)
    // 3. Remove the source node from its parent's children
    // 4. Add the source node to the target node's children
    // 5. Update the state with the new structure
  };
  
  // Update the renderTree function to use DraggableTreeItem
  const renderTree = (nodes) => {
    return nodes.map((node) => (
      <DraggableTreeItem
        key={node.id}
        nodeId={node.id}
        type={node.type}
        label={node.name}
        onContextMenu={(event) => handleContextMenu(event, node.id)}
        onMoveNode={handleMoveNode}
      >
        {node.children && node.children.length > 0 ? renderTree(node.children) : null}
      </DraggableTreeItem>
    ));
  };
  
  return (
    <DndProvider backend={HTML5Backend}>
      <Box sx={{ minHeight: 300, flexGrow: 1, maxWidth: 300, overflowY: 'auto' }}>
        {/* Rest of the component... */}
      </Box>
    </DndProvider>
  );
};

This enhancement adds drag-and-drop functionality using the react-dnd library. We create a DraggableTreeItem component that wraps our FileTreeItem and handles the drag-and-drop logic. The handleMoveNode function is a placeholder where you would implement the actual logic to update your data structure when a node is moved.

Step 9: Add Search Functionality

Let's add a search feature to help users find files and folders quickly:

import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import SearchIcon from '@mui/icons-material/Search';

// Inside the FolderNavigation component
const FolderNavigation = () => {
  // Previous state variables...
  const [searchTerm, setSearchTerm] = useState('');
  const [searchResults, setSearchResults] = useState([]);
  
  const handleSearch = (event) => {
    const term = event.target.value;
    setSearchTerm(term);
    
    if (term.trim() === '') {
      setSearchResults([]);
      return;
    }
    
    // Search through the folder structure
    const results = searchNodes(folderData, term.toLowerCase());
    setSearchResults(results);
    
    // Expand paths to search results
    const pathsToExpand = results.reduce((paths, node) => {
      return [...paths, ...getPathToNode(folderData, node.id)];
    }, []);
    
    setExpanded([...new Set(pathsToExpand)]);
  };
  
  // Helper function to search nodes
  const searchNodes = (nodes, term, results = []) => {
    for (const node of nodes) {
      if (node.name.toLowerCase().includes(term)) {
        results.push(node);
      }
      
      if (node.children && node.children.length > 0) {
        searchNodes(node.children, term, results);
      }
    }
    
    return results;
  };
  
  // Helper function to get the path to a node
  const getPathToNode = (nodes, targetId, path = [], currentPath = []) => {
    for (const node of nodes) {
      const newPath = [...currentPath, node.id];
      
      if (node.id === targetId) {
        return [...path, ...newPath];
      }
      
      if (node.children && node.children.length > 0) {
        const foundPath = getPathToNode(node.children, targetId, path, newPath);
        if (foundPath.length > 0) {
          return foundPath;
        }
      }
    }
    
    return [];
  };
  
  return (
    <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      <TextField
        fullWidth
        variant="outlined"
        size="small"
        placeholder="Search files and folders..."
        value={searchTerm}
        onChange={handleSearch}
        sx={{ mb: 2 }}
        InputProps={{
          startAdornment: (
            <InputAdornment position="start">
              <SearchIcon />
            </InputAdornment>
          ),
        }}
      />
      
      <Box sx={{ flexGrow: 1, overflow: 'auto' }}>
        <TreeView
          // Previous props...
        >
          {renderTree(folderData)}
        </TreeView>
      </Box>
      
      {/* Context menu... */}
    </Box>
  );
};

This enhancement adds a search field that filters the folder structure as the user types. When a match is found, the tree automatically expands to show the matching nodes. The searchNodes function recursively searches through the folder structure, and the getPathToNode function helps identify which nodes need to be expanded to reveal the search results.

Step 10: Handle Large Data Sets with Virtualization

For large folder structures, rendering the entire tree can cause performance issues. Let's implement virtualization to improve performance:

import { FixedSizeTree } from 'react-vtree';
import AutoSizer from 'react-virtualized-auto-sizer';

// Inside the FolderNavigation component
const FolderNavigation = () => {
  // Previous state variables...
  
  // Flatten the tree data for virtualization
  const flattenTree = (nodes, depth = 0, parentExpanded = true, result = []) => {
    for (const node of nodes) {
      const isExpanded = expanded.includes(node.id);
      const isVisible = depth === 0 || parentExpanded;
      
      if (isVisible) {
        result.push({
          id: node.id,
          name: node.name,
          type: node.type,
          depth,
          isExpanded,
          isLeaf: !node.children || node.children.length === 0,
        });
        
        if (node.children && node.children.length > 0 && isExpanded) {
          flattenTree(node.children, depth + 1, isExpanded, result);
        }
      }
    }
    
    return result;
  };
  
  const flatData = flattenTree(folderData);
  
  // Node renderer for virtualized tree
  const Node = ({ data, style }) => {
    const { id, name, type, depth, isExpanded, isLeaf } = data;
    const indent = depth * 24;
    
    const getIcon = () => {
      if (type === 'file') {
        return <InsertDriveFileIcon color="action" fontSize="small" />;
      }
      return isExpanded ? 
        <FolderOpenIcon color="primary" /> : 
        <FolderIcon color="primary" />;
    };
    
    const handleNodeClick = () => {
      if (!isLeaf) {
        const newExpanded = isExpanded 
          ? expanded.filter(id => id !== data.id) 
          : [...expanded, data.id];
        setExpanded(newExpanded);
      }
      
      setSelected([id]);
    };
    
    const isSelected = selected.includes(id);
    
    return (
      <div
        style={{
          ...style,
          paddingLeft: `${indent}px`,
          backgroundColor: isSelected ? 'rgba(0, 0, 0, 0.08)' : 'transparent',
          display: 'flex',
          alignItems: 'center',
          cursor: 'pointer',
          userSelect: 'none',
        }}
        onClick={handleNodeClick}
        onContextMenu={(event) => handleContextMenu(event, id)}
      >
        {!isLeaf && (
          <div style={{ marginRight: 4 }}>
            {isExpanded ? <ExpandMoreIcon /> : <ChevronRightIcon />}
          </div>
        )}
        <div style={{ marginRight: 4 }}>
          {getIcon()}
        </div>
        <Typography variant="body2">{name}</Typography>
      </div>
    );
  };
  
  return (
    <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      <TextField
        // Search field...
      />
      
      <Box sx={{ flexGrow: 1, overflow: 'auto' }}>
        <AutoSizer>
          {({ height, width }) => (
            <FixedSizeTree
              height={height}
              width={width}
              itemSize={32}
              itemCount={flatData.length}
              itemData={flatData}
              children={Node}
            />
          )}
        </AutoSizer>
      </Box>
      
      {/* Context menu... */}
    </Box>
  );
};

This enhancement replaces the standard TreeView with a virtualized tree implementation using react-vtree and react-virtualized-auto-sizer. The virtualized approach only renders the nodes that are currently visible in the viewport, which significantly improves performance for large data sets.

Step 11: Integrate with Backend API

Let's integrate our folder navigation with a backend API to fetch and update the folder structure:

import { useEffect } from 'react';
import axios from 'axios';
import CircularProgress from '@mui/material/CircularProgress';

// Inside the FolderNavigation component
const FolderNavigation = () => {
  // Previous state variables...
  const [folderData, setFolderData] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchFolderData = async () => {
      try {
        setLoading(true);
        const response = await axios.get('/api/folders');
        setFolderData(response.data);
        
        // Expand the root folder by default
        if (response.data.length > 0) {
          setExpanded([response.data[0].id]);
        }
        
        setLoading(false);
      } catch (err) {
        setError('Failed to fetch folder data');
        setLoading(false);
        console.error(err);
      }
    };
    
    fetchFolderData();
  }, []);
  
  const handleCreateFolder = async (parentId, name) => {
    try {
      const response = await axios.post('/api/folders', {
        parentId,
        name,
        type: 'folder'
      });
      
      // Update the folder structure with the new folder
      const updatedData = addNodeToTree(folderData, parentId, response.data);
      setFolderData(updatedData);
    } catch (err) {
      console.error('Failed to create folder', err);
    }
  };
  
  const handleDeleteItem = async (id) => {
    try {
      await axios.delete(`/api/items/${id}`);
      
      // Update the folder structure by removing the deleted item
      const updatedData = removeNodeFromTree(folderData, id);
      setFolderData(updatedData);
    } catch (err) {
      console.error('Failed to delete item', err);
    }
  };
  
  // Helper function to add a node to the tree
  const addNodeToTree = (nodes, parentId, newNode) => {
    return nodes.map(node => {
      if (node.id === parentId) {
        return {
          ...node,
          children: [...(node.children || []), newNode]
        };
      }
      
      if (node.children && node.children.length > 0) {
        return {
          ...node,
          children: addNodeToTree(node.children, parentId, newNode)
        };
      }
      
      return node;
    });
  };
  
  // Helper function to remove a node from the tree
  const removeNodeFromTree = (nodes, id) => {
    return nodes.filter(node => node.id !== id).map(node => {
      if (node.children && node.children.length > 0) {
        return {
          ...node,
          children: removeNodeFromTree(node.children, id)
        };
      }
      
      return node;
    });
  };
  
  if (loading) {
    return (
      <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
        <CircularProgress />
      </Box>
    );
  }
  
  if (error) {
    return (
      <Box sx={{ p: 2 }}>
        <Typography color="error">{error}</Typography>
      </Box>
    );
  }
  
  // Rest of the component...
};

This enhancement integrates the folder navigation with a backend API. It fetches the folder structure when the component mounts and provides functions to create and delete items. The addNodeToTree and removeNodeFromTree helper functions update the local state when changes are made.

Customizing TreeView Appearance and Behavior

MUI's TreeView component is highly customizable. Let's explore some advanced customization options:

Theme Customization

You can customize the TreeView appearance using MUI's theming system:

import { createTheme, ThemeProvider } from '@mui/material/styles';

const theme = createTheme({
  components: {
    MuiTreeItem: {
      styleOverrides: {
        root: {
          '&.Mui-selected > .MuiTreeItem-content .MuiTreeItem-label': {
            backgroundColor: 'transparent',
          },
          '&.Mui-selected > .MuiTreeItem-content .MuiTreeItem-label:hover': {
            backgroundColor: 'rgba(0, 0, 0, 0.04)',
          },
        },
        content: {
          padding: '4px 8px',
          borderRadius: '4px',
          '&:hover': {
            backgroundColor: 'rgba(0, 0, 0, 0.04)',
          },
        },
        label: {
          fontWeight: 400,
          padding: '0 4px',
        },
        iconContainer: {
          marginRight: 4,
          width: 20,
          display: 'flex',
          justifyContent: 'center',
          '& svg': {
            fontSize: 20,
          },
        },
        group: {
          marginLeft: 12,
          paddingLeft: 12,
          borderLeft: `1px dashed rgba(0, 0, 0, 0.2)`,
        },
      },
    },
  },
});

// Wrap your component with ThemeProvider
const App = () => {
  return (
    <ThemeProvider theme={theme}>
      <FolderNavigation />
    </ThemeProvider>
  );
};

Custom Node Content

You can customize the content of each node to include additional information or controls:

const CustomTreeItem = (props) => {
  const { nodeId, type, label, size, modified, ...other } = props;
  
  return (
    <StyledTreeItem
      nodeId={nodeId}
      label={
        <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
          <Box sx={{ display: 'flex', alignItems: 'center' }}>
            {type === 'file' ? (
              <InsertDriveFileIcon color="action" fontSize="small" />
            ) : (
              <FolderIcon color="primary" fontSize="small" />
            )}
            <Typography variant="body2" sx={{ ml: 1 }}>
              {label}
            </Typography>
          </Box>
          <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
            {size && (
              <Typography variant="caption" color="text.secondary">
                {size}
              </Typography>
            )}
            {modified && (
              <Typography variant="caption" color="text.secondary">
                {modified}
              </Typography>
            )}
          </Box>
        </Box>
      }
      {...other}
    />
  );
};

This custom TreeItem displays additional information like file size and modification date. You would use it like this:

const renderTree = (nodes) => {
  return nodes.map((node) => (
    <CustomTreeItem
      key={node.id}
      nodeId={node.id}
      type={node.type}
      label={node.name}
      size={node.size}
      modified={node.modified}
    >
      {node.children && node.children.length > 0 ? renderTree(node.children) : null}
    </CustomTreeItem>
  ));
};

Custom Expand/Collapse Icons

You can customize the icons used for expanding and collapsing nodes:

import AddBoxOutlinedIcon from '@mui/icons-material/AddBoxOutlined';
import IndeterminateCheckBoxOutlinedIcon from '@mui/icons-material/IndeterminateCheckBoxOutlined';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';

// Inside your component
return (
  <TreeView
    defaultCollapseIcon={<IndeterminateCheckBoxOutlinedIcon />}
    defaultExpandIcon={<AddBoxOutlinedIcon />}
    defaultEndIcon={<CheckBoxOutlineBlankIcon />}
    // Other props...
  >
    {/* Tree content */}
  </TreeView>
);

Advanced TreeView Capabilities

Let's explore some advanced capabilities of the MUI TreeView component:

Keyboard Navigation

TreeView supports keyboard navigation out of the box, but you can enhance it with custom handlers:

const handleKeyDown = (event) => {
  const selectedNode = findNodeById(folderData, selected[0]);
  
  switch (event.key) {
    case 'Enter':
      if (selectedNode && selectedNode.type === 'file') {
        console.log(`Opening file: ${selectedNode.name}`);
        // Logic to open the file
      }
      break;
    case 'Delete':
      if (selectedNode) {
        console.log(`Deleting: ${selectedNode.name}`);
        // Logic to delete the selected item
      }
      break;
    case 'F2':
      if (selectedNode) {
        console.log(`Renaming: ${selectedNode.name}`);
        // Logic to rename the selected item
      }
      break;
    default:
      break;
  }
};

// Add the event listener to the TreeView
<TreeView
  // Other props...
  onKeyDown={handleKeyDown}
>
  {/* Tree content */}
</TreeView>

Lazy Loading

For large folder structures, you might want to implement lazy loading to fetch child nodes only when a parent is expanded:

const FolderNavigation = () => {
  // Previous state variables...
  const [loadingNodes, setLoadingNodes] = useState({});
  
  const handleToggle = async (event, nodeIds) => {
    const expandedNodes = nodeIds.filter(id => !expanded.includes(id));
    
    // For each newly expanded node, fetch its children if needed
    for (const nodeId of expandedNodes) {
      const node = findNodeById(folderData, nodeId);
      
      // If the node is a folder and has no children yet, fetch them
      if (node && node.type === 'folder' && (!node.children || node.children.length === 0)) {
        try {
          setLoadingNodes(prev => ({ ...prev, [nodeId]: true }));
          
          // Fetch children from the API
          const response = await axios.get(`/api/folders/${nodeId}/children`);
          
          // Update the folder structure with the fetched children
          const updatedData = updateNodeChildren(folderData, nodeId, response.data);
          setFolderData(updatedData);
          
          setLoadingNodes(prev => ({ ...prev, [nodeId]: false }));
        } catch (err) {
          console.error(`Failed to fetch children for folder ${nodeId}`, err);
          setLoadingNodes(prev => ({ ...prev, [nodeId]: false }));
        }
      }
    }
    
    setExpanded(nodeIds);
  };
  
  // Helper function to update a node's children
  const updateNodeChildren = (nodes, nodeId, children) => {
    return nodes.map(node => {
      if (node.id === nodeId) {
        return {
          ...node,
          children
        };
      }
      
      if (node.children && node.children.length > 0) {
        return {
          ...node,
          children: updateNodeChildren(node.children, nodeId, children)
        };
      }
      
      return node;
    });
  };
  
  // Modify the renderTree function to show loading indicators
  const renderTree = (nodes) => {
    return nodes.map((node) => (
      <FileTreeItem
        key={node.id}
        nodeId={node.id}
        type={node.type}
        label={
          <Box sx={{ display: 'flex', alignItems: 'center' }}>
            <Typography variant="body2">{node.name}</Typography>
            {loadingNodes[node.id] && (
              <CircularProgress size={16} sx={{ ml: 1 }} />
            )}
          </Box>
        }
        // Other props...
      >
        {node.children && node.children.length > 0 ? renderTree(node.children) : null}
      </FileTreeItem>
    ));
  };
  
  // Rest of the component...
};

This implementation fetches children only when a node is expanded, which can significantly improve performance for large folder structures.

Handling Multiple Selection

The TreeView component supports multiple selection, which can be useful for operations that involve multiple files or folders:

const FolderNavigation = () => {
  // Previous state variables...
  
  const handleSelect = (event, nodeIds) => {
    setSelected(nodeIds);
    
    // Count the number of files and folders selected
    let fileCount = 0;
    let folderCount = 0;
    
    for (const nodeId of nodeIds) {
      const node = findNodeById(folderData, nodeId);
      if (node) {
        if (node.type === 'file') {
          fileCount++;
        } else {
          folderCount++;
        }
      }
    }
    
    console.log(`Selected ${fileCount} files and ${folderCount} folders`);
    
    // You could update a status bar or enable/disable actions based on the selection
  };
  
  return (
    <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      {/* Previous components... */}
      
      <TreeView
        // Other props...
        multiSelect={true}
        selected={selected}
        onNodeSelect={handleSelect}
      >
        {renderTree(folderData)}
      </TreeView>
      
      <Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
        <Typography variant="caption">
          {selected.length > 0 
            ? `${selected.length} item(s) selected` 
            : 'No items selected'}
        </Typography>
      </Box>
    </Box>
  );
};

Best Practices and Common Issues

When working with MUI TreeView for folder navigation, here are some best practices and common issues to be aware of:

Performance Optimization

  1. Virtualization: For large trees, always use virtualization to render only visible nodes.
  2. Memoization: Use React's useMemo and useCallback to prevent unnecessary re-renders.
  3. Lazy Loading: Load child nodes only when needed to reduce initial load time.
// Example of memoization for the renderTree function
const renderTree = useCallback((nodes) => {
  return nodes.map((node) => (
    <FileTreeItem
      key={node.id}
      nodeId={node.id}
      type={node.type}
      label={node.name}
    >
      {node.children && node.children.length > 0 ? renderTree(node.children) : null}
    </FileTreeItem>
  ));
}, [expanded, selected]); // Only re-create when expanded or selected changes

Accessibility

  1. Keyboard Navigation: Ensure that all functionality is accessible via keyboard.
  2. ARIA Attributes: MUI TreeView includes ARIA attributes, but you may need to add more for custom functionality.
  3. Focus Management: Properly manage focus, especially after operations like deleting nodes.
// Example of enhancing accessibility
<TreeView
  aria-label="Folder navigation"
  defaultCollapseIcon={<FolderOpenIcon />}
  defaultExpandIcon={<FolderIcon />}
  // Other props...
>
  {renderTree(folderData)}
</TreeView>

Common Issues and Solutions

  1. Issue: TreeView doesn't update when data changes. Solution: Ensure you're properly updating the state and using the correct keys for each node.

  2. Issue: Drag and drop doesn't work correctly. Solution: Make sure you're handling the drop event properly and updating the data structure accordingly.

// Proper data structure update after drag and drop
const handleMoveNode = (sourceId, targetId) => {
  // Find the source node and its parent
  let sourceNode = null;
  let sourceParentId = null;
  
  const findSourceNodeAndParent = (nodes, parentId = null) => {
    for (const node of nodes) {
      if (node.id === sourceId) {
        sourceNode = { ...node };
        sourceParentId = parentId;
        return true;
      }
      
      if (node.children && node.children.length > 0) {
        if (findSourceNodeAndParent(node.children, node.id)) {
          return true;
        }
      }
    }
    
    return false;
  };
  
  findSourceNodeAndParent(folderData);
  
  if (!sourceNode || !sourceParentId) {
    console.error('Source node or parent not found');
    return;
  }
  
  // Remove the source node from its parent
  const removeFromParent = (nodes) => {
    return nodes.map(node => {
      if (node.id === sourceParentId) {
        return {
          ...node,
          children: node.children.filter(child => child.id !== sourceId)
        };
      }
      
      if (node.children && node.children.length > 0) {
        return {
          ...node,
          children: removeFromParent(node.children)
        };
      }
      
      return node;
    });
  };
  
  let updatedData = removeFromParent(folderData);
  
  // Add the source node to the target node
  const addToTarget = (nodes) => {
    return nodes.map(node => {
      if (node.id === targetId) {
        return {
          ...node,
          children: [...(node.children || []), sourceNode]
        };
      }
      
      if (node.children && node.children.length > 0) {
        return {
          ...node,
          children: addToTarget(node.children)
        };
      }
      
      return node;
    });
  };
  
  updatedData = addToTarget(updatedData);
  
  setFolderData(updatedData);
};
  1. Issue: Context menu appears in the wrong position. Solution: Make sure you're using the correct coordinates from the event and handling scroll position correctly.
// Proper context menu positioning
const handleContextMenu = (event, nodeId) => {
  event.preventDefault();
  event.stopPropagation();
  
  // Get the target element's position relative to the viewport
  const rect = event.currentTarget.getBoundingClientRect();
  
  setRightClickedNode(nodeId);
  setContextMenu({
    mouseX: event.clientX,
    mouseY: event.clientY,
    // Store the scroll position to adjust when the menu is closed
    scrollTop: document.documentElement.scrollTop,
    scrollLeft: document.documentElement.scrollLeft
  });
};

// When rendering the menu, adjust for any scrolling that happened since the menu was opened
<Menu
  open={contextMenu !== null}
  onClose={handleContextMenuClose}
  anchorReference="anchorPosition"
  anchorPosition={
    contextMenu !== null
      ? { 
          top: contextMenu.mouseY + (document.documentElement.scrollTop - contextMenu.scrollTop), 
          left: contextMenu.mouseX + (document.documentElement.scrollLeft - contextMenu.scrollLeft) 
        }
      : undefined
  }
>
  {/* Menu items */}
</Menu>

Wrapping Up

In this comprehensive guide, we've explored how to build a fully-featured folder navigation sidebar using MUI's TreeView component. We've covered everything from basic setup to advanced features like drag-and-drop, context menus, search, and virtualization for large data sets.

The MUI TreeView component provides a solid foundation for building hierarchical navigation interfaces, but as we've seen, it can be extended and customized to meet specific requirements. By combining it with other MUI components and React patterns, you can create a powerful and user-friendly folder navigation system for your applications.

Remember to consider performance, accessibility, and user experience when implementing your solution, especially when dealing with large data sets. With the techniques and best practices covered in this guide, you should be well-equipped to build a robust folder navigation sidebar for your React applications.