7

I'm having some trouble updating nested components in my tree structure. I have created the following minimal example to illustrate the problem: Codesandbox.io

For completeness sake, this is the component that's being nested:

class Node extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      selected: props.selected
    };
    this.toggleSelected = this.toggleSelected.bind(this);
  }

  toggleSelected() {
    this.setState({
      selected: !this.state.selected
    });
  }

  render() {
    return (
      <>
        <div onClick={this.toggleSelected}>
          Selected: {this.state.selected ? "T" : "F"}, Depth: {this.props.depth}
        </div>

        {this.props.depth < 5 && (
          <Node selected={this.state.selected} depth={this.props.depth + 1} />
        )}
      </>
    );
  }
}

Nodes in the tree should be selectable on click and I'd like to toggle the selected state in all children (recursively) aswell. I thought I could do this by passing this.state.selected via props to the child/children, unfortunately this doesn't seem to work.

The children get re-rendered, however using their old state (understandibly, as they're not being re-initialized via the constructor). What would be the correct way to handle this?

I've tried passing the key prop to the nodes aswell to help react distinguish the elements, to no avail.

Edit: Here are a few examples of desired behaviour:

Consider this tree:

[ ] Foo
    [ ] Foo A
        [ ] Foo A1
        [ ] Foo A2
    [ ] Foo B
        [ ] Foo B1
        [ ] Foo B2

Expected result when checking "Foo"-Node:

[x] Foo
    [x] Foo A
        [x] Foo A1
        [x] Foo A2
    [x] Foo B
        [x] Foo B1
        [x] Foo B2

Expected result when checking "Foo A"-Node:

[ ] Foo
    [x] Foo A
        [x] Foo A1
        [x] Foo A2
    [ ] Foo B
        [ ] Foo B1
        [ ] Foo B2

Any tips / hints in the right direction are appreciated.

ccKep
  • 5,786
  • 19
  • 31
  • 1
    Only one Node should be selected? If so, why does every Node have a selected state? – Alvaro Nov 18 '19 at 16:52
  • @Alvaro It's a tree (think of it like nested folders). If I selected a node at depth n I want all it's children to be selected aswell. But their can be multiple selected nodes at every level... think of it like selecting folders `/var/www/foo` and `/var/www/bar` but not `/var/www/foobar`. Every subfolder of `/var/www/foo` and `/var/www/bar` should be selected aswell in this case. – ccKep Nov 18 '19 at 16:59
  • 2
    I think you want to use [getDerivedStateFromProps](https://reactjs.org/docs/react-component.html#static-getderivedstatefromprops). Set the selected state from props whenever it changes. – HMR Nov 18 '19 at 17:00
  • Maybe this image illustrates the problem a bit more: [Link](https://imgur.com/a/nnVUatT). It's a tree and the node with the green check is currently selected. I want the children of that node to toggle with it's parent. – ccKep Nov 18 '19 at 17:01
  • Does this answer your question? [How to update parent's state in React?](https://stackoverflow.com/questions/35537229/how-to-update-parents-state-in-react) – Emile Bergeron Nov 18 '19 at 17:01
  • @EmileBergeron I don't want to update the parent, I want the children to update when I change the parent. – ccKep Nov 18 '19 at 17:02
  • It's quite an abstract duplicate target to understand considering the goal here, but it's what you want. You'd set the state only on the root parent, with some kind of node ID, which would be passed down to the child nodes. Once the active node ID is reached, it sets the active props of its children accordingly. – Emile Bergeron Nov 18 '19 at 17:05
  • @HMR I actually tried that aswell when I looked at the react component lifecycle, however I couldn't get it to work. [This](https://codesandbox.io/s/stoic-dewdney-bch4c?fontsize=14&hidenavigation=1&theme=dark) is my attempt at it at codesandbox.io - do you know how I could solve this? – ccKep Nov 18 '19 at 17:06
  • @ccKep You are correct, is it possible to not use local state at all and have a data structure representing your tree in the root of the nodes? – HMR Nov 18 '19 at 17:18
  • 1
    @HMR that would've been the next thing I was going to try, passing along something like an `onSelect` handler. For now, this question is solved. Thank you for your help aswell! – ccKep Nov 18 '19 at 17:26
  • I wrote an answer to demonstrate how the dupe target is relevant. – Emile Bergeron Nov 18 '19 at 17:58

2 Answers2

3

You should use getDerivedStateFromProps like this:

    constructor(props) {
       super(props);
       this.state = {
         selected: props.selected,
         propsSelected: props.selected
       };
       this.toggleSelected = this.toggleSelected.bind(this);
    }



    static getDerivedStateFromProps(props, state) {
        if (props.selected !== state.propsSelected)
          return {
            selected: props.selected,
            propsSelected: props.selected
          };
      }

We always store the prevProp in state. Whenever we encounter a change in the props that are stored in the state and the props coming from the parent, we update the state part (selected) being controlled both from parent and the component itself and we also preserve the props at that point, in state for future diffing.

Usually a component which can be controlled from both the parent and itself will involve a logic of this sort. An input component found in most react component libraries is an ideal example.

sudheer singh
  • 862
  • 1
  • 9
  • 23
  • Doesn't work for me. Am I asking for something unreasonable here? (Considering the examples I added to the original question now) – ccKep Nov 18 '19 at 17:18
  • 1
    I did the change in the sandbox and it works. https://codesandbox.io/s/wizardly-sammet-p61yw – sudheer singh Nov 18 '19 at 17:18
  • 1
    Wow, I need to compare our differences then. This is exactly the behaviour I wanted to have - thank you very much! Although I don't understand dragging the `propsSelected` attribute along for now. But I'll eventually get behind that. – ccKep Nov 18 '19 at 17:19
  • @HMR thats the reason we store props in state so that we can do the diffing. notice code in constructor. – sudheer singh Nov 18 '19 at 17:20
  • Edit: Upon looking closer, it's quite obvious what the purpose of `propsSelected` is... thanks again! – ccKep Nov 18 '19 at 17:21
  • @ccKep Happy to help. I'll update the answer to describe a simple pattern when the problem involves controlled and uncontrolled logic at the same time. – sudheer singh Nov 18 '19 at 17:21
0

Disclaimer: Instead of fixing the code in OP's question, I'm demonstrating how to render a tree and manage its state in a React app.


It would be way easier to create a data-driven tree of stateless Node components and leave the state management to the root component.

Each node receives the selectedId and compares it with their own id to know if they (and their children) should be rendered as active.

They also receive a setSelected callback to notify the parent that they were clicked on.

Being driven by the data (their unique ID, the selected state, etc) from the parent, it becomes more generic and leaves a lot of place to augment the tree with new features. It's also decoupled, easier to test, etc.

// Stateless node
const Node = ({ depth, setSelected, selected, id, nodes, selectedId }) => {
  const isSelected = selected || selectedId === id;
  return (
    <React.Fragment>
      {id && (
        <div style={{ marginLeft: `${depth * 15}px` }}>
          <input
            type="checkbox"
            onChange={() => setSelected(id)}
            checked={isSelected}
          />
          {id}
        </div>
      )}

      {depth < 5 &&
        nodes.map(nodeProps => (
          <Node
            key={nodeProps.id}
            {...nodeProps}
            selected={isSelected}
            selectedId={selectedId}
            setSelected={setSelected}
            depth={depth + 1}
          />
        ))}
    </React.Fragment>
  );
};

Node.defaultProps = {
  selected: false,
  nodes: [],
  depth: 0
};

// Parent keeps the selected Id
class App extends React.Component {
  state = {
    selected: null
  };

  setSelected = id => {
    this.setState(({ selected }) => ({
      selected: selected !== id ? id : null
    }));
  };

  render() {
    return (
      <div className="App">
        <Node
          nodes={this.props.tree}
          selectedId={this.state.selected}
          setSelected={this.setSelected}
        />
      </div>
    );
  }
}

const treeData = [
  {
    id: "Foo",
    nodes: [
      {
        id: "Foo A",
        nodes: [{ id: "Foo A1" }, { id: "Foo A2" }]
      },
      {
        id: "Foo B",
        nodes: [{ id: "Foo B1" }, { id: "Foo B2" }]
      }
    ]
  }
];

ReactDOM.render(
  <App tree={treeData} />,
  document.getElementById("root")
);
input {
  cursor: pointer;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.6/umd/react-dom.production.min.js"></script>
<div id="root"></div>

This is the same concept as the duplicate target: How to update parent's state in React? but with a personalized example to help understand the relationship between the current use-case and the concept behind.

Emile Bergeron
  • 17,074
  • 5
  • 83
  • 129