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 ids.
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.