React 19.3 e o browser(): o fim do useEffect(() => setMounted(true))
Se você já trabalhou com React e renderização no servidor (SSR), com certeza já escreveu isso aqui:
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
Ou essa outra versão, que parece mais simples e é pior:
if (typeof window === 'undefined') return null;
As duas existem pelo mesmo motivo: alguns componentes simplesmente não têm o que mostrar no servidor. Eles dependem do localStorage, do fuso horário do usuário, do tamanho da tela, de uma biblioteca que mexe no window. E o React nunca teve um jeito oficial de dizer "esse aqui, só no navegador".
No React 19.3, lançado em 9 de setembro de 2026, isso mudou. Chegou o browser().
O problema que o browser() resolve
Com SSR, todo componente roda duas vezes, em dois lugares diferentes:
- No servidor: gera o HTML inicial, que o usuário vê antes de qualquer JavaScript carregar.
- No navegador: o React "hidrata" esse HTML, ou seja, reaproveita o que veio pronto e liga os eventos.
Para a hidratação funcionar, o primeiro render no navegador precisa produzir exatamente o mesmo HTML que o servidor produziu. Se não bater, você toma o famoso erro de hydration mismatch, e o React joga fora o HTML do servidor e renderiza aquele pedaço do zero.
Agora pensa num componente que mostra a hora da última atualização no fuso do usuário. O servidor está em UTC, o usuário está em São Paulo. O servidor gera "17:32", o navegador gera "14:32". Não bate.
As soluções que a gente usava até agora:
typeof window: no servidor retorna uma coisa, no navegador retorna outra. É literalmente a receita do hydration mismatch. Funciona "por acidente" quando o componente retornanullnos dois casos parecidos, mas é frágil.- Flag
mountedcomuseEffect: funciona, mas o componente renderiza duas vezes no navegador (primeiro vazio, depois com conteúdo), precisa de um efeito só para isso e você não consegue usar o valor do navegador no primeiro render, nem num inicializador deuseState. - Opção do framework:
dynamic(() => import('./Mapa'), { ssr: false })no Next.js,client:onlyno Astro. Funciona, mas cada framework tem a sua, e é sempre no nível do módulo: não dá para decidir com base numa prop.
O que é o browser()
É uma função do react-dom que você passa para o use():
import { use } from 'react';
import { browser } from 'react-dom';
function FusoHorario() {
use(browser());
const fuso = Intl.DateTimeFormat().resolvedOptions().timeZone;
return <p>{fuso}</p>;
}
E o comportamento é simples:
- No servidor:
use(browser())suspende o componente. O React para de renderizar ali e coloca no HTML ofallbackdo<Suspense>mais próximo. - No navegador:
use(browser())retornaundefinede o componente segue normalmente.
Ou seja, ele reaproveita um mecanismo que o React já tem: o Suspense. Para o React, "esse componente só existe no navegador" passa a ser só mais um tipo de "ainda não está pronto". O mesmo jeito que você já usa para mostrar loading enquanto um dado chega.
Então o uso fica assim:
export default function App() {
return (
<>
<p>Seu fuso horário é:</p>
<Suspense fallback={<p>Carregando...</p>}>
<FusoHorario />
</Suspense>
</>
);
}
O HTML do servidor sai com "Carregando...". Quando o JavaScript carrega, o React renderiza o FusoHorario direto com o valor real. Sem mismatch, sem efeito, sem render vazio.
O parâmetro reason
O browser() aceita um motivo opcional, uma string ou uma função:
use(browser('O rascunho fica no localStorage.'));
// se montar o motivo for caro, use uma função: só roda no servidor
use(browser(() => new Error('Sem fuso padrão para este usuário')));
Esse motivo não vai para o HTML. Ele serve para observabilidade, que eu mostro mais abaixo.
Testei para ver o que sai no HTML
Rodei um renderToPipeableStream com o react-dom@19.3.1 para ver o que de fato chega ao navegador:
<div>
<h1>Hi</h1>
<!--$!-->
<template data-msg="Switched to client rendering because a component requested it."></template>
Loading...
<!--/$-->
</div>
O <!--$!--> é o marcador que o React usa para dizer "essa fronteira de Suspense vai ser renderizada no cliente". É o mesmo mecanismo que ele já usava quando um componente dava erro no servidor. A diferença é que agora isso é intencional: não chama o onError do servidor nem o onRecoverableError do hydrateRoot. Não polui seu log de erros.
As vantagens, na prática
1. Acabou o hydration mismatch "acidental". O componente simplesmente não renderiza no servidor, então não tem o que comparar.
2. Um render só no navegador. Com o padrão mounted, o navegador renderiza o componente vazio, roda o efeito, muda o estado e renderiza de novo. Com o browser(), a fronteira já é renderizada no cliente direto, com o conteúdo final.
3. Você pode ler APIs do navegador no primeiro render. Isso parece detalhe, mas muda o código. Esse exemplo, da própria documentação, não funcionaria com o padrão mounted:
function Rascunho() {
use(browser('O rascunho fica no localStorage.'));
const [texto, setTexto] = useState(
() => localStorage.getItem('rascunho') ?? ''
);
return (
<textarea
value={texto}
onChange={(e) => {
setTexto(e.target.value);
localStorage.setItem('rascunho', e.target.value);
}}
/>
);
}
O localStorage é lido direto no inicializador do useState. Sem useEffect para sincronizar depois.
4. Pode ser condicional. Como todo use(), ele pode ficar dentro de um if ou depois de um return. Isso é o que nenhuma opção de framework permite:
function useFusoHorario(fusoPadrao) {
if (fusoPadrao !== undefined) {
return fusoPadrao; // renderiza no servidor normalmente
}
use(browser('Nenhum fuso padrão foi informado.'));
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
Se o servidor já sabe a resposta (porque veio de um cookie, do perfil do usuário, do loader), o componente vai no HTML. Se não sabe, espera o navegador.
5. Entra no fluxo de loading que você já tem. Como é Suspense, o fallback do componente que só existe no navegador se coordena com os outros componentes que estão esperando dados. Um skeleton só para a seção inteira, em vez de cada pedaço aparecendo num momento.
6. Funciona em qualquer SSR. Não depende de Next.js, Remix, Astro ou do que for. É do React.
Casos de uso
1. Data e hora no fuso do usuário
O caso clássico. Num app de finanças que eu mantenho, a tela de investimentos mostra "Última atualização hoje às 14:32" e se a bolsa está aberta ou fechada. Tudo isso depende do relógio e do fuso de quem está olhando. Hoje esse app é uma SPA com Vite, então o problema não existe. Mas se ele passasse a ter SSR, esse seria o primeiro lugar a quebrar.
Com o browser(), o componente da hora fica assim:
function UltimaAtualizacao({ data }) {
use(browser('Depende do fuso do usuário.'));
const hora = new Intl.DateTimeFormat('pt-BR', { timeStyle: 'short' }).format(data);
return <span>Última atualização hoje às {hora}</span>;
}
2. Bibliotecas que mexem no window
Mapas (Leaflet), editores de código (Monaco), alguns gráficos. Muitas acessam window ou document no momento em que são importadas, e aí quebram o servidor só de existir no bundle.
Aqui tem um detalhe importante: o browser() impede o render no servidor, mas não impede o import. Se a biblioteca quebra no import, junte com lazy:
const Mapa = lazy(() => import('./Mapa'));
function MapaDaLoja() {
use(browser('O Leaflet precisa do window.'));
return <Mapa />;
}
Como o MapaDaLoja suspende antes de renderizar o <Mapa />, o servidor nunca chega a chamar o import().
3. Busca de dados que só roda no servidor quando tem dado inicial
Esse é o exemplo que eu achei mais interessante, e que também está na documentação. Um hook de busca que vai para o HTML quando o servidor já tem o dado, e espera o navegador quando não tem:
function useBrowserQuery(query, options) {
if (options.initialData === undefined) {
use(browser('useBrowserQuery: sem dados iniciais.'));
}
return useQuery(query, options);
}
function DetalhesDoProduto({ productId, initialData }) {
const produto = useBrowserQuery(`/api/products/${productId}`, { initialData });
return <h1>{produto.name}</h1>;
}
Página de produto vinda do loader: vai no HTML, bom para SEO. O mesmo componente num modal aberto pelo usuário, sem dado inicial: busca no navegador. Um componente, dois comportamentos, sem if (typeof window) em lugar nenhum.
4. Servidor lento? Deixa o navegador terminar
Esse é menos óbvio. Você pode passar o browser() como motivo do abort do render no servidor. Tudo que ainda estava pendente fica com o fallback e termina no navegador:
const { pipe, abort } = renderToPipeableStream(<App />, {
onShellReady() {
pipe(response);
setTimeout(() => {
abort(browser('O render no servidor passou de 10 segundos.'));
}, 10000);
},
});
Antes, abortar o render era tratado como erro. Agora é um caminho previsto: "servidor, desiste, o navegador assume".
5. Saber quanto da sua página não vai no HTML
Toda vez que um componente pede para renderizar só no navegador, o servidor chama o onBrowserBailout:
renderToPipeableStream(<App />, {
onBrowserBailout(error, errorInfo) {
// error.cause é o reason que você passou
metrics.increment('ssr.browser_only', { reason: error.cause });
console.log(errorInfo.componentStack);
},
});
No meu teste, o error.cause veio exatamente com o texto que eu passei no browser('needs tz'). Isso é muito útil: dá para medir quanto da página está saindo do HTML e se alguém, sem querer, colocou um browser() no meio de um conteúdo que deveria ser indexado.
As pegadinhas que encontrei testando
Sem Suspense, o servidor quebra. Se não houver uma fronteira de <Suspense> acima, o render falha com: "The server render could not complete because client rendering was requested outside a Suspense boundary". E aí vai para o onError, não para o onBrowserBailout.
A fronteira inteira vai para o navegador, não só o componente. Essa é a mais importante. Testei colocar um componente comum ao lado de um com browser(), dentro do mesmo <Suspense>:
<Suspense fallback="Carregando...">
<Preco /> {/* componente normal */}
<FusoHorario /> {/* use(browser()) */}
</Suspense>
O <Preco /> também sumiu do HTML. Faz sentido: quem decide o que vai no HTML é a fronteira de Suspense, não o componente. Então mantenha o <Suspense> o mais perto possível do componente que só roda no navegador. Senão você tira do HTML um pedaço da página que nem precisava sair.
Só em Client Component. Num Server Component não funciona. Precisa do 'use client' ou mover a chamada para um componente filho.
browser() sozinho não faz nada. Tem que passar para o use(). Também não é para dar throw nele.
Se é SPA, não precisa. Se o seu app é Vite puro ou Create React App sem SSR, tudo já roda no navegador. O browser() não muda nada para você.
Estável ou canary? Algumas matérias dizem que a API ainda estaria só no canary. Instalei o react-dom@19.3 do npm (a latest, hoje 19.3.1) e o browser já vem exportado e funcionando. Confira a versão do seu projeto antes.
Quando não usar
O browser() é um opt-out. Ele tira conteúdo do HTML. Então:
- Não use em conteúdo que precisa de SEO ou que é o principal da página (o que conta para o LCP). Isso vai para o navegador e chega mais tarde.
- Não use para esconder um mismatch que dá para corrigir. Se o servidor pode saber o fuso do usuário por um cookie, ou o tema por uma preferência salva, mande isso para o servidor e renderize no HTML. O
browser()é para quando o servidor realmente não tem como saber. - Prefira a fronteira menor possível, como vimos nas pegadinhas.
O resto do React 19.3, rapidinho
O browser() não veio sozinho. A mesma versão trouxe:
- View Transitions estáveis: o
<ViewTransition>anima elementos ao entrar, sair e mudar, integrado com Suspense. - Fragment Refs estáveis:
refnum<Fragment>para trabalhar com um grupo de elementos irmãos (foco, eventos, observers) sem criar umadiva mais. - Suporte a Trusted Types: o React passa os valores para o DOM sem converter para string, então as políticas de Trusted Types do navegador funcionam.
- Context em Server Components: dá para renderizar um Context de cliente direto a partir de um Server Component.
Cada um daria um artigo. Talvez dê.
Conclusão
O browser() não traz nada que antes fosse impossível. A gente já fazia tudo isso com mounted, typeof window e ssr: false. O que ele traz é um nome oficial para um padrão que todo projeto com SSR reinventava, e com isso:
- Sem hydration mismatch por componente que depende do navegador.
- Um render só, com acesso às APIs do navegador já no primeiro.
- Condicional: o mesmo componente pode ir ou não para o HTML dependendo do que o servidor sabe.
- Integrado ao Suspense: o loading conversa com o resto da página.
- Observável: o
onBrowserBailoutte diz exatamente o que saiu do HTML e por quê. - Fora do framework: funciona em qualquer SSR.
A dica que eu deixo é procurar no seu projeto por setMounted(true) e typeof window. Cada um desses é candidato a virar uma linha só: use(browser()). Só não esquece do <Suspense> bem pertinho.
Referências
- React 19.3 (blog oficial do React)
- browser (documentação do react-dom)
- use (documentação do React)
- Suspense (documentação do React)
- hydrateRoot (documentação do React)
- renderToPipeableStream (documentação do React)
- React 19.3: Adds a browser() API, Giving Client-Only Rendering a First-Class Name (InfoQ)
- Lazy loading com next/dynamic (Next.js)
- Diretivas de cliente do Astro (client:only)
