Skip to main content

React 19.3 and browser(): the end of useEffect(() => setMounted(true))

· 11 min read
Bruno Carneiro
Fundador da @TautornTech

If you've ever worked with React and server-side rendering (SSR), you've almost certainly written this:

const [mounted, setMounted] = useState(false);

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

if (!mounted) return null;

Or this other version, which looks simpler and is worse:

if (typeof window === 'undefined') return null;

Both exist for the same reason: some components simply have nothing to show on the server. They depend on localStorage, on the user's time zone, on the screen size, on a library that touches window. And React never had an official way to say "this one, browser only".

React 19.3, released on September 9, 2026, changed that. Meet browser().

The problem browser() solves​

With SSR, every component runs twice, in two different places:

  1. On the server: it produces the initial HTML, which the user sees before any JavaScript loads.
  2. In the browser: React "hydrates" that HTML, meaning it reuses what arrived ready-made and wires up the events.

For hydration to work, the first render in the browser has to produce exactly the same HTML the server produced. If it doesn't match, you get the famous hydration mismatch error, and React throws away the server HTML and renders that piece from scratch.

Now picture a component that shows the last update time in the user's time zone. The server runs in UTC, the user is in São Paulo. The server outputs "17:32", the browser outputs "14:32". No match.

The workarounds we've been using so far:

  • typeof window: returns one thing on the server and another in the browser. That's literally the recipe for a hydration mismatch. It works "by accident" when the component returns something similar in both cases, but it's fragile.
  • A mounted flag with useEffect: it works, but the component renders twice in the browser (first empty, then with content), it needs an Effect just for that, and you can't use browser values on the first render, not even in a useState initializer.
  • A framework option: dynamic(() => import('./Map'), { ssr: false }) in Next.js, client:only in Astro. It works, but every framework has its own, and it always works at the module level: you can't decide based on a prop.

What browser() is​

It's a function from react-dom that you pass to use():

import { use } from 'react';
import { browser } from 'react-dom';

function TimeZone() {
use(browser());
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;

return <p>{timeZone}</p>;
}

And the behavior is simple:

  • On the server: use(browser()) suspends the component. React stops rendering there and puts the nearest <Suspense> boundary's fallback in the HTML.
  • In the browser: use(browser()) returns undefined and the component renders as usual.

In other words, it reuses a mechanism React already has: Suspense. To React, "this component only exists in the browser" becomes just another kind of "not ready yet". The same thing you already use to show a loading state while data arrives.

So usage looks like this:

export default function App() {
return (
<>
<p>Your time zone is:</p>
<Suspense fallback={<p>Loading...</p>}>
<TimeZone />
</Suspense>
</>
);
}

The server HTML ships with "Loading...". When the JavaScript loads, React renders TimeZone straight away with the real value. No mismatch, no Effect, no empty render.

The reason parameter​

browser() takes an optional reason, a string or a function:

use(browser('The draft is stored in localStorage.'));

// if building the reason is expensive, use a function: it only runs on the server
use(browser(() => new Error('No default time zone for this user')));

The reason never goes into the HTML. It's there for observability, which I'll show further down.

I tested it to see what ends up in the HTML​

I ran renderToPipeableStream with react-dom@19.3.1 to see what actually reaches the browser:

<div>
<h1>Hi</h1>
<!--$!-->
<template data-msg="Switched to client rendering because a component requested it."></template>
Loading...
<!--/$-->
</div>

The <!--$!--> is the marker React uses to say "this Suspense boundary will be rendered on the client". It's the same mechanism React already used when a component errored on the server. The difference is that now it's intentional: it doesn't call the server's onError or hydrateRoot's onRecoverableError. It doesn't pollute your error logs.

The advantages, in practice​

1. No more "accidental" hydration mismatches. The component simply doesn't render on the server, so there's nothing to compare.

2. A single render in the browser. With the mounted pattern, the browser renders the component empty, runs the Effect, updates state, and renders again. With browser(), the boundary is rendered on the client directly, with the final content.

3. You can read browser APIs on the first render. It sounds like a detail, but it changes the code. This example, from the official docs, wouldn't work with the mounted pattern:

function SavedDraft() {
use(browser('The draft is stored in localStorage.'));
const [draft, setDraft] = useState(
() => localStorage.getItem('draft') ?? ''
);

return (
<textarea
value={draft}
onChange={(e) => {
setDraft(e.target.value);
localStorage.setItem('draft', e.target.value);
}}
/>
);
}

localStorage is read right in the useState initializer. No useEffect to sync it afterwards.

4. It can be conditional. Like any use(), it can sit inside an if or after an early return. That's something no framework option allows:

function useTimeZone(defaultTimeZone) {
if (defaultTimeZone !== undefined) {
return defaultTimeZone; // renders on the server as usual
}

use(browser('No default time zone was provided.'));
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}

If the server already knows the answer (from a cookie, the user's profile, a loader), the component goes into the HTML. If it doesn't, it waits for the browser.

5. It fits into the loading flow you already have. Since it's Suspense, the fallback of a browser-only component coordinates with other components waiting for data. One skeleton for the whole section, instead of each piece popping in at a different moment.

6. It works with any SSR setup. It doesn't depend on Next.js, Remix, Astro or anything else. It's React.

Use cases​

1. Dates and times in the user's time zone​

The classic case. In a personal finance app I maintain, the investments screen shows "Last updated today at 14:32" and whether the stock exchange is open or closed. All of that depends on the clock and time zone of whoever is looking. Today that app is a Vite SPA, so the problem doesn't exist. But if it moved to SSR, that would be the first thing to break.

With browser(), the time component looks like this:

function LastUpdated({ date }) {
use(browser('Depends on the user time zone.'));
const time = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }).format(date);

return <span>Last updated today at {time}</span>;
}

2. Libraries that touch window​

Maps (Leaflet), code editors (Monaco), some charting libraries. Many of them access window or document the moment they're imported, so they break the server just by being in the bundle.

There's an important detail here: browser() prevents the render on the server, not the import. If the library breaks on import, combine it with lazy:

const Map = lazy(() => import('./Map'));

function StoreMap() {
use(browser('Leaflet needs window.'));
return <Map />;
}

Since StoreMap suspends before rendering <Map />, the server never gets to call import().

3. Data fetching that only runs on the server when there's initial data​

This is the example I found most interesting, and it's also in the docs. A data-fetching hook that goes into the HTML when the server already has the data, and waits for the browser when it doesn't:

function useBrowserQuery(query, options) {
if (options.initialData === undefined) {
use(browser('useBrowserQuery: No initial data was provided.'));
}
return useQuery(query, options);
}

function ProductDetails({ productId, initialData }) {
const product = useBrowserQuery(`/api/products/${productId}`, { initialData });
return <h1>{product.name}</h1>;
}

Product page coming from the loader: it goes into the HTML, good for SEO. The same component in a modal the user opens, with no initial data: it fetches in the browser. One component, two behaviors, and no if (typeof window) anywhere.

4. Slow server? Let the browser finish​

This one is less obvious. You can pass browser() as the reason when aborting a server render. Everything still pending keeps its fallback and finishes in the browser:

const { pipe, abort } = renderToPipeableStream(<App />, {
onShellReady() {
pipe(response);
setTimeout(() => {
abort(browser('The server render took more than 10 seconds.'));
}, 10000);
},
});

Before, aborting a render was treated as an error. Now it's a planned path: "server, give up, the browser takes over".

5. Knowing how much of your page doesn't ship in the HTML​

Every time a component asks to render only in the browser, the server calls onBrowserBailout:

renderToPipeableStream(<App />, {
onBrowserBailout(error, errorInfo) {
// error.cause is the reason you passed
metrics.increment('ssr.browser_only', { reason: error.cause });
console.log(errorInfo.componentStack);
},
});

In my test, error.cause came back with exactly the text I passed to browser('needs tz'). That's very useful: you can measure how much of the page is leaving the HTML, and catch someone accidentally putting a browser() in the middle of content that should be indexed.

The gotchas I ran into while testing​

Without Suspense, the server breaks. If there's no <Suspense> boundary above it, the render fails with: "The server render could not complete because client rendering was requested outside a Suspense boundary". And that goes to onError, not onBrowserBailout.

The whole boundary moves to the browser, not just the component. This is the most important one. I tried putting a regular component next to one using browser(), inside the same <Suspense>:

<Suspense fallback="Loading...">
<Price /> {/* regular component */}
<TimeZone /> {/* use(browser()) */}
</Suspense>

<Price /> also disappeared from the HTML. It makes sense: what decides what goes into the HTML is the Suspense boundary, not the component. So keep the <Suspense> as close as possible to the browser-only component. Otherwise you'll pull out of the HTML a part of the page that didn't need to leave.

Client Components only. It doesn't work in a Server Component. You need 'use client', or move the call into a child component.

browser() on its own does nothing. You have to pass it to use(). And don't throw it either.

If it's an SPA, you don't need it. If your app is plain Vite or Create React App without SSR, everything already runs in the browser. browser() changes nothing for you.

Stable or canary? Some write-ups say the API is still canary-only. I installed react-dom@19.3 from npm (the latest tag, currently 19.3.1) and browser is already exported and working. Check your project's version anyway.

When not to use it​

browser() is an opt-out. It takes content out of the HTML. So:

  • Don't use it on content that needs SEO, or that is the main content of the page (what counts for LCP). It moves to the browser and arrives later.
  • Don't use it to hide a mismatch you could fix. If the server can learn the user's time zone from a cookie, or the theme from a saved preference, send that to the server and render it in the HTML. browser() is for when the server really has no way of knowing.
  • Prefer the smallest possible boundary, as we saw in the gotchas.

The rest of React 19.3, quickly​

browser() didn't come alone. The same release brought:

  • Stable View Transitions: <ViewTransition> animates elements as they enter, exit and update, integrated with Suspense.
  • Stable Fragment Refs: a ref on a <Fragment> to work with a group of sibling elements (focus, events, observers) without adding an extra div.
  • Trusted Types support: React passes values to the DOM without coercing them to strings, so the browser's Trusted Types policies work.
  • Context in Server Components: you can render a client Context directly from a Server Component.

Each of these could be its own article. Maybe they will be.

Conclusion​

browser() doesn't enable anything that was impossible before. We already did all of this with mounted, typeof window and ssr: false. What it brings is an official name for a pattern every SSR project kept reinventing, and with it:

  • No hydration mismatch from components that depend on the browser.
  • A single render, with access to browser APIs from the very first one.
  • Conditional: the same component can go into the HTML or not, depending on what the server knows.
  • Built on Suspense: the loading state plays along with the rest of the page.
  • Observable: onBrowserBailout tells you exactly what left the HTML and why.
  • Framework-agnostic: it works with any SSR setup.

My tip: search your project for setMounted(true) and typeof window. Each one is a candidate to become a single line: use(browser()). Just don't forget the <Suspense> right next to it.

References​