Recursive component in React

Shubham BankarShubham Bankar
5 min read

Building a Recursive File Tree Component in React

In this blog, I will walk you through creating a recursive file tree component in React. This component can be particularly useful for applications that need to display hierarchical data structures, such as file directories. We will create two main components: Source and File.

Components Overview

  1. Source: The main component responsible for managing the root nodes.

  2. File: A recursive component representing individual file/directory nodes, displaying them in a hierarchical structure.

Both components will listen to Electron API events to fetch data and update the UI.

Let's Dive In

Here is the complete code for the Source and File components:

import React, { useEffect, useState, useCallback } from 'react';
import { MdExpandLess, MdExpandMore } from 'react-icons/md';
import log from 'electron-log/renderer';

// Interface representing a file/directory node structure
interface FileNode {
  id: string;
  name: string;
  children?: FileNode[];
  isDirectory: boolean;
}

/**
 * File Component
 * ---------------
 * Recursive component to represent file/directory nodes in a hierarchical tree.
 * - Listens to events to load subdirectory contents dynamically.
 * - Handles toggle visibility of subdirectories.
 */
const File: React.FC<FileNode> = ({ id, name, children, isDirectory }: FileNode) => {
  const [showChildren, setShowChildren] = useState(false); // Toggle subdirectory visibility
  const [childNodes, setChildNodes] = useState(children || []); // Store subdirectory nodes

  // Handle node click event: toggle subdirectory visibility or fetch contents
  const handleClick = useCallback(() => {
    setShowChildren((prev) => !prev);
    log.debug('handleClick for', id);

    if (isDirectory) {
      // Fetch contents only if it is a directory and not already loaded
      if (!children || childNodes?.length === 0) {
        log.debug('sending get-directory-contents for', id);
        window.api.send('get-directory-contents', id);
      }
    } else {
      // Fetch file contents
      log.debug('sending get-file-contents for', id);
      window.api.send('get-file-contents', id);
    }
  }, [id, childNodes?.length, isDirectory, children]);

  // Listen for directory contents and update nodes if relevant
  useEffect(() => {
    const updateChildNodes = (contents: { parentId: string; contents: FileNode[] }) => {
      if (id === contents.parentId) {
        setChildNodes(contents.contents);
      }
    };
    window.api.receive('directory-contents', updateChildNodes);

    // Cleanup listener on component unmount
    return () => {
      window.api.removeAllListeners('directory-contents');
    };
  }, [id]);

  // Render the file node with a recursive subdirectory expansion
  return (
    <div>
      <div onClick={handleClick} className="cursor-pointer flex flex-row">
        {/* Toggle expand/collapse icon for directories */}
        {isDirectory ? (showChildren ? <MdExpandLess /> : <MdExpandMore />) : null}
        <span>
          <h4 className={`${showChildren ? 'font-bold' : 'font-normal'}`}>{name}</h4>
        </span>
      </div>
      {showChildren && (
        <div className="ml-6 mr-6 border-l pl-4">
          {childNodes?.map((node) => (
            <File key={node.id} {...node} />
          ))}
        </div>
      )}
    </div>
  );
};

/**
 * Source Component
 * -----------------
 * Main component that manages and displays the root nodes for the file tree structure.
 * - Receives and processes the directory contents event from the main process.
 */
const Source = (): JSX.Element => {
  const [nodes, setNodes] = useState<FileNode[]>([]);

  // Listen for root directory contents and update state
  useEffect(() => {
    const updateSourceNodes = (contents: FileNode[]) => {
      console.log('Received source-directory-contents', contents);
      setNodes(contents);
    };
    window.api.receive('source-directory-contents', updateSourceNodes);

    // Cleanup listener on component unmount
    return () => {
      window.api.removeAllListeners('source-directory-contents');
    };
  }, []);

  // Render the root nodes with the File component
  return (
    <div className="inline-block bg-gray-700 flex-1 font-normal text-white text-[16px] leading-[24px]">
      {nodes.map((node) => (
        <div className="ml-4 mr-6" key={node.id}>
          <File id={node.id} name={node.name} isDirectory={node.isDirectory} />
        </div>
      ))}
    </div>
  );
};

export default Source;

How It Works

File Component

The File component is recursive, meaning it can call itself to display nested directories. Here's a breakdown of its key parts:

  • State Management: We use the useState hook to manage the visibility of child nodes and store the subdirectory nodes.

  • Event Handling: The handleClick function toggles the visibility of child nodes. If the node is a directory and its contents are not already loaded, it sends a request to load them.

  • Side Effects: We use the useEffect hook to listen for directory contents updates. When the component mounts, it sets up a listener for the 'directory-contents' event and cleans it up when the component unmounts.

Source Component

The Source component manages and displays the root nodes for the file tree structure. Here's a breakdown of its key parts:

  • State Management: We use the useState hook to store the root nodes.

  • Event Handling: We use the useEffect hook to listen for the 'source-directory-contents' event and update the root nodes accordingly.

Handling Event Listeners and Memory Leaks

While developing and testing, you might encounter issues related to memory leaks or too many event listeners being added. Here are some tips to handle these issues:

  1. Remove Event Listeners: Always clean up your event listeners in the useEffect cleanup function to avoid memory leaks. This is crucial, especially in recursive components where multiple instances might be created and destroyed.

     jsxCopy codeExplainuseEffect(() => {
       const updateChildNodes = (contents: { parentId: string; contents: FileNode[] }) => {
         if (id === contents.parentId) {
           setChildNodes(contents.contents);
         }
       };
       window.api.receive('directory-contents', updateChildNodes);
    
       // Cleanup listener on component unmount
       return () => {
         window.api.removeAllListeners('directory-contents');
       };
     }, [id]);
    
  2. Check for Event Listener Count: If you find that the number of event listeners exceeds a certain limit (e.g., more than 18), it might indicate a memory leak. Make sure that each listener is added and removed correctly.

  3. Debugging Memory Leaks: Use tools like Chrome DevTools to monitor memory usage and identify leaks. Check for detached DOM nodes and uncollected garbage that might indicate a memory leak.

    • In DevTools, go to the "Memory" tab, take a heap snapshot, and analyze the memory allocation.

    • Look for any large arrays or objects that shouldn't be there and trace back to find the cause.

Conclusion

By combining React's state management with recursive components, we can create a dynamic and interactive file tree structure. This approach is not limited to file systems and can be adapted to any hierarchical data structure.

Feel free to use and modify this code to fit your needs. Happy coding!

Additional Tips

  • Styling: You can customize the styling using CSS or libraries like TailwindCSS.

  • Performance: For large directories, consider optimizing performance by lazy-loading nodes or using virtualization techniques.

I hope this blog helps you understand how to build recursive components in React and handle potential memory leak issues. If you have any questions or feedback, feel free to leave a comment below.

0
Subscribe to my newsletter

Read articles from Shubham Bankar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shubham Bankar
Shubham Bankar