Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React Hooks (Rendering Arrays) - Parent component holding a reference of children that are mapped vs Parent component holding the state of children

I have been learning hooks in react for the past couple of days, and I tried creating a scenario where I need to render a big grid on screen, and update the background color of the nodes depending on the action I want to take. There are two actions that will change the background color of a node, and these two actions must coexist.

  • The cursor hovers a node while it is clicked.
  • There exists an algorithm inside the Grid component that will change backgrounds of some of the nodes.

The way I see it, there are multiple ways I can achieve this, but I am having some trouble with the way hooks were intended to be used. I will first walk you through my thought process on how this could be achieved from what I learned, and then show you the implementation that I tried. I tried to keep the important parts of the code so it can be understood clearly. Please let me know if I missed somethings or misunderstood a concept completely.

  1. The children can hold their own state and know how to update themselves. The parent can hold the reference to each children of the list, and call the necessary function from the reference of the child when it is needed in order to update the children.

    • Works well for the first and the second action to be taken. This solution causes no performance issues since the children manage their own state, and if the parent updates the children state via reference, the only child to be re-rendered will be the one that gets called.
    • This solution is seen as an anti-pattern from what I read.

    const Grid = () => {
        // grid array contains references to the GridNode's

        function handleMouseDown() {
            setIsMouseDown(true);
        }

        function handleMouseUp() {
            setIsMouseDown(false);
        }

        function startAlgorithm() {
            // call grid[row][column].current.markAsVisited(); for some of the children in grid.
        }

        return (
            <table>
                <tbody>
                {
                    grid.map((row, rowIndex) => {
                            return (
                                <tr key={`R${rowIndex}`}>
                                    {
                                        row.map((node, columnIndex) => {
                                            return (
                                                <GridNode
                                                    key={`R${rowIndex}C${columnIndex}`}
                                                    row={rowIndex}
                                                    column={columnIndex}
                                                    ref={grid[rowIndex][nodeIndex]}
                                                    onMouseDown={handleMouseDown}
                                                    onMouseUp={handleMouseUp}
                                                />
                                            );
                                        })
                                    }
                                </tr>
                            );
                        }
                    )
                }
                </tbody>
            </table>
        );
    };

    const GridNode = forwardRef((props, ref) => {
        const [isVisited, setIsVisited] = useState(false);

        useImperativeHandle(ref, () => ({
            markAsVisited: () => {
                setIsVisited(!isVisited);
            }
        }));

        function handleMouseDown(){
                setIsVisited(!isVisited);
            }

        function handleMouseEnter () {
                if (props.isMouseDown.current) {
                    setIsVisited(!isVisited);
                }
            }

        return (
            <td id={`R${props.row}C${props.column}`}
                onMouseDown={handleMouseDown}
                onMouseEnter={handleMouseEnter}
                className={classnames("node", {
                    "node-visited": isVisited
                })}
            />
        );
    });


2. The state of the children could be given as props from the parent, any update operation can be achieved inside the parent. (Children gets updated correctly, render gets called in only the necessary children, but the DOM seems to stutter. If you move the mouse at a certain speed, nothing happens, and every visited node gets updated at once.)

  • Doesn't work for the first action. Children gets updated correctly, render gets called in only the necessary children, but the DOM seems to stutter. If you move the mouse at a certain speed, nothing happens and every visited node gets updated at once.

    const Grid = () => {
        // grid contains objects that have boolean "isVisited" as a property.

        function handleMouseDown() {
            isMouseDown.current = true;
        }

        function handleMouseUp() {
            isMouseDown.current = false;
        }

        const handleMouseEnterForNodes = useCallback((row, column) => {
            if (isMouseDown.current) {
                setGrid((grid) => {
                    const copyGrid = [...grid];

                    copyGrid[row][column].isVisited = !copyGrid[row][column].isVisited;

                    return copyGrid;
                });
            }
        }, []);

        function startAlgorithm() {
            // do something with the grid, update some of the "isVisited" properties.

            setGrid(grid);
        }

        return (
            <table>
                <tbody>
                {
                    grid.map((row, rowIndex) => {
                            return (
                                <tr key={`R${rowIndex}`}>
                                    {
                                        row.map((node, columnIndex) => {
                                            const {isVisited} = node;

                                            return (
                                                <GridNode
                                                    key={`R${rowIndex}C${columnIndex}`}
                                                    row={rowIndex}
                                                    column={columnIndex}
                                                    isVisited={isVisited}
                                                    onMouseDown={handleMouseDown}
                                                    onMouseUp={handleMouseUp}
                                                    onMouseEnter={handleMouseEnterForNodes}
                                                />
                                            );
                                        })
                                    }
                                </tr>
                            );
                        }
                    )
                }
                </tbody>
            </table>
        );
    };

    const GridNode = ({row, column, isVisited, onMouseUp, onMouseDown, onMouseEnter}) => {
        return useMemo(() => {
            function handleMouseEnter() {
                onMouseEnter(props.row, props.column);
            }

            return (
                <td id={`R${row}C${column}`}
                    onMouseEnter={handleMouseEnter}
                    onMouseDown={onMouseDown}
                    onMouseUp={onMouseUp}
                    className={classnames("node", {
                        "node-visited": isVisited
                    })}
                />
            );
        }, [props.isVisited]);
    }


I have two questions that I want to ask on this topic.

  1. In the first implementation; the parent component doesn't re-render when a node changes its' state. Is it wrong to just utilize this anti-pattern if it is beneficial in this kind of situations?

  2. What may be the cause of the stutter that the second implementation suffers from? I have spent a while reading the docs and trying out different things, but cannot find the reason of the stuttering that is happening.

like image 395
Pirhan Avatar asked Oct 26 '22 21:10

Pirhan


1 Answers

As you say that using refs to control child data is an anti-pattern, However it doesn't mean that you cannot use it.

What it means is that if there are better and more performant means, its better to use them as they lead to better readability of the code and also improve debugging.

In your case using a ref definitely makes it easier to update state and also prevents a lot of re-rendering is a good way to implement the above solution

What may be the cause of the stutter that the second implementation suffers from? I have spent a while reading the docs and trying out different things, but cannot find the reason of the stuttering that is happening.

A lot of the problem in the second solution arise from the fact that you define functions which are recreated on each re-render and hence cause the entire grid to be re-rendered instead of just the cell. Make use of useCallback to memoize these function in Grid component

Also you should use React.memo instead of useMemo for your usecase in GridNode.

Another thing to note is that you are mutating the state while updating, Instead you should update it in an immutable manner

Working code:

const Grid = () => {
  const [grid, setGrid] = useState(getInitialGrid(10, 10));
  const isMouseDown = useRef(false);
  const handleMouseDown = useCallback(() => {
    isMouseDown.current = true;
  }, []);

  const handleMouseUp = useCallback(() => {
    isMouseDown.current = false;
  }, []);

  const handleMouseEnterForNodes = useCallback((row, column) => {
    if (isMouseDown.current) {
      setGrid(grid => {
        return grid.map((r, i) =>
          r.map((c, ci) => {
            if (i === row && ci === column)
              return {
                isVisited: !c.isVisited
              };
            return c;
          })
        );
      });
    }
  }, []);

  function startAlgorithm() {
    // do something with the grid, update some of the "isVisited" properties.

    setGrid(grid);
  }

  return (
    <table>
      <tbody>
        {grid.map((row, rowIndex) => {
          return (
            <tr key={`R${rowIndex}`}>
              {row.map((node, columnIndex) => {
                const { isVisited } = node;
                if (isVisited === true) console.log(rowIndex, columnIndex);
                return (
                  <GridNode
                    key={`R${rowIndex}C${columnIndex}`}
                    row={rowIndex}
                    column={columnIndex}
                    isVisited={isVisited}
                    onMouseDown={handleMouseDown}
                    onMouseUp={handleMouseUp}
                    onMouseEnter={handleMouseEnterForNodes}
                  />
                );
              })}
            </tr>
          );
        })}
      </tbody>
    </table>
  );
};

const GridNode = ({
  row,
  column,
  isVisited,
  onMouseUp,
  onMouseDown,
  onMouseEnter
}) => {
  function handleMouseEnter() {
    onMouseEnter(row, column);
  }
  const nodeVisited = isVisited ? "node-visited" : "";
  return (
    <td
      id={`R${row}C${column}`}
      onMouseEnter={handleMouseEnter}
      onMouseDown={onMouseDown}
      onMouseUp={onMouseUp}
      className={`node ${nodeVisited}`}
    />
  );
};

Edit FORM VALUES

P.S. While useCallback and other memoizations will help give to some performance benefits it will still not be able to overcome the performance impacts on state updates and re-render. In such scenarios its better to make define state within the children and expose a ref for the parent

like image 199
Shubham Khatri Avatar answered Nov 08 '22 12:11

Shubham Khatri