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:
Material UI => Tree View Component UI
GraphQL and Apollo Client => Fetch data from back4app database
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.
Top comments (0)