John
John

Reputation: 437

Dynamically adding to nested array

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

Answers (3)

T.J. Crowder
T.J. Crowder

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>


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.

Upvotes: 3

Hero Qu
Hero Qu

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:

  • ID of its parent (can be empty, if this is the root node)
  • list of its children IDs
  • boolean attribute showing if it is expanded or not
  • and finally, its own payload data (name and address in your case)

The "Tree" component should only hold the single property in this regard:

  • ID of its root node

Then when user clicks on some TreeNode, it would fire an event and the TreeNode would process it:

  • if it is not yet expanded, then it starts expanding which leads to fetch request.
  • if it is already "expanded" - then it depends on your requirement - it can be collapsed or not - whatever it proper for your use case.

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:

  1. 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"

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

Brother Woodrow
Brother Woodrow

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

Related Questions