smoores.dev

Building stuff on the Internet.

There’s no such thing as an isomorphic layout effect

Feb. 22, 2025

So, recently, React ProseMirror added support for server-side rendering. If you read my post about how React ProseMirror works, you may already know that React ProseMirror relies fairly heavily on React’s useLayoutEffect hook for reading data from the DOM after render. And if you’re familiar with server-side rendering, you may be familiar with what happens when you render a component that uses useLayoutEffect on the server:

Warning: useLayoutEffect does nothing on the server, because its effect
cannot be encoded into the server renderer's output format. This will
lead to a mismatch between the initial, non-hydrated UI and the intended
UI. To avoid this, useLayoutEffect should only be used in components
that render exclusively on the client. See
https://reactjs.org/link/uselayouteffect-ssr for common fixes.

It’s worth breaking down what this warning is actually trying to communicate, because it’s not especially straightforward. To start, we should review what useLayoutEffect is actually for. Like other React hooks, useLayoutEffect provides a mechanism for managing side effects. In particular, as the name implies, layout effects are meant to be side effects that read from the DOM, usually for the purpose of modifying the layout of a component. To allow this, React will execute a component’s render function, commit the changes to the DOM, and then immediately run its layout effects before the browser paints those DOM updates. This means that something like a tooltip component can evaluate the position of its anchor in a layout effect, update its state to reflect that position, and be re-rendered with that new state, all without the user ever seeing the tooltip in the wrong place.

Now let’s walk through what happens when we server-side render a component like this. Below, we have an example application that uses a layout effect to position a tooltip:

App.tsx
import { useLayoutEffect } from "react";

export function App() {
const [tooltipTop, setTooltipTop] = useState(0);
const [tooltipLeft, setTooltipLeft] = useState(0);

const anchorRef = useRef<HTMLDivElement | null>(null);

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

const rect = anchorRef.current.getBoundingClientRect();
setTooltipTop(rect.top);
setTooltipLeft(rect.left);
}, []);

return (
<article>
<h1>Positioned Tooltip Demo</h1>
<div ref={anchorRef} />
<p>A tooltip should be positioned above this paragraph.</p>
<div style={{ position: "absolute", top, left }}>This is the tooltip</div>
</article>
);
}

Because we’re using a layout effect, this component will actually be rendered twice on mount, with both renders occurring before the DOM has even been painted once. The result is that the tooltip will be correctly positioned on the very first paint, with the user only ever visually seeing a DOM represented by the following HTML:

client.html
<article>
<h1>Positioned Tooltip Demo</h1>
<div></div>
<p>A tooltip should be positioned above this paragraph.</p>
<div style="position: absolute; top: 50px; left: 8px;"></div>
</article>

But what happens when we render this component on the server? There is no DOM on the server at all, so React never executes layout effects. Instead, the component is rendered exactly once, using the default values for our state:

server.html
<article>
<h1>Positioned Tooltip Demo</h1>
<div></div>
<p>A tooltip should be positioned above this paragraph.</p>
<div style="position: absolute; top: 0; left: 0;"></div>
</article>

This means that in a server-side rendered context, until the client-side JavaScript bundle is loaded, parsed, and executed, the user will be looking at the wrong UI. The tooltip will simply be in the wrong place (at 0, 0). It will look broken!

This is precisely the issue that React was trying to warn us about. Because effect hooks don’t execute on the server at all, server-side rendered UIs that rely on them may appear broken until they’re hydrated on the client. Following the link from the warning message takes us to a GitHub Gist with two proposed solutions: replacing the useLayoutEffect with a useEffect, and conditionally rendering the component that uses useLayoutEffect only on the client. For our tooltip example, we should use the second option — it’s better to simply not render the tooltip at all until the client-side JavaScript has a chance to run and determine where it should be positioned.

Not all layout effects actually need to modify the layout, though. React ProseMirror, for example, uses layout effects internally to maintain ProseMirror’s view descriptor tree, which is roughly analogous to React’s virtual DOM. Because this requires reading from the DOM, but not modifying it, it’s actually safe to include in a server-side rendered component. But it’s a huge pain to fill up users’ server logs with warnings about useLayoutEffect that they can’t (and don’t need to) do anything about!

If you’ve been around the server-side rendering block once or twice, you can probably see where this is going. The use-isomorphic-layout-effect library, or other implementations of it available from other popular libraries, is often the first tool that developers reach for when they encounter this warning. Let’s take a look at its implementation:

use-isomorphic-layout-effect/src/index.ts
import { useEffect, useLayoutEffect } from 'react'
import isClient from '#is-client'

export default isClient ? useLayoutEffect : useEffect

Very simple! The library only runs useLayoutEffect if the code is running on the client (in the browser, this determined via typeof document !== "undefined"). On the server, instead, it runs… useEffect, instead? That’s sort of odd. Effects never execute on the server — why would we bother running useEffect there?

And it’s not just this library that’s made this somewhat odd choice of no-op. Here’s react-use’s implementation:

react-use
const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;

The Mantine design system:

mantine
export const useIsomorphicEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;

React Beautiful DnD:

react-beautiful-dnd
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'
? useLayoutEffect
: useEffect;

In case it’s not clear why I’m so fascinated by this choice, here’s React ProseMirror’s implementation:

useClientLayoutEffect.ts
import { useLayoutEffect } from "react";

export function useClientLayoutEffect(
...args: Parameters<typeof useLayoutEffect>
) {
if (typeof document === "undefined") return;

useLayoutEffect(...args);
}

This implementation has precisely the same behavior as the implementations above. On the client, it calls useLayoutEffect, and on the server, it does nothing. I didn’t name it “isomorphic”, because it’s not really isomorphic — at least in the sense of “Isomorphic JavaScript”, which describes JavaScript code that runs on both the client and the server — as it doesn’t run on the server at all!

Just to be clear, this doesn’t really matter. I’m not arguing that no one should ever use use-isomorphic-layout-effect, or that all of these libraries need to change their implementations of this function to use an explicit no-op instead of useEffect on the server. I am, however, curious about where this surprisingly ubiquitous quirk of the React ecosystem came from. And I have a hypothesis.

In February of 2019, the React team released React 16.8, the first stable release of React that included hooks. Two months later, React Redux released their v7, which included a new hooks-based integration between React and Redux. And wouldn’t you know it:

connectAdvanced.js
// React currently throws a warning when using useLayoutEffect on the server.
// To get around it, we can conditionally useEffect on the server (no-op) and
// useLayoutEffect in the browser. We need useLayoutEffect because we want
// `connect` to perform sync updates to a ref to save the latest props after
// a render is actually committed to the DOM.
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect

...

// We need this to execute synchronously every time we re-render. However, React warns
// about useLayoutEffect in SSR, so we try to detect environment and fall back to
// just useEffect instead to avoid the warning, since neither will run anyway.
useIsomorphicLayoutEffect(() => {

Make sure to read those comments — the React Redux team seems fully aware that useEffect is a mere no-op here. React Beautiful DnD’s implementation actually directly references this React Redux code. Other implementations likely either copied from one of these two popular libraries, or from this Medium post from a few weeks later.

From what I can tell, a very popular, well maintained library made an early, arbitrary implementation decision. Because copying this library felt like a safe bet to other library maintainers, this arbitrary decision became the de facto implementation for this workaround. A Medium post about this implementation became so widely read that it’s still the number one Google result for the query “useLayoutEffect ssr warning”, several slots above the GitHub Gist discussing the correct solution for most use cases.

Even though I had an explanation, this kept itching at me. This is partly due to the description of the use-isomorphic-layout-effect library, which reads:

A React helper hook for scheduling a layout effect with a fallback to a regular effect for environments where layout effects should not be used (such as server-side rendering).

There is no mention here that useEffect is a mere no-op in those situations. It also seems to describe the problem space somewhat incorrectly — if a given layout effect actually should not be used in server-side rendering, then the component using it almost certainly should not be server-side rendered at all. Falling back to a plain effect in that situation is precisely as incorrect as using a layout effect — only without a warning to guide you toward the correct solution.

react-use’s useIsomorphicLayoutEffect hook has a somewhat more accurate description:

useLayoutEffect that does not show warning when server-side rendering, see Alex Reardon’s article for more info.

But it also lacks any detail about when it’s appropriate to use this hook in place of useLayoutEffect. And, worse, on the main README for react-use, the description for the hook reads:

useLayoutEffect that that [sic] works on server.

Which is not correct. This hook, like all other “isomorphic” layout effect hooks, has exactly the same behavior as useLayoutEffect, minus the warning. It does not work on the server!

I may be reading far too much into this very scant story, but I began to see a narrative unfold the further I looked into this:

A maintainer for a very popular open source library, in the midst of a big refactor, made an essentially arbitrary decision to work around a noisy warning that wasn’t relevant to their use case. They seem to have done this with full knowledge that their decision was arbitrary, and left a comment explaining it.

Another maintainer for a similarly popular open source library also needed to work around the warning, which was similarly irrelevant to their use case. They saw this workaround and decided to copy it as-is, leaving only link to the original (which has since been replaced) as explanation.

A developer, frustrated by the warning, found these libraries’ workaround and authored a short blog post touting it as a way to quiet the warning. They seem to at least somewhat misunderstand the purpose of the warning (or maybe they fully understand it, but didn’t fully explain), and don’t clarify in their post that the choice of useEffect is essentially arbitrary.

As more developers migrated to use React hooks, more developers ran into this warning and began searching for solutions. Some of them published the solution from Reardon’s blog post in their own libraries, and others found Reardon’s post and implemented his approach themselves.

At each step in the saga, there’s less and less context. Even though the warning itself links to a GitHub Gist that explains the issue and solutions quite well, searching the language of the warning will retrieve Reardon’s post and other solutions before the linked Gist from the React team.

As a result, the de facto solution to this “problem” doesn’t have sufficient context for users to understand how to use it effectively. The hugely popular React-Select library, for example, incorrectly uses use-isomorphic-layout-effect to position and scroll a menu, when it should instead avoid rendering the menu on the server at all. And I’m not trying to pick on React-Select — it seems likely that this is almost never an actual bug for them, since menus are likely always collapsed during the server render. But that is precisely the use case that the React team had in mind when they added the useLayoutEffect warning!

To me at least, this is a reminder of why it’s important to understand why our code does what it does. It can be tempting to sit back and let sleeping dogs lie after finally finding the solution to a confounding bug. But it’s all too easy for those incomplete understandings to build up and slowly shift our intuition over time, until we find that our mental model of our problem space doesn’t match reality any longer.

Oh, and React ProseMirror doesn’t trigger the layout effect warning during server-side rendering anymore!