All posts

use(browser()): React 19.3 just retired typeof window

React 19.3 shipped with a small API that caught my attention more than the rest: use(browser()), an official way to say a component only exists in the browser. In this post, I go over the problem it solves, the ways we worked around it until now, and why it doesn't fix everything it seems to.

4 min read

On this page

React 19.3 came out on September 9 and, reading the release, the feature I kept coming back to was the smallest one: use(browser()). It's an official way to tell React a component only exists in the browser.

It sounds like a small thing, but it's the end of a hack almost every SSR project has tucked away somewhere: good old typeof window !== 'undefined'.

Before getting to the new API, it's worth understanding the problem it tries to solve.

What a hydration mismatch is

With SSR, the server renders the page's HTML and sends it to the browser. The browser shows that HTML right away, builds the component tree again and compares it with what arrived. That process is hydration.

If the first client render differs from the server's, React complains. And some components differ by definition. The classic example is a theme toggle that reads the saved preference from localStorage: the server has no localStorage, so it has no way of knowing you prefer dark mode.

theme-toggle.tsx
export function ThemeToggle() {
const theme =
typeof window !== "undefined"
? localStorage.getItem("theme") ?? "light"
: "light";

return <button>{theme === "dark" ? "🌙 dark" : "☀️ light"}</button>;
}

typeof window does exactly what React doesn't want: false there, true here. The server sends "light", the browser renders "dark", and there's your mismatch.

Hit play below to watch it happen, then compare it with the other approaches:

view-source: the HTML that arrived
Themelight

There's no window on the server, so the component falls back to the default: light theme.

Theme saved in localStorage: dark. Pick an approach and hit play.

Three ways around it (until now)

Up to 19.3, you solved this in one of three ways, none of them pretty.

The mounted flag in useEffect

This is the one I see most. A mounted state that starts as false and flips to true in a useEffect:

theme-toggle.tsx
export function ThemeToggle() {
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
}, []);

if (!mounted) return null;

const theme = localStorage.getItem("theme") ?? "light";
return <button>{theme === "dark" ? "🌙 dark" : "☀️ light"}</button>;
}

It works, but look closely: it's an effect with no effect. It only exists to mark that hydration is done. And since the component renders nothing until then, whatever comes after it on the page jumps when it shows up.

dynamic with ssr: false

In Next.js you can solve it from outside the component, asking for it not to be rendered on the server at all:

header.tsx
import dynamic from "next/dynamic";

const ThemeToggle = dynamic(() => import("./theme-toggle"), { ssr: false });

The component stays clean, but the "this only runs in the browser" decision moves to whoever imports it. And if you forget the loading option, the spot is empty in the HTML all the same.

useSyncExternalStore with getServerSnapshot

This one is considered the correct way. useSyncExternalStore takes a third argument, getServerSnapshot, which is used on the server and during hydration:

theme-toggle.tsx
const subscribe = () => () => {};

export function ThemeToggle() {
const theme = useSyncExternalStore(
subscribe,
() => localStorage.getItem("theme") ?? "light", // browser
() => "light" // server and hydration
);

return <button>{theme === "dark" ? "🌙 dark" : "☀️ light"}</button>;
}

That gets rid of the mismatch and the fake effect. But right after hydration, React reads the real value, notices it changed and forces a synchronous update with a re-render. In practice, someone who prefers dark mode sees light flash first. That's a lot of ceremony to say one thing. 😒

Enter use(browser())

The new API is imported from react-dom and used with use:

theme-toggle.tsx
import { use } from "react";
import { browser } from "react-dom";

export function ThemeToggle() {
use(browser());

const theme = localStorage.getItem("theme") ?? "light";
return <button>{theme === "dark" ? "🌙 dark" : "☀️ light"}</button>;
}
header.tsx
<Suspense fallback={<ToggleSkeleton />}>
<ThemeToggle />
</Suspense>

Here's how it behaves:

  • On the server, the component suspends and the nearest Suspense writes its fallback into the HTML.
  • In the browser, the call returns undefined and rendering continues. You can use localStorage, window and friends without worry.
  • If there's no Suspense above it, the server render fails outright, instead of quietly falling back to nothing.

I really like that last point. React makes you decide what shows up before the browser takes over.

This blog already runs on React 19.3, so you can see the API for real. The counter below reads and writes localStorage right in render, with no useEffect and no typeof window. Click it a few times and reload the page:

This page's HTML only has the skeleton. The counter only exists in your browser.

If you open the page source, you'll see only the skeleton where the counter is. Lovely! 🤩

But it doesn't fix what it looks like it fixes

use(browser()) doesn't make the component exist on the server. It still has two states over time: the fallback and the content. And the fallback is what decides whether the page jumps.

Pass null there and you're back to the mounted problem, just with a newer API:

view-source: the HTML that arrived

Same API, but now with fallback={null}. The server HTML has nothing in that spot.

The same use(browser()), with two different fallbacks.

With a skeleton the same size as the component, the space is already reserved in the HTML and nothing moves when the content arrives. With null, everything below it shifts down.

Why I still like it

Even without any magic, I like the idea. It forces you to write the fallback in a declared spot, instead of hiding the server/browser boundary inside a useEffect. Anyone reading the component sees use(browser()) on the first line and knows what's going on.

For libraries this matters even more, because it becomes a shared vocabulary. Wasp already has an issue open to swap their useSyncExternalStore trick for this API, precisely because of the synchronous update and the extra re-render.

Wrapping up

Next time you're about to write a typeof window or a mounted, give use(browser()) a try. Just put some care into the fallback, because that's what your users see first.

The API docs are short and worth a read. If you've already swapped a hack for it, tell me how it went.