reactjsreact-memo

React deeply nested components preventing re-rendering


For this question, I'm trying to validate whether my understanding is correct here. Imagine a hackernews comment section where there are deeply nested / threaded comments -- i.e. a tree like structure -- where more comments can be loaded at any level / depth of the tree. I am trying to prevent as much re-rendering as possible, so I'm using memo.

What I can't tell is whether every time I load 10 new top level components, will the existing top level comments re-render or will memo prevent them from re-rendering?

Let's say I load "replies" to an existing comment that is at depth 4. With memo, I'm hoping that only the top level comment that contains this new the new replies and the rest of the components won't re-render. What I'm scared is the act of setting a new useState will cause the entire tree to re-render after adding new replies.

Is the way I'm structuring this tree where all the data for the page is stored in a state that is constantly updated at multiple levels of the tree able to prevent re-rendering for all TreeNode components?

import React, { useState, memo } from "react";

function getChildrenLengthSum(tree) {
  // Base case: if there's no valid tree or children property, return 0
  if (!tree || !Array.isArray(tree.children)) {
    return 0;
  }

  // Get the length of the current node's children
  const currentLength = tree.children.length;

  // Recursively get the sum of lengths from all child nodes
  const childLengthsSum = tree.children.reduce(
    (sum, child) => sum + getChildrenLengthSum(child),
    0
  );

  // Return the sum of the current length and all child node lengths
  return currentLength + childLengthsSum;
}

// Memoized TreeNode
const TreeNode = memo(
  ({ node, onUpdate }) => {
    console.log(`Rendering Node ${node.id}`);

    const handleAddChild = () => {
      const newChild = { id: Math.random(), children: [] };
      onUpdate(node.id, newChild);
    };

    return (
      <div>
        <div>
          Node: {node.id} <button onClick={handleAddChild}>Add Child</button>
        </div>
        <div style={{ paddingLeft: 20 }}>
          {node.children.map((child) => (
            <TreeNode key={child.id} node={child} onUpdate={onUpdate} />
          ))}
        </div>
      </div>
    );
  },
  (prevProps, nextProps) => {
    // Compare children references directly
    return prevProps.node === nextProps.node;
  }
);

function App() {
  const [tree, setTree] = useState([
    {
      id: 1,
      children: [
        {
          id: 2,
          children: [{ id: 4, children: [] }],
        },
        { id: 3, children: [] },
      ],
    },
  ]);

  // Update handler for the tree
  const handleUpdate = (parentId, newChild) => {
    const updateTree = (nodes) =>
      nodes.map((node) =>
        node.id === parentId
          ? { ...node, children: [...node.children, newChild] } // New array for children
          : { ...node, children: updateTree(node.children) }
      );

    setTree((prevTree) => updateTree(prevTree));
  };

  return (
    <div>
      {tree.map((rootNode) => (
        <TreeNode key={rootNode.id} node={rootNode} onUpdate={handleUpdate} />
      ))}
    </div>
  );
}

export default App;

Solution

  • What I can't tell is whether every time I load 10 new top level components, will the existing top level comments re-render or will memo prevent them from re-rendering?

    Yes, It will. Your code shows the same. It means this: memo lets you skip re-rendering a component when its props are unchanged (from the docs)

    Let us check the console logs entries in your code.

    Case 1 : After the initial render, let us add a new node as child to the parent node, Node: 1.

    The logs

    // Rendering Node 1
    // Rendering Node 0.3296669083837116
    

    It rendered twice.

    Firstly for the parent node with id 1 and, secondly for the newly added node. The reason for these two renders may be self explanatory, still documented below for clarifications.

    To avoid object mutation, the parent object had been newly created by copying its existing data. The following code did the same. And it ends up with a re-render.

    ...
    { ...node, children: [...node.children, newChild] }
    ...
    

    The new node requires an initial render too.

    The rest of the 3 nodes, id:2, id:3 and id:4, are intact. The spread operation ...node.children restores the same objects. Therefore, those renders are skipped.

    Case 2 : After the initial render, let us add a new node as child to the parent node, Node: 4.

    The logs

    // Rendering Node 1
    // Rendering Node 2
    // Rendering Node 4
    // Rendering Node 0.8119218330034577
    // Rendering Node 3
    

    It rendered 5 times. It means the whole tree has been rendered again along with the new node.

    The reason is the same. The parent node, id:1 as well as its whole children are newly created along with the addition of a new node. The below code did the job.

    ...
    ? { ...node, children: [...node.children, newChild] } // New array for children
    : { ...node, children: updateTree(node.children) }
    ...
    

    ...What I'm scared is the act of setting a new useState will cause the entire tree to re-render after adding new replies.

    While working with nested state, adding a leaf node does require maximum re-rendering. However, the number of re-rendering decreases as the addition being close to the root. We have seen the same in the two cases verified above.

    Is the way I'm structuring this tree where all the data for the page is stored in a state that is constantly updated at multiple levels of the tree able to prevent re-rendering for all TreeNode components?

    Nested states always require cascading updating and re-rending. Therefore it is recommended to avoid deeply nested state. Instead consider making it flat. You can read more on it here Avoid deeply nested state

    Aside:

    1. Render in React means invocation of function objects. Drawing screen in React is an another distinct phase which happens subsequent to Rendering. This is called commit phase. The update of DOM happens only during commit. Therefore though there are renders, it does not correspond to DOM update. DOM update is always with respect to the difference in JSX of the two renders - the latest and the previous one. Therefore, optimising with memo is only valuable when your component re-renders often with the same exact props, and its re-rendering logic is expensive (from docs of memo)

    2. React compiler now in Beta, chiefly targeted to do automatic memoisation by analysing the source code. More can be read here React Compiler

    3. By default React renders child components whenever parent component renders. The reason for this default behaviour is that, React advocates and assumes the function components in use to be pure. As we know, pure functions will return the same results as long as its inputs do not change, therefore it is safe to skip calling it again under the event of unchanged inputs. However, there is no purity check as such, therefore React renders children irrespective of its props status. When we use memoisation, we give React the assurance that the function is pure, therefore it is safe to skip.