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>
Original answer
...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.