Render Tree View Recursively in React & GraphQL

If you want to render a multilevel Tree View Component in React dynamically not caring about how many levels there will be, so you will need to use Recursion.

If you need to know what recursion is, you should check out this link.

This article will be using the following packages:

Install Packages

npm install @mui/lab @mui/material @mui/icons-material @apollo/client graphql

Apollo Client Setup

In your index.js, you need to wrap your Components with ApolloProvider to be available in all you app.

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  createHttpLink,
} from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

// URI for graphql API on back4app
const httpLink = createHttpLink({
  uri: "https://parseapi.back4app.com/graphql",
});

const headersLink = setContext((_, { headers }) => {
  // return the headers to the context so httpLink can read them
  return {
    headers: {
      ...headers,
      // These keys are found when you create app on back4app
      "X-Parse-Application-Id": "<YOUR_APPLICATION_ID>",
      "X-Parse-Master-Key": "<YOUR_MASTER_KEY>",
      "X-Parse-REST-API-Key": "<YOUR_REST_API_KEY>",
    },
  };
});

const client = new ApolloClient({
  link: headersLink.concat(httpLink),
  cache: new InMemoryCache(),
});

ReactDOM.render(
  <React.StrictMode>
    <ApolloProvider client={client}>
        <App />
    </ApolloProvider>
  </React.StrictMode>,
  document.getElementById("root")
);

Prepare you GraphQL Queries

You now need to prepare the queries for the api you're using. I will be using the ContinentsCountriesCities Database on back4app which will provide proper nesting for this tutorial.

So the queries for the continents, countries and cities will be as follows (You can check the Graphql API playground in your app with docs about queries details)

import { gql } from "@apollo/client";

export const GET_CONTINENTS = gql`
  query allContinents {
    data: continentscountriescities_Continents {
      count
      results: edges {
        node {
          objectId
          name
          children: countries {
            count
          }
        }
      }
    }
  }
`;

export const GET_COUNTRIES = gql`
  query allCountries($continentId: ID) {
    data: continentscountriescities_Countries(
      where: { continent: { have: { objectId: { equalTo: $continentId } } } }
    ) {
      count
      results: edges {
        node {
          objectId
          name
          children: cities {
            count
          }
        }
      }
    }
  }
`;

export const GET_CITIES = gql`
  query allCities($countryId: ID) {
    data: continentscountriescities_Cities(
      where: { country: { have: { objectId: { equalTo: $countryId } } } }
    ) {
      count
      results: edges {
        node {
          objectId
          name
        }
      }
    }
  }
`;

The gql String literal provided by apollo client will help in the validation of your query against the main schema.

Tree View UI

We can use the basic tree view in material ui, but we need to give it custom content to handle the fetching of data on TreeItem click.

So our CustomTreeItem would look something like this.

import React, { useEffect } from "react";
import clsx from "clsx";
import { CircularProgress, Typography } from "@mui/material";
import TreeItem, { useTreeItem } from "@mui/lab/TreeItem";
import { useLazyQuery } from "@apollo/client";
import { GET_COUNTRIES, GET_CITIES } from "../../utils/Queries";

const CustomContent = React.forwardRef(function CustomContent(
  props,
  ref
) {
  // TreeItemContentProps + typename + appendNewData props
  const {
    classes,
    className,
    label,
    nodeId,
    icon: iconProp,
    expansionIcon,
    displayIcon,
    typename,
    appendNewData,
  } = props;

   // Extract last part from Typename key of node from graphql
  // Ex: Continentscountriescities_Country => Country
  const type: string = typename?.split("_")[1] || "";

  let lazyQueryParams = {};

  // Add lazyQueryParams according to type of node
  switch (type) {
    case "Continent":
      lazyQueryParams = {
        query: GET_COUNTRIES,
        variableName: "continentId",
      };
      break;
    case "Country":
      lazyQueryParams = {
        query: GET_CITIES,
        variableName: "countryId",
      };
      break;
    default:
      lazyQueryParams = {
        query: GET_COUNTRIES,
        variableName: "continentId",
      };
      break;
  }

  // Lazy query for getting children of this node
  const [getChildren, { loading, data }] = useLazyQuery(
    lazyQueryParams?.query,
    {
      variables: { [lazyQueryParams?.variableName]: nodeId },
    }
  );

  const { disabled, expanded, selected, focused, handleExpansion } =
    useTreeItem(nodeId);

  const icon = iconProp || expansionIcon || displayIcon;

  // Append new children to node
  useEffect(() => {
    if (data?.data?.results && appendNewData) {
      appendNewData(nodeId, data.data?.results || []);
    }
  }, [data]);

  const handleExpansionClick = (event) => {
    // Fetch data only once
    if (!data) {
      getChildren();
    }

    handleExpansion(event);
  };

  return (
    <div
      className={clsx(className, classes.root, {
        [classes.expanded]: expanded,
        [classes.selected]: selected,
        [classes.focused]: focused,
        [classes.disabled]: disabled,
      })}
      onClick={handleExpansionClick}
      ref={ref}
    >
      <div className={classes.iconContainer}>{icon}</div>
      <Typography component="div" className={classes.label}>
        {label}
      </Typography>

    </div>
  );
});

const CustomTreeItem = (props) => {
  return (
    <TreeItem
      ContentComponent={CustomContent}
      // These props will be sent from the parent
      ContentProps={
        { typename: props.typename, appendNewData: props.appendNewData } as any
      }
      {...props}
    />
  );
};

export default CustomTreeItem;

It uses the queries we made above and with the useLazyQuery hook from apollo client, we have a method getChildren() (or any other name) to be called wherever and whenever we need in the component. So we are calling this method on the handleExpansionClick method and checking if the data is not already fetched.

And we are switching the type of the node we are rendering to decide which query to call in the hierarchy.

Now for the parent component of rendering the tree, It will render the continents data by default on first render and it will have the recursive function that will append the new children data fetched into the main array. For this to happen, all of our queries should have a fixed structure as above.

The parent component will look something like this

import React, { useEffect, useState } from "react";
import { useQuery } from "@apollo/client";
import TreeView from "@mui/lab/TreeView";
import { CircularProgress } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { GET_CONTINENTS } from "../../utils/Queries";
import CustomTreeItem from "../CustomTreeItem";
import { getModifiedData } from "../../utils/Shared";

const Tree = () => {
  // Get all continents on first render
  const { loading, data: allContinents } = useQuery(GET_CONTINENTS);
  // Data to render all tree items from
  const [treeItemsData, setTreeItemsData] = useState([]);

  // Set treeItemsData with continents recieved
  useEffect(() => {
    if (allContinents?.data?.results) {
      setTreeItemsData(allContinents?.data?.results);
    }
  }, [allContinents]);

  // Add new data in its correct place in treeItemsData array
  const appendNewData = (nodeId, data) => {
    const treeItemsDataClone = JSON.parse(JSON.stringify(treeItemsData)); // Deep Copy

    // getModifiedData is the recursive function (will be shown below alone)
    const newData = getModifiedData(treeItemsDataClone, nodeId, data);

    setTreeItemsData(newData); // set the rendered array with the modified array
  };

  // Render children items recursively
  const renderChild = (node) => {
    return (
      <CustomTreeItem
        key={node.objectId}
        classes={{ content: styles.treeItemContent }}
        typename={node.__typename}
        appendNewData={appendNewData}
        nodeId={node.objectId}
        label={node.name}
      >
        {/* If children is an object with a count key > 0, render a dummy treeItem to show expand icon on parent node */}
        {node.children &&
          (node.children.count > 0 ? (
            <CustomTreeItem nodeId="1" />
          ) : (
            node.children.length &&
            node.children.map((child: any) => renderChild(child.node)) // Recursively rendering children if array is found
          ))}
      </CustomTreeItem>
    );
  };

  // Show a loader until query resolve
  if (loading) return <CircularProgress />;
  else if (allContinents)
    return (
      <TreeView
        defaultCollapseIcon={<ExpandMoreIcon />}
        defaultExpandIcon={<ChevronRightIcon />}
        sx={{ height: 240, flexGrow: 1, maxWidth: 400, overflowY: "auto" }}
      >
        {treeItemsData.map((continent: any) => {
          return renderChild(continent.node);
        })}
      </TreeView>
    );
  else return <></>;
};

export default Tree;

Now for the recursive function, It takes the following parameters: the original array, the node id to find and insert new data in it and the new data to insert.

This function was found here but was customized for our specific requirements.

/*
    Original Answer: https://stackoverflow.com/a/15524326
    @Description: Searches for a specific object in nested objects or arrays according to "objectId" key
    @Params: originalData => The original array or object to search in
             nodeId => the id to compare to objectId field
             dataToBeAdded => new data to be added ad children to found node
    @Returns: Modified original data
  */
export const getModifiedData = (
  originalData: any,
  nodeId: string,
  dataToBeAdded: any
) => {
  let result = null;
  const originalDataCopy = JSON.parse(JSON.stringify(originalData)); // Deep copy

  if (originalData instanceof Array) {
    for (let i = 0; i < originalDataCopy.length; i++) {
      result = getModifiedData(originalDataCopy[i], nodeId, dataToBeAdded);

      if (result) {
        originalDataCopy[i] = result;
      }
    }
  } else {
    for (let prop in originalDataCopy) {
      if (prop === "objectId") {
        if (originalDataCopy[prop] === nodeId) {
          originalDataCopy.children = dataToBeAdded;
          return originalDataCopy;
        }
      }

      if (
        originalDataCopy[prop] instanceof Object ||
        originalDataCopy[prop] instanceof Array
      ) {
        result = getModifiedData(originalDataCopy[prop], nodeId, dataToBeAdded);
        if (result) {
          originalDataCopy[prop] = result;
          break;
        }
      }
    }
  }

  return originalDataCopy;
};

It will return the modified array to be set in the state easily.

Sorry for the long code snippets but it is somewhat complex and I wanted to expose all of the code. Working with back4app database and graphql in react wasn't that clear in the docs so I wanted to provide these steps as well.

Hope this article will help someone implementing a similar feature.

17