2

I'm trying to create a handy react hook to fit an SVG's viewBox to it's content.

import { useState } from 'react';
import { useEffect } from 'react';
import { useRef } from 'react';

// get fitted view box of svg
export const useViewBox = () => {
  const svg = useRef();
  const [viewBox, setViewBox] = useState(undefined);

  useEffect(() => {
    // if svg not mounted yet, exit
    if (!svg.current)
      return;
    // get bbox of content in svg
    const { x, y, width, height } = svg.current.getBBox();
    // set view box to bbox, essentially fitting view to content
    setViewBox([x, y, width, height].join(' '));
  });

  return [svg, viewBox];
};

then using it:

const [svg, viewBox] = useViewBox();

return <svg ref={svg} viewBox={viewBox}>... content ...</svg>

But I get the following eslint error:

React Hook useEffect contains a call to 'setViewBox'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [viewBox] as a second argument to the useEffect Hook.eslint(react-hooks/exhaustive-deps)

I've never run into a situation where the react hooks eslint errors were "wrong", until now. I feel like this is a perfectly legitimate use of hooks. It needs to run as an effect, because it needs to run AFTER the render to see if the contents of the SVG have changed. And as far as the warning message: this code already avoids an infinite render loop because setState doesn't fire a re-render unless the new value is different from the current one.

I can disable the eslint rule:

// eslint-disable-next-line react-hooks/exhaustive-deps

But that seems wrong, and I'm wondering if there's a simpler/different way to achieve the same goal that I'm not seeing.

I could have the caller of useViewBox provide some variable that would go in useEffect's dependency array and would force a re-render, but I want it to be more flexible and easier to use than that.

Or perhaps the problem actually lies in the exhaustive-deps rule. Maybe it should allow setState within a no-dependencies-specified useEffect if it detects some conditionality in front of the setState...

V. Rubinetti
  • 1,324
  • 13
  • 21

2 Answers2

2

Okay I found a "solution", inspired from this answer. I think it's kind of a silly work around, but I suppose it's better than disabling the eslint rule:

import { useState } from 'react';
import { useEffect } from 'react';
import { useRef } from 'react';
import { useCallback } from 'react';

// get fitted view box of svg
export const useViewBox = () => {
  const svg = useRef();
  const [viewBox, setViewBox] = useState(undefined);

  const getViewBox = useCallback(() => {
    // if svg not mounted yet, exit
    if (!svg.current)
      return;
    // get bbox of content in svg
    const { x, y, width, height } = svg.current.getBBox();
    // set view box to bbox, essentially fitting view to content
    setViewBox([x, y, width, height].join(' '));
  }, []);

  useEffect(() => {
    getViewBox();
  });

  return [svg, viewBox];
};
V. Rubinetti
  • 1,324
  • 13
  • 21
  • 2
    I have had to do this before too, I wonder why the es lint rule gets thrown in the first place forcing us to use this workaround or disable the es lint rule. Puzzling. – apena Sep 18 '20 at 19:55
0

I actually did the same with less code, maybe it's gonna help somebody coming (following React FAQ on how to measure components height)

function Layout({children}) {
  const [autoWidth, setAutoWidth] = useState();
  const [autoHeight, setAutoHeight] = useState();
  const handleRef = React.useCallback(node => {
    if (node !== null) {
      const box = node.getBBox()
      setAutoWidth(box.width)
      setAutoHeight(box.height)
    }
  }, [])

    return <svg ref={handleRef} viewBox={`0 0 ${autoWidth} ${autoHeight}`}>{children}</svg>
}
export default Layout
math
  • 167
  • 2
  • 13