reactjsreact-refreact-portal

React useLayoutEffect in a child of a portal runs before the component renders


I'm using a Portal component to create a portal and add it to the document. Some component (a tooltip, in my actual use case) is a child of the portal and needs to read its width (in my case, to check if the tooltip will be outside the window, and reposition it if necessary), and then re-render itself using useLayoutEffect.

My issue is that the callback of useLayoutEffect is called before the element has been rendered, so when I do something like getBoundingClientRect(), the width is still 0. If I move the component outside of the portal then it works correctly.

https://codesandbox.io/s/divine-snowflake-071dbw

Relevant parts:

const Portal = ({ children }) => {
  const [container] = useState(() => {
    const el = document.createElement("div");
    return el;
  });

  useEffect(() => {
    document.body.appendChild(container);
    return () => {
      document.body.removeChild(container);
    };
  }, [container]);

  return createPortal(children, container);
};

const MyComponent = ({ name }) => {
  const ref = useRef(null);

  useLayoutEffect(() => {
    if (ref.current) {
      const rect = ref.current.getBoundingClientRect();
      // For the component inside the portal,
      // the width/height/etc is all 0
      console.log("rect of", name, rect.width);
    }
  }, [name]);

  return <div ref={ref}>{name}</div>;
};

const App = () => {
  return (
    <>
      <Portal>
        <MyComponent name="component inside portal" />
      </Portal>
      <MyComponent name="outside" />
    </>
  );
}

Logs

rect of component inside portal 0
rect of outside 770

Solution

  • The portal is to render the component on the utmost top of the DOM tree and does not depend on the parent components of the portal's component. That's why it cannot calculate sizes initially which usually depends on containers (parent components).

    For getting element sizes in portals, you can use setTimeout that will help you to execute your logic after all other call stacks are done (in your cases, call stacks are for initializing portals)

    import { useState, useEffect, useLayoutEffect, useRef } from "react";
    import { createPortal } from "react-dom";
    
    const Portal = ({ children }) => {
      const [container] = useState(() => {
        const el = document.createElement("div");
        return el;
      });
    
      useEffect(() => {
        document.body.appendChild(container);
        return () => {
          document.body.removeChild(container);
        };
      }, [container]);
    
      return createPortal(children, container);
    };
    
    const MyComponent = ({ name }) => {
      const ref = useRef(null);
    
      useLayoutEffect(() => {
        if (ref.current) {
          //delay the execution till the portal initialization completed
          setTimeout(() => {
            const rect = ref.current.getBoundingClientRect();
          console.log("rect of", name, rect.width);
          })
        }
      }, [name]);
    
      return <div ref={ref}>{name}</div>;
    };
    
    export default function App() {
      return (
        <>
          <Portal>
            <MyComponent name="component inside portal" />
          </Portal>
          <MyComponent name="outside" />
        </>
      );
    }
    

    You can check this sandbox for the testing.