You added a useLayoutEffect to measure a tooltip, shipped it, and the next time your Next.js (or Remix, or Gatsby) dev server rendered a page on the server, the console lit up:
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.
The warning is correct, the suggested fix ("only use it on the client") is unhelpful, and the obvious workaround โ just switch to useEffect โ quietly reintroduces the visual bug you used useLayoutEffect to kill in the first place. useIsomorphicLayoutEffect is the small hook that resolves the standoff. This post explains exactly why the warning happens, why the two naive fixes are both wrong, and what the one-line hook actually does.
Why useLayoutEffect Exists At All
React gives you two effect hooks that look nearly identical:
-
useEffectruns after the browser has painted. Its callback is queued and fires asynchronously once the frame is on screen. -
useLayoutEffectruns before the browser paints, synchronously, right after React has mutated the DOM but before the user sees anything.
That timing difference is the whole point. If you need to read layout โ getBoundingClientRect, scrollHeight, the measured width of a node โ and then write a style based on it, you have to do it before paint. Otherwise the user sees one frame of the wrong layout, then a flicker as your useEffect corrects it. The canonical example is a tooltip that has to position itself relative to its own measured size:
function Tooltip({ targetRect, children }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
const { height, width } = ref.current!.getBoundingClientRect();
// place the tooltip above the target, centered
setPos({
top: targetRect.top - height - 8,
left: targetRect.left + targetRect.width / 2 - width / 2,
});
}, [targetRect]);
return <div ref={ref} style={{ position: 'fixed', ...pos }}>{children}</div>;
}
With useLayoutEffect, React measures and repositions in the same synchronous pass, so the tooltip is only ever painted in the right spot. Swap in useEffect and the tooltip flashes at { top: 0, left: 0 } for one frame before jumping into place. On a fast machine you might not notice; on a throttled phone you absolutely will.
Why the Server Hates It
Server-side rendering produces an HTML string. There is no browser, no DOM, no layout phase, and โ critically โ nothing ever paints. The entire reason useLayoutEffect exists is to run synchronously before a paint that, on the server, never comes.
So React makes a deliberate choice: useLayoutEffect callbacks do not run during server rendering at all. They can't be meaningfully serialized into the HTML, and running them would do nothing useful. React knows this is a footgun โ your component's server output won't reflect whatever the layout effect would have computed โ so it emits that warning to tell you the server HTML and the intended client UI may not match.
The warning is not a bug in your code. It is React pointing out that you have a hook whose only job is impossible to do on the server.
Why You Can't Just Use useEffect
The first instinct is to silence the warning by switching to useEffect, which React is perfectly happy to run on the server (it just defers the callback). The warning disappears. The flicker comes back.
Remember the timing: useEffect fires after paint. So on the client, after hydration, your measure-then-reposition logic now runs one frame late. The user sees the un-positioned state first, then the correction. You traded a console warning for a visible visual glitch โ a strictly worse outcome, because at least the warning was invisible to users.
The second instinct โ render the component only on the client (typeof window !== 'undefined' guards, dynamic imports with ssr: false, mounting flags) โ works but throws away server rendering for that whole subtree. You lose the SSR HTML, the content is invisible to crawlers until hydration, and you've added a layout-shift on first load. That's a sledgehammer for a hook-selection problem.
The Actual Fix: Branch on Environment
The realization is simple: you want useLayoutEffect's pre-paint timing in the browser, and you want useEffect's "quietly do nothing useful, no warning" behavior on the server. Those are two different hooks, and which one is correct depends entirely on where the code is running.
So pick at module-load time based on whether you're in a browser:
import { useEffect, useLayoutEffect } from 'react';
const isBrowser = typeof window !== 'undefined';
export const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
That is the entire hook. In the browser it is useLayoutEffect โ identical pre-paint, synchronous timing, identical signature. On the server it is useEffect, which React never warns about and which never runs a useless layout pass. "Isomorphic" is the old term for code that runs the same way on server and client; the hook picks the right same-meaning effect for each environment.
ReactUse ships exactly this as useIsomorphicLayoutEffect, so you don't copy-paste the snippet into every project:
import { useIsomorphicLayoutEffect } from '@reactuses/core';
function Tooltip({ targetRect, children }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
// Same code as before โ but no SSR warning, and no client flicker.
useIsomorphicLayoutEffect(() => {
const { height, width } = ref.current!.getBoundingClientRect();
setPos({
top: targetRect.top - height - 8,
left: targetRect.left + targetRect.width / 2 - width / 2,
});
}, [targetRect]);
return <div ref={ref} style={{ position: 'fixed', ...pos }}>{children}</div>;
}
It's a drop-in replacement for useLayoutEffect: same callback, same optional dependency array, same cleanup function. The only thing that changes is that the warning goes away and your client behavior stays identical.
One subtlety: why the branch lives outside render
Notice the isBrowser ? useLayoutEffect : useEffect runs once, at module evaluation, not inside the component. That's deliberate. The Rules of Hooks require that you call the same hooks in the same order on every render. If you wrote if (isBrowser) useLayoutEffect(...) else useEffect(...) inside the component, you'd technically be calling different hooks on server vs client โ and worse, the linter would (rightly) complain about a conditional hook call.
By resolving the choice to a single stable function reference at module load, the component just calls useIsomorphicLayoutEffect(...) unconditionally. isBrowser never changes within a process, so the selected hook is constant for the lifetime of the bundle. Hook order stays stable; the lint rule stays happy.
When To Reach For It (And When Not)
Use useIsomorphicLayoutEffect when all of these are true:
- You need layout-phase timing โ you're measuring or mutating the DOM and the result must be on screen in the first painted frame (tooltips, popovers, autosizing textareas, scroll restoration, focus management, anything where a one-frame flash is visible).
- The component is server-rendered (Next.js, Remix, Astro islands, Gatsby, TanStack Start โ anything that calls
renderToString/renderToPipeableStream). - You want the SSR warning gone without disabling SSR for the subtree.
Do not reach for it as a blanket replacement for useEffect. If your effect doesn't touch layout โ fetching data, subscribing to an event, syncing to localStorage, logging โ plain useEffect is correct and you want its post-paint, non-blocking timing. useLayoutEffect (and therefore the isomorphic version) runs synchronously and blocks paint; overusing it makes your app feel janky for no benefit. The rule of thumb hasn't changed: reach for layout effects only when you'd otherwise see a flicker.
And if a component is genuinely client-only โ it imports window at the top level, or wraps a browser-only library โ rendering it client-side (dynamic(() => ..., { ssr: false })) is still the right tool. useIsomorphicLayoutEffect is for components that do render on the server and just have a layout effect inside them.
The Layout-Timing Family
useIsomorphicLayoutEffect is the base of a small family of effect hooks in ReactUse. Once you understand the SSR-safe layout-effect, the rest fall out naturally:
-
useUpdateLayoutEffectโ a layout effect that skips the first mount and only runs on updates. Internally it wrapsuseLayoutEffectwith a first-mount guard, so it's the layout-phase sibling ofuseUpdateEffect. Handy when the initial DOM is already correct and you only need to react to subsequent prop changes (animating a value to a new position, not into existence). Note that this one usesuseLayoutEffectdirectly โ combine the pattern with anisBrowserbranch if you need it SSR-silent. -
useUpdateEffectโ the same skip-first-render behavior on top ofuseEffect. The everyday "run this on change but not on mount" hook. -
useMountโ runs a callback exactly once after mount. A readable alias foruseEffect(fn, [])when all you mean is "on mount".
There's also a quietly important consumer of this hook inside the library itself. useEvent โ ReactUse's stable-callback hook, the one that gives you an event handler with a permanent identity but always-fresh closure โ uses useIsomorphicLayoutEffect to sync the latest function into a ref before paint:
const handlerRef = useRef(fn);
useIsomorphicLayoutEffect(() => {
handlerRef.current = fn;
}, [fn]);
Writing the ref in the layout phase guarantees that if any child fires the handler during its layout effect, it already sees the newest version โ and doing it isomorphically means useEvent itself never trips the SSR warning. It's a good illustration of why a library hook reaches for the isomorphic variant by default: you don't know which environment your consumers run in, so you pick the one that's correct in both.
Takeaways
- The warning "useLayoutEffect does nothing on the server" is React telling you a pre-paint hook can't run where there's no paint. It's accurate, not a false alarm.
- Switching to
useEffectsilences the warning but reintroduces a one-frame flicker on the client, becauseuseEffectruns after paint. -
useIsomorphicLayoutEffectresolves both: it isuseLayoutEffectin the browser anduseEffecton the server, chosen once at module load so hook order stays stable. - Use it for layout measurement/mutation in server-rendered components; keep plain
useEffectfor everything that doesn't touch layout. - ReactUse ships it (and the related
useUpdateLayoutEffect,useUpdateEffect,useMount) so you don't reinvent the one-liner โ and uses it internally to keep its own hooks SSR-safe.
Browse the full set of SSR-safe effect hooks at reactuse.com, and drop useIsomorphicLayoutEffect in wherever a useLayoutEffect is making your server console nervous.













