1

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?

John
  • 447
  • 1
  • 8
  • 22
  • My first idea would be, that every node has it's own state. Do you need the data in your App, or can it reside in the nodes individually? – colburton Dec 07 '19 at 11:12
  • I'm not sure I understand your question. Can you rephrase? – John Dec 07 '19 at 11:23

3 Answers3

2

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.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • Thank you for your answer. What do you mean by path of id's? – John Dec 07 '19 at 12:20
  • 1
    @John - I kept meaning to define that somewhere. :-) I mean an array with the ID of each node leading to the target node. For `Development` that would be `["1234", "3323"]`. But you don't *have* to do that, you can find the path later just by the `id`, it just requires a search. – T.J. Crowder Dec 07 '19 at 12:35
  • Thanks again :) As I'm new to react, just be sure, the `TreeNode` you are refering to is the name of the component? – John Dec 07 '19 at 12:53
  • 1
    @John - A component you'd define in your code, yes. Also something I should have mentioned. :-) – T.J. Crowder Dec 07 '19 at 12:54
  • @John - Actually, I'm wondering if this entire answer is an anti-pattern. The DOM/React component tree is *already* a tree. It seems like the `TreeNode` should handle updating its children directly, and your `TreeView` component should essentially just be a container for the root node/nodes. – T.J. Crowder Dec 08 '19 at 11:44
  • Now it is getting a bit confusing :) So I need a `TreeView` component which acts as a container, fetching data from server. I also need a `TreeNode` component responsible for handling/maintaing the `treeRoot` state from your example. Am I understanding this correctly? – John Dec 08 '19 at 12:15
  • @John - Actually I think `TreeNode` can, itself, be the root. But there are a lot of ways to spin this. I've updated the answer (well, added a new answer above the old one) with a live example. – T.J. Crowder Dec 08 '19 at 12:29
  • Thank you for your answer. Makes more sense now. Would the code work if I just substitued the fake url with the real url? – John Dec 12 '19 at 20:49
  • @John - I assume so. You may need to tweak a couple of other things, but... – T.J. Crowder Dec 13 '19 at 07:15
1

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.

Hero Qu
  • 911
  • 9
  • 10
0

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.

Brother Woodrow
  • 6,092
  • 3
  • 18
  • 20