Danylo Mysak
Danylo Mysak

Reputation: 1512

Measuring a DOM node synchronously in React

Say, I want to measure a DOM element as described in React’s hooks FAQ with an additional twist: the result needs to be applied to another element before the browser repaints. As far as I understand, setState does not guarantee that the update will be applied immediately unless it’s inside useLayoutEffect. However, effects should not be used for this purpose either. What would be the correct approach then?

To sum up, I’d like to achieve the same thing as in the example below, but without 0px briefly flashing before setHeight has any effect.

function MeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}

Upvotes: 1

Views: 823

Answers (2)

Richard Scarrott
Richard Scarrott

Reputation: 7073

I had a similar concern however from my tests the snippet you've taken from the React docs does run before a paint occurs. i.e. it doesn't flash 0px before rendering the measured height.

Practically speaking, you can prove this using the chrome profiler which will spit out screenshots. Alternatively, you can slow down the callback and see it never paints 0px.

NOTE: Debug statements aren't a good way to test this because they will pause JS execution, but allow paints (at least in Chrome).

function MeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback(node => {
    if (node !== null) {
      for (let i = 0; i < 500000000; i++) {}
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}

Theoretically speaking, it has to execute the ref callback before paint because DOM refs are available in useLayoutEffect which we know runs before paint.

function MeasureExample2() {
  const [height, setHeight] = useState(0);
  const nodeRef = useRef();
  useLayoutEffect(() => {
     if (!nodeRef.current) {
       throw new Error('Expected el'); // Never called
     }
     setHeight(node.getBoundingClientRect().height);
  });
  return (
    <>
      <h1 ref={nodeRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}

Additionally, setState within useLayoutEffect will run synchronously; if it didn't it would make useLayoutEffect rather painful as you'd have to imperatively mutate the DOM rather than using React render.

I think therefore React must:

  1. Mutate DOM
  2. Set DOM refs
  3. Execute useLayoutEffect callbacks
  4. Execute useEffect callbacks

Where steps 1, 2 and 3 are all synchronous meaning the browser hasn't had the opportunity to paint yet.

Upvotes: 1

H&#229;ken Lid
H&#229;ken Lid

Reputation: 23084

Only render the h2 when you have a non-zero height.

{height && (<h2>The above header is {Math.round(height)}px tall</h2>)}

you could also use css property visibility: hidden on the entire component until it has been measured.

<div style={{visibility: height ? 'visible' : 'hidden'}}>
  <h1 ref={measuredRef}>Hello, world</h1>
  <h2>The above header is {Math.round(height)}px tall</h2>
</div>

Upvotes: 0

Related Questions