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.
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:
There's no window on the server, so the component falls back to the default: light theme.
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:
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:
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:
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:
Here's how it behaves:
- On the server, the component suspends and the nearest
Suspensewrites its fallback into the HTML. - In the browser, the call returns
undefinedand rendering continues. You can uselocalStorage,windowand 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:
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:
Same API, but now with fallback={null}. The server HTML has nothing in that spot.
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.