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;
...Thus memoizing would be ideal, right -- as any modification to the list of elements (which I think a state starts a re-render based on the length of the list) would re-render the parent and thus all "comments" / components?
Flattening deeply nested state is recommended for a relatively simple update process. However, a flattened list will fail to update when memoized. The following post is illustrating the same.
Let us compare the update processes first.
Code updating a nested state, the same code pasted from the post.
...
// 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));
};
...
Code updating a flattened state, a sample code
The highlight in this code is that it does not require to update all of the nodes from the changed node to the root node. Instead, it needs to update only its immediate parent node. It means a flat list does not require recursive updating as it is in a nested state. More clarity on this point will get after seeing the refactored flat list enclosed in the full listing of a sample code underneath.
...
const handleUpdate = (parentId, newChildId, newChild) => {
function updateList(prevList) {
const copyList = { ...prevList };
const copyParent = { ...prevList[parentId] };
const updatedParent = {
...copyParent,
children: [...copyParent.children, newChild[newChildId].id],
};
const updatedCopyList = {
...copyList,
...newChild,
[parentId]: updatedParent,
};
return updatedCopyList;
}
setList((prevList) => updateList(prevList));
};
...
Let us now look into the rendering part.
As mentioned in the beginning, a flat list will fail to update when memoized. The below is the full code listing. The flat list also included in the code. A detailed discussion of it with test run is given below.
App.js
import React, { useState, memo } from 'react';
const initialList = {
0: {
id: 0,
children: [1],
},
1: {
id: 1,
children: [2, 3],
},
2: {
id: 2,
children: [4],
},
3: {
id: 3,
children: [],
},
4: {
id: 4,
children: [],
},
};
function App() {
const [list, setList] = useState({ ...initialList });
console.clear();
// Update handler for the list
const handleUpdate = (parentId, newChildId, newChild) => {
function updateList(prevList) {
const copyList = { ...prevList };
const copyParent = { ...prevList[parentId] };
const updatedParent = {
...copyParent,
children: [...copyParent.children, newChild[newChildId].id],
};
const updatedCopyList = {
...copyList,
...newChild,
[parentId]: updatedParent,
};
return updatedCopyList;
}
setList((prevList) => updateList(prevList));
};
return (
<div>
<TreeNode
key={list[0].id}
nodeId={list[0].id}
list={list}
onUpdate={handleUpdate}
/>
</div>
);
}
// Memoized TreeNode
const TreeNode = memo(
({ nodeId, list, onUpdate }) => {
const node = list[nodeId];
//console.log(children);
console.log(`Rendering Node ${node.id}`);
const handleAddChild = () => {
const newId = Math.random();
const newChild = { [newId]: { id: newId, children: [] } };
onUpdate(node.id, newId, newChild);
};
return (
<div>
<div>
Node: {node.id} <button onClick={handleAddChild}>Add Child</button>
<label>Comments here</label>
<input></input>
</div>
<div style={{ paddingLeft: 20 }}>
{node.children.map((child) => (
<TreeNode
key={child}
nodeId={child}
list={list}
onUpdate={onUpdate}
/>
))}
</div>
</div>
);
},
(oldProps, newProps) =>
oldProps.list[oldProps.nodeId] === newProps.list[newProps.nodeId]
);
export default App;
Test run
On loading the app
The logs generated
// Rendering Node 0
// Rendering Node 1
// Rendering Node 2
// Rendering Node 4
// Rendering Node 3
Observation:
A full render is required on loading the app. This is same as in the case of a nested state.
Case 1 : After the initial render, let us add a new node as child to the parent node, Node: 0.
The logs
// Rendering Node 0
// Node 0.141798551673729
Observation:
Two nodes have been rendered here. The update process happens in the following steps.
step 1 : By the following statement a copy of the existing list of objects has been created.
const copyList = { ...prevList };
step 2 : A copy of the parent object has also been made.
const copyParent = { ...prevList[parentId] };
/* the object under this context
{
id: 0,
children: [1],
}
*/
step 3: The new node has been added to the children array of the copy of parent object.
const updatedParent = {
...copyParent,
children: [...copyParent.children, newChild[newChildId].id],
};
/* the object under this context
{
id: 0,
children: [1,0.141798551673729],
}
*/
Step 4: The copy of the list of objects has been updated with new node and the updated parent objects.
const updatedCopyList = {
...copyList,
...newChild,
[parentId]: updatedParent,
};
/* the object under this context
{
0: {
id: 0,
children: [1, 0.141798551673729],
},
1: {
id: 1,
children: [2, 3],
},
2: {
id: 2,
children: [4],
},
3: {
id: 3,
children: [],
},
4: {
id: 4,
children: [],
},
0.141798551673729: {
id : 0.141798551673729,
children: []
}
};
*/
step 5 : As the final step, the state has been updated with the updated list.
setList((prevList) => updateList(prevList));
Case 2 : After the initial render, let us add a new node as child to the parent node, Node: 4.
The logs
// no logs generated
Observation
It is to note that there is no log generated. It means there is no render has been taken place. As a result, the new node has not displayed in the IU. On clicking the button, the UI remains in the original form. This is the same status while trying to add new child node to Node2, Node4 and Node 3. It means it allows to add new nodes only under Node 0.
Let us the see the reason for it.
This is where we can see a flat list conflicts with memoisation. Let us take the steps individually.
step 1 : After the clicking the new child under Node 4, a new node has been successfully added to the list. This can be verified by console.log(list) on render of root component. It means the list has been successfully updated as below. However the UI does not show it.
/* the object under this context
{
0: {
id: 0,
children: [1, 0.141798551673729],
},
1: {
id: 1,
children: [2, 3],
},
2: {
id: 2,
children: [4],
},
3: {
id: 3,
children: [],
},
4: {
id: 4,
children: [0.929498551673729],
},
0.141798551673729: {
id : 0.141798551673729,
children: []
},
0.929498551673729 :
{
id : 0.929498551673729,
children : []
}
};
*/
step 2 : The first declaration to render is the following one. The nodeId over here is 0. Please recall that in a flat list, the updating process does not cascade. Instead the change is confined or applicable only to the immediate parent object. Therefore while adding a child node to Node 4, the root object with id : 0, does not change.
<TreeNode
key={list[0].id}
nodeId={list[0].id}
list={list}
onUpdate={handleUpdate}
/>
Therefore, the below code, the custom arePropsEqual function will return true for the root node. We may know the reason, since it has not been changed since rendered previously.
(oldProps, newProps) =>
oldProps.list[oldProps.nodeId] === newProps.list[newProps.nodeId]
It means the render will get the memoized result. It will include the whole JSX to render the whole UI since it is the top most object. It means the whole UI generated in the initial loading of the app, will be received here through memoization. This may seem very good.
The above memoization which seemed worked in favour, has an adverse impact on the recursive rendering. It means the next rendering call, the below one, will not be invoked at all. This breaks the recursive render which will cause the new node never be reached to render. This is what happens in this case. Therefore the UI remains in the original though the state updation is working fine behind the scene.
...
{node.children.map((child) => (
<TreeNode
key={child}
nodeId={child}
list={list}
onUpdate={onUpdate}
/>
))}
...
A possible conclusion
A flat list has been suggested to keep the update process simple and easy to maintain. However, while coupling a flat list with memoization of rendering, will have adverse impact. We may be able to find some ways to circumvent the known issue, still the objective of this post confined to the reasoning part only.