When I Actually Needed useLayoutEffect() in React

-

If you’ve worked with React for any length of time, you’ve probably heard of the infamous useLayoutEffect() hook. You’ve also probably never used it. That’s because for most cases, useEffect() covers the vast majority of use cases with a much lower chance of shooting yourself in the foot. In fact, the the React docs even explicitly encourage you to use the latter whenever possible to avoid performance gotchas.

The reason for this is because of when each hook fires its callback. The useEffect() hook fires after a component renders and paints changes to the DOM; useLayoutEffect() fires before, and does so synchronously.

This means if you’re not careful, your expensive callback could block the main thread, preventing any UI updates from being rendered for the user.

An Illustration

Here’s a simple Name component. All it does is render a piece of name state with a default of “Bob.” But there’s also a useEffect() hook that’ll fire whenever that state changes. And as soon as it does, it updates the name to “Alex.” If you were to render this component in the browser, you probably wouldn’t notice any sort of flash. Instead, all you’d see is “Alex” painted to the screen:

const Name = () => {
  const [name, setName] = useState('Bob');

  useEffect(() => {
    setName('Alex');
  }, [name]);

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

// <Name /> seemingly _only_ paints "Alex" to the screen.

But technically, useEffect() is firing after a full render has taken place, which could also mean after that render has been painted to the screen. We can force this behavior by putting a synchronous pause in place. This Name component will still behave the same way, but this time, the state update will be pushed back by a second (1000ms).

const pause = (ms) => {
  let time = new Date();

  while (new Date() - time <= ms) {}
};

const Name = () => {
  const [name, setName] = useState('Bob');
	
	// Fires _after_ a render has taken place.
  useEffect(() => {
    pause(1000);
    setName('Alex');
  }, [name]);

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

If you try it out this time, “Alex” no longer immediately paints to the screen. Instead, “Bob” is first rendered, and then, after a second, “Alex” pops in.

Based on how useEffect() is documented, this checks out. It’s only firing after a full render has been committed and prepared to be painted. And so any last-second state changes or DOM modifications are at risk for being “flashed” to the screen before they’re desired.

Now, let’s make a small modification: instead of using useEffect(), we’ll use useLayoutEffect(). This time around, we’ll change the state before any changes have been allowed to fully render to the DOM. And for that reason, we’ll see some far more predictable results:

const Name = () => {
  const [name, setName] = useState('Bob');

	// Fires _before_ anything's been rendered at all.
  useLayoutEffect(() => {
    pause(1000);
    setName('Alex');
  }, [name]);

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

If you were to try it out now, you’ll see that the “Bob” flash is gone. Instead, nothing renders at all until our synchronous useLayoutEffect() callback has finished:

So, When Should I Use It?

When choosing between useEffect() and useLayoutEffect(), use the latter when you need to something to or with the DOM before anything has been painted to the screen.

My Own Scenario

I was building a simple tooltip from scratch. The component allows its positioning to be configured with a position prop — either right, center, or left. However, depending on the layout of the item its mounted to, as well as the dimensions of the viewport, I sometimes might want to override the provided position with an overridePosition. Here’s an ultra-simplified version of this component and how it can be used.

const Tooltip = ({ children, message, position = 'center' }) => {
  const [shouldShow, setShouldShow] = useState(false);
  const toggle = () => setShouldShow((val) => !val);

  return (
    <span class="tooltip-wrapper">
      {shouldShow && <div className={`tooltip ${position}`}>{message}</div>}

      {children({ toggle })}
    </span>
  );
};

export default function App() {
  return (
    <Tooltip position="left" message="My message!">
      {({ toggle }) => {
        return <button onClick={toggle}>Toggle Tooltip</button>;
      }}
    </Tooltip>
  );
}

This works, but there’s a problem: we can predict neither the layout of the page nor the size of the user’s viewport. And so, we might end up with the tooltip rendering like this — outside the view of the user:

In cases like these, there isn’t much React can do for us. Instead, we need to take the DOM into our own hands for a bit before any rendering takes place.

Reading the DOM Before Anything’s Rendered

In order to get around this, we’re gonna update this component to set an overridePosition only if two conditions are met:

  1. The tooltip’s DOM node itself is actually mounted.
  2. That mounted node is hanging off the edge of the viewport.

Because all of this needs to take place before the user has a chance to see anything, it’s a great use case for useLayoutEffect().

First, we’ll set up a useLayoutEffect() hook that checks if a ref.current value is truthy. That ref will be attached to the actual tooltip markup that’s conditionally rendered (via shouldShow):

const Tooltip = ({ children, message, position = 'center' }) => {
+	const ref = useRef(null);
	const [shouldShow, setShouldShow] = useState(false);
	const toggle = () => setShouldShow((val) => !val);

+	useLayoutEffect(() => {
+		if (!ref.current) return;
+	});

  return (
		<span className="tooltip-wrapper">
			{shouldShow && (
				<div 
					className={`tooltip ${position}`}
+					ref={ref}
				>
					{message}
				</div>
			)}

			{children({ toggle })}
		</span>
	);
};

It’s probably worth noting that we’re not passing any values to the dependency array of our hook. That’s because we want the callback to run whenever any state changes. There could be countless other things going on in the browser between toggles, and so performing a fresh (yet-to-be-written) DOM check keeps us flexible with unpredictable layout changes.

Next up, we can check if the rendered tooltip is popping outside of the viewport. I’ll leave the implementation of that method (and other supporting methods) out of here, but if you’re interested, you can check out the demo.

const Tooltip = ({ children, message, position = 'center' }) => {
	const ref = useRef(null);
	const [shouldShow, setShouldShow] = useState(false);
	const toggle = () => setShouldShow((val) => !val);

	useLayoutEffect(() => {
		if (!ref.current) return;

+		// Don't bother unless the tip is clipped by the viewport.
+		if (!elementIsOutsideViewport(ref.current)) return;
 });

  return (
    <span className="tooltip-wrapper">
      {shouldShow && (
	      <div 
					className={`tooltip ${position}`}
					ref={ref}
				>
          {message}
        </div>
      )}

      {children({ toggle })}
    </span>
  );
};

And finally, if the element is being clipped by the viewport, we can run through all possible positions (”left,” “center”, and “right”) to find the one that offers the most visibility. If that override is ever set, a small piece of state will be used to update the class instead of the position prop.

const Tooltip = ({ children, message, position = 'center' }) => {
	const ref = useRef(null);
	const [shouldShow, setShouldShow] = useState(false);
+	const [overridePosition, setOverridePosition] = useState('');
	const toggle = () => setShouldShow((val) => !val);

	useLayoutEffect(() => {
		if (!ref.current) return;
		if (!elementIsOutsideViewport(ref.current)) return;

+		const position = findMostVisiblePosition(ref.current);
+		setOverridePosition(position);
	});

	return (
		<span className="tooltip-wrapper">
			{shouldShow && (
				<div 
+					className={`tooltip ${overridePosition || position}`}
					ref={ref}
				>
					{message}
				</div>
			)}

			{children({ toggle })}
		</span>
	);
};

After all of that, the tooltip should behave much better, despite being provided a position that would cause it to render outside the viewport. And thanks to useLayoutEffect(), there’s zero risk of the incorrect position ever flashing to the screen.

What Else Am I Missing Out On?

In digging into this, I came across many hooks provided by React that I’ve never even heard of (ex: useSyncExternalStore()???). It’s incredible the seemingly niche use cases some of them cover, and the range in different problems they help to solve.

So, some homework for you: skim through some of them the next time you’re waiting for a synchronous timeout to finish. You’ll be better off for it.

Links


Was this post helpful? Thanks for the feedback!

Get an email whenever I publish a new post or project.

I won't send you spam. Unsubscribe whenever.
my face

Alex MacArthur is a software developer working for Dave Ramsey in Nashville, TN. Soli Deo gloria.

Leave a Comment

0 comments