Reputation: 437
I'm trying to build a treeview component in react where data for the tree is fetched based on the nodes expanded by the user.
The idea
When the first node is expanded a HTTP request is sent to a service which returns all of the children of that node. When another node is expanded the children of that node is fetched etc. I have a very large dataset so I prefer this method of fetching instead of getting all data at startup of the website.
Problem
This could be an example of data returned by the service when the division node is expanded
{
"division": {
"id": "1234",
"name": "string",
"address": "string",
},
"children": [
{
"id": "3321",
"parentId": "1234",
"name": "Marketing",
"address": "homestreet",
},
{
"id": "3323",
"parentId": "1234",
"name": "Development",
"address": "homestreet",
}
]
}
I can then eg. expand the Development node and get the children for this node.
I'm thinking i need some kind of nested array, but I'm unsure on how to handle maintaining the correct order of the array so I get the correct tree hierarchy. Becuase the user can choose to expand any node. Can anybody help with this?
Upvotes: 1
Views: 3726
Reputation: 1074038
I think my original answer (below, since it had been accepted) was an example of an anti-pattern or not thinking the problem through.
The React component tree itself is...a tree. So I think you're good if you just have a TreeNode
component or similar that knows how to load its children, something like this:
function TreeNode({id, name, parentId, address}) {
// The nodes, or `null` if we don't have them yet
const [childNodes, setChildNodes] = useState(null);
// Flag for whether this node is expanded
const [expanded, setExpanded] = useState(false);
// Flag for whether we're fetching child nodes
const [fetching, setFetching] = useState(false);
// Flag for whether child node fetch failed
const [failed, setFailed] = useState(false);
// Toggle our display of child nodes
const toggleExpanded = useCallback(
() => {
setExpanded(!expanded);
if (!expanded && !childNodes && !fetching) {
setFailed(false);
setFetching(true);
fetchChildNodes(id)
.then(nodes => setChildNodes(nodes.map(node => <TreeNode {...node} />)))
.catch(error => setFailed(true))
.finally(() => setFetching(false));
}
},
[expanded, childNodes, fetching]
);
return (
<div class="treenode">
<input type="button" onClick={toggleExpanded} value={expanded ? "-" : "+"} style={{width: "28px"}}/>
<span style={{width: "4px", display: "inline-block"}}></span>{name}
{failed && expanded && <div className="failed">Error fetching child nodes</div>}
{fetching && <div className="loading">Loading...</div>}
{!failed && !fetching && expanded && childNodes && (childNodes.length > 0 ? childNodes : <div class="none">(none)</div>)}
</div>
);
}
Live Example with fake ajax and data:
const {useState, useCallback} = React;
const fakeData = {
"1234": {
id: "1234",
name: "Division",
address: "string",
childNodes: ["3321", "3323"]
},
"3321": {
id: "3321",
parentId: "1234",
name: "Marketing",
address: "homestreet",
childNodes: ["3301", "3302"]
},
"3301": {
id: "3301",
parentId: "3321",
name: "Promotion",
address: "homestreet",
childNodes: []
},
"3302": {
id: "3302",
parentId: "3321",
name: "Advertising",
address: "homestreet",
childNodes: ["3311", "3312"]
},
"3311": {
id: "3311",
parentId: "3302",
name: "Television",
address: "homestreet",
childNodes: []
},
"3312": {
id: "3312",
parentId: "3302",
name: "Social Media",
address: "homestreet",
childNodes: []
},
"3323": {
id: "3323",
parentId: "1234",
name: "Development",
address: "homestreet",
childNodes: ["3001", "3002", "3003", "3004"]
},
"3001": {
id: "3001",
parentId: "3323",
name: "Research",
address: "homestreet",
childNodes: []
},
"3002": {
id: "3002",
parentId: "3323",
name: "Design",
address: "homestreet",
childNodes: []
},
"3003": {
id: "3003",
parentId: "3323",
name: "Coding",
address: "homestreet",
childNodes: []
},
"3004": {
id: "3004",
parentId: "3323",
name: "Testing",
address: "homestreet",
childNodes: []
},
};
function fakeAjax(url) {
return new Promise((resolve, reject) => {
const match = /\d+/.exec(url);
if (!match) {
reject();
return;
}
const [id] = match;
setTimeout(() => {
if (Math.random() < 0.1) {
reject(new Error("ajax failed"));
} else {
resolve(fakeData[id].childNodes.map(childId => fakeData[childId]));
}
}, Math.random() * 400);
});
}
function fetchChildNodes(id) {
return fakeAjax(`/get/childNodes/${id}`);
}
function TreeNode({id, name, parentId, address}) {
// The nodes, or `null` if we don't have them yet
const [childNodes, setChildNodes] = useState(null);
// Flag for whether this node is expanded
const [expanded, setExpanded] = useState(false);
// Flag for whether we're fetching child nodes
const [fetching, setFetching] = useState(false);
// Flag for whether child node fetch failed
const [failed, setFailed] = useState(false);
// Toggle our display of child nodes
const toggleExpanded = useCallback(
() => {
setExpanded(!expanded);
if (!expanded && !childNodes && !fetching) {
setFailed(false);
setFetching(true);
fetchChildNodes(id)
.then(nodes => setChildNodes(nodes.map(node => <TreeNode {...node} />)))
.catch(error => setFailed(true))
.finally(() => setFetching(false));
}
},
[expanded, childNodes, fetching]
);
return (
<div class="treenode">
<input type="button" onClick={toggleExpanded} value={expanded ? "-" : "+"} style={{width: "28px"}}/>
<span style={{width: "4px", display: "inline-block"}}></span>{name}
{failed && expanded && <div className="failed">Error fetching child nodes</div>}
{fetching && <div className="loading">Loading...</div>}
{!failed && !fetching && expanded && childNodes && (childNodes.length > 0 ? childNodes : <div class="none">(none)</div>)}
</div>
);
}
ReactDOM.render(
<TreeNode {...fakeData["1234"]} />,
document.getElementById("root")
);
.treenode > .treenode,
.treenode > .none {
margin-left: 32px;
}
.failed {
color: #d00;
}
.none {
font-style: italics;
color: #aaa;
}
<div>This includes a 1 in 10 chance of any "ajax" operation failing, so that can be tested. Just collapse and expand again to re-try</div>
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.10.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.10.2/umd/react-dom.production.min.js"></script>
...that I don't think much of anymore. :-)
I'm thinking i need some kind of nested array, but I'm unsure on how to handle maintaining the correct order of the array so I get the correct tree hierarchy.
Each node should probably be largely as you've shown it (but as a component), but with children
being a property of the node object itself, not in a separate object in an array. I'd also use Map
rather than an array, because Map
provides both order (like an array) and keyed retrieval (by id
). So your structure with an expanded division
node would look like this (expressed in JavaScript, not JSON):
this.state.treeRoot = new Map([
[
"1234",
<TreeNode
id="1234"
name="Division"
address="string"
children={new Map([
[
"3321",
<TreeNode
id="3321"
parentId="1234"
name="Marketing"
address="homestreet"
children={null}
/>
],
[
"3323",
<TreeNode
id="3323"
parentId="1234"
name="Development"
address="homestreet"
children={null}
/>
]
])
}
]
]);
There I'm using null
as a flag value to say "we haven't tried to expand the children yet". An empty Map
would be "we've expanded the children but there aren't any." :-) (You could use undefined
instead of null
, or even use the absense of a children
property, but I prefer keeping the shape of the nodes consistent [helps the JavaScript engine optimize] and to use null
where I'm later going to have an object.)
Becuase the user can choose to expand any node.
You've shown nodes with unique id
values, so that shouldn't be a problem. Ensure that the id
is passed to whatever handler handles expanding the nodes — or better yet, a path of id
s.
Since state must be immutable in React, you'll need to handle cloning every container leading up to the node that you're modifying by updating its children
property.
For instance, here's a sketch (just a sketch!) of a function that receives a path of id
values:
async function retrieveChildren(path) {
const children = await doAjax(`/path/to/${path[path.length - 1].id}`);
await new Promise((resolve, reject) => { // Ugh, but setState callback is non-promise, so...
this.setState(({treeRoot}) => {
treeRoot = new Map(treeRoot);
let node = treeRoot;
for (const id of path) {
node = node.children && node.children.get(id);
if (!node) {
reject(new Error(`No node found for path ${path}`));
return;
}
node = {...node, children: node.children === null ? null : new Map(node.children)};
}
node.children = new Map(children.map(child => [child.id, <TreeNode {...child} parent={node} />]);
return {treeRoot};
}, resolve);
});
}
If using hooks, it would be very similar:
const [treeRoot, setTreeRoot] = useState(new Map());
// ...
async function retrieveChildren(path) {
const children = await doAjax(`/path/to/${path[path.length - 1].id}`);
await new Promise((resolve, reject) => { // Ugh, but setState callback is non-promise, so...
setTreeRoot(treeRoot => {
treeRoot = new Map(treeRoot);
let node = treeRoot;
for (const id of path) {
node = node.children && node.children.get(id);
if (!node) {
reject(new Error(`No node found for path ${path}`));
return;
}
node = {...node, children: node.children === null ? null : new Map(node.children)};
}
node.children = new Map(children.map(child => [child.id, <TreeNode {...child} parent={node} />]);
return treeRoot;
}, resolve);
});
}
That assumes children
comes back as an array of child nodes.
Upvotes: 3
Reputation: 951
I would make each node of the tree to be a separate component, called "TreeNode" or something, then what it has to hold is:
The "Tree" component should only hold the single property in this regard:
Then when user clicks on some TreeNode, it would fire an event and the TreeNode would process it:
This setup means you only need 2 types of components and go without any nested arrays.
Each TreeNode component is responsible on how to render its own payload data and then, after that, all its children, so it has to know how to apply visual styling etc. But this is as usual with any visible react component.
So, the point is each node is responsible for only one level deeper, its direct children, while stay agnostic in relation to what is going on with grandchildren and so on.
UPDATE:
One little caveat: each TreeNode has to stop the mouse click from bubbling upwards. There was a dedicated question on this here on SO: How to stop even propagation in react
UPDATE 2
Also, there are two ways to hold fetched data for each node:
Hold all children data, including payloads (not just IDs), inside parent component. In this case "list of its children IDs" above would go "list of its children data: both IDs and payloads"
All fetched data is being held 'globally', e.g. inside Tree component. Then each TreeNode while rendering children has to refer to that source of knowledge and retrieve data by IDs.
The Tree component in such a case can use JS Map object ( ID -> NodeData ).
In this second setup each TreeNode should also keep reference to either the Tree, or the directly map, if one doesn't use Tree component at all. Something like this.
Upvotes: 1
Reputation: 6372
I'm not particularly familiar with React, but generally speaking, each node would have to have a children
element, which would be an empty array. I assume that when a user expands a node, you know which node it is they are expanding (the node object is probably available to you when the user clicks the expand button), so it is then trivial to get the children and replace the empty children
array with the data from the server.
Upvotes: 0