score:2

Accepted answer

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

score:0

As stated, the solution is an anti-pattern because you're mixing the rendering and business logic on both levels. You don't need to explicitly use React.forwardRef, in fact according to the docs you shouldn't, even when composing HOC (Higher order components). You shouldn't need to directly access the element and do some sort of action on it - let React do its thing. It's very good and efficient at it.

Generally when you're calling a re-render method on a child node tree when there's n nodes, you don't want to cause a re-render from the top-level node, the parent in this case, because it will cause the entire node-tree to re-render into a new DOM element, rather than update existing elements.

Your current solution has a combination of parent-triggered renders and child triggered renders. The React page has a good example with the tic-tac-toe application for how to render children without causing the parent to re-render.

The strategy that you should use is one where the parent node has an object structure, in this case n^2 nodes (eg 10x10 for arguments sake), is to pass the rendering functionality to the child nodes, and let the child nodes handle the rendering.

When you're triggering a render from the parent node, you have a couple of options (assuming functional components) which really fall into the case of observable updates. You want to be able to push updates from the parent to the child, to modify the child node state, and let the child node update itself.

Here's an example with child nodes rendering, while the parent is communicating changes to the children. You'll see that the performance scales well even up to massive grids, compared to the nested level renders your example has.

https://codepen.io/jmitchell38488/pen/pogbKEb

This is achieved by using a combination of RxJS observable/subject, React.useState and React.useEffect. We use useState in both the parent and child nodes to deal with rendering and prop updates, and useEffect to bind the observable. useState is persistent between renders, which means you don't need to rebuild the entire grid every time you update in the parent, but even if you do, React is intelligent enough to determine that you updated the props of a node, not replaced it.

const Grid = (props) => {
  // When we update the grid, we trigger the parent to re-render
  const [grid, setGrid] = React.useState([]);
  const subject = new Rx.Subject();
  if (grid.length < 1) {
    const newGrid = [];
    for (i = 0; i < props.h; i++) {
      for (k = 0; k < props.w; k++) {
        if (!Array.isArray(newGrid[i])) {
          newGrid[i] = [];
        }

        newGrid[i][k] = {
          visited: false,
          id: `${i}${k}`
        };
      }
    }
    setGrid(newGrid);
  }

  // Tell our node to update
  handleClick = (node, visited) => {
    subject.next({
      id: node.id,
      visited: visited
    })
  };

  randomSetAllVisited = () => {
    const newGrid = [...grid];
    newGrid.forEach(row => {
      row.forEach(node => {
        node.visited = Math.random() * 2 >= 1;
      })
    })

    // Tell parent to re-render
    setGrid(newGrid);

    // Because our nodes use `useState`, they are persistent, if the structure of
    // grid is the same and the data is mostly the same. This is based on the `key={...}` value
    // in row.map, so we need to tell our children nodes to re-render manually
    subject.next({
      reset: true
    })
  };

  randomSetAnyVisited = () => {
    const h = Math.floor(Math.random()*props.h);
    const w = Math.floor(Math.random()*props.w);
    const node = grid[h][w];
    subject.next({
      id: node.id,
      visited: true
    });
  };

  // Watch console.log to see how frequently parent renders
  console.log("rendering parent");

  return (
    <div>
      <table>
        <tbody>
          {grid.map((row, rowIndex) => (
            <tr key={`R${rowIndex}`}>
              {row.map((node, columnIndex) => (<GridNode {...node} observer={subject.asObservable()} key={node.id} />))}
            </tr>
          ))}
        </tbody>
      </table>
      <button onClick={randomSetAllVisited}>Random set all visited</button>
      <button onClick={randomSetAnyVisited}>Random set any visited</button>
    </div>
  );
};

const GridNode = (props) => {
  // We need to set to undefined to handle full grid reset from parent
  const [visited, setVisited] = React.useState(undefined);

  // Toggle visited based on props and if visited is undefined
  if (props.visited !== visited && visited === undefined) {
    setVisited(props.visited);
  }

  // bind all this with useEffect, so we can subscribe/unsubscribe, and not block rendering, `useEffect` is a good practice
  React.useEffect(() => {
    // notifications that come from parent node, `setVisited` will re-render this node
    const obs = props.observer.subscribe(next => {
      if (!!next.id && next.id === props.id) {
        setVisited(next.visited !== undefined ? next.visited : !visited);
      } else if (!!next.reset) {
        setVisited(undefined);
      }
    });
    return () => obs.unsubscribe();
  }, [visited]);

  handleMouseEnter = () => {
    setVisited(!visited);
  }

  handleMouseLeave = () => {
    setVisited(!visited);
  }

  classes = ["node"];
  if (visited) {
    classes.push("node-visited");
  }
  return (<td onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className={classes.join(" ")}/>);
}

In the codepen example, I have a 50x50 grid, that has no stutters, lag, or issues re-rendering the children nodes, or updating them. There are two helper buttons to randomise the state for all nodes, or randomise a single node. I've scaled this over 100x100 and no lag or performance issues.


Related Query

More Query from same tag