Todos os posts

use(browser()): o React 19.3 aposentou o typeof window

O React 19.3 saiu com uma API pequena que me chamou mais atenção do que o resto: use(browser()), um jeito oficial de dizer que um componente só existe no browser. Neste post, mostro o problema que ela resolve, os jeitos que a gente contornava isso até agora e por que ela não resolve tudo o que parece.

4 min de leitura

Nesta página

O React 19.3 saiu no dia 9 de setembro e, lendo a release, a feature que mais me chamou atenção foi a menor delas: o use(browser()). É um jeito oficial de dizer ao React que um componente só existe no browser.

Parece pouca coisa, mas é o fim de um hack que quase todo projeto com SSR tem escondido em algum lugar: o velho typeof window !== 'undefined'.

Antes de chegar na API nova, vale entender o problema que ela tenta resolver.

O que é um hydration mismatch

Com SSR, o servidor gera o HTML da página e manda para o browser. O browser mostra esse HTML na hora, monta a árvore de componentes de novo e compara com o que chegou. Esse processo é a hidratação.

Se a primeira renderização no cliente sair diferente da do servidor, o React reclama. E tem componente que sai diferente por definição. O exemplo clássico é um toggle de tema que lê a preferência salva no localStorage: no servidor não existe localStorage, então ele não tem como saber que você prefere o tema escuro.

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

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

O typeof window faz exatamente o que o React não quer: dá false lá e true aqui. O servidor manda "claro", o browser renderiza "escuro" e pronto, mismatch.

Dá play aí embaixo para ver isso acontecendo, e depois compara com as outras abordagens:

view-source: o HTML que chegou
Temaclaro

No servidor não existe window, então o componente cai no padrão: tema claro.

Tema salvo no localStorage: escuro. Escolha uma abordagem e dê play.

Os três jeitos de contornar (até agora)

Até o 19.3, isso se resolvia de três jeitos, nenhum muito bonito.

O mounted no useEffect

É o que eu mais vejo por aí. Um estado mounted que começa false e vira true num 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" ? "🌙 escuro" : "☀️ claro"}</button>;
}

Funciona, mas repara: é um efeito sem efeito nenhum. Ele só existe para marcar que a hidratação passou. E como o componente não renderiza nada até lá, o que vem depois dele na página pula quando ele aparece.

dynamic com ssr: false

No Next.js dá para resolver por fora do componente, pedindo para ele nem ser renderizado no servidor:

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

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

O componente fica limpo, mas a decisão de "isso só roda no browser" passa para quem importa o componente. E se você esquecer a opção loading, o espaço fica vazio no HTML do mesmo jeito.

useSyncExternalStore com getServerSnapshot

Esse é considerado o jeito certo. O useSyncExternalStore aceita um terceiro argumento, o getServerSnapshot, que é usado no servidor e durante a hidratação:

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

export function ThemeToggle() {
const theme = useSyncExternalStore(
subscribe,
() => localStorage.getItem("theme") ?? "light", // browser
() => "light" // servidor e hidratação
);

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

Isso acaba com o mismatch e com o efeito falso. Só que, logo depois da hidratação, o React lê o valor de verdade, percebe que mudou e força um update síncrono com re-render. Na prática, quem prefere o tema escuro vê o claro piscar antes. Muita cerimônia para comunicar uma coisa só. 😒

Chega o use(browser())

A API nova é importada de react-dom e usada com o 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" ? "🌙 escuro" : "☀️ claro"}</button>;
}
header.tsx
<Suspense fallback={<ToggleSkeleton />}>
<ThemeToggle />
</Suspense>

O funcionamento é este:

  • No servidor, o componente suspende e o Suspense mais próximo coloca o fallback no HTML.
  • No browser, a chamada retorna undefined e o render segue normal. Dá para usar localStorage, window e companhia sem medo.
  • Se não tiver um Suspense acima, o render no servidor falha inteiro, em vez de cair num fallback vazio sem você perceber.

Esse último ponto eu acho ótimo. O React te obriga a decidir o que aparece enquanto o browser não assume.

Este blog já roda com o React 19.3, então dá para ver a API de verdade. O contador abaixo lê e grava no localStorage direto no render, sem useEffect e sem typeof window. Clica algumas vezes e recarrega a página:

O HTML desta página só tem o skeleton. O contador existe apenas no seu browser.

Se você abrir o código-fonte da página, vai ver que no lugar do contador só tem o skeleton. Coisa linda! 🤩

Mas não resolve o que parece resolver

O use(browser()) não faz o componente existir no servidor. Ele continua tendo dois estados no tempo: o fallback e o conteúdo. E é o fallback que decide se a tela vai pular.

Se você passar null ali, voltou pro problema do mounted, só que com uma API nova:

view-source: o HTML que chegou

Mesma API, mas agora com fallback={null}. O HTML do servidor não tem nada nesse espaço.

O mesmo use(browser()), com dois fallbacks diferentes.

Com um skeleton do mesmo tamanho do componente, o espaço já vem reservado no HTML e nada se mexe quando o conteúdo chega. Com null, tudo que está abaixo desce.

Por que ainda gosto dela

Mesmo sem mágica, gostei da ideia. O que ela faz é te obrigar a escrever o fallback num lugar declarado, em vez de deixar a fronteira entre servidor e browser escondida num useEffect. Quem lê o componente vê o use(browser()) na primeira linha e já sabe o que está acontecendo.

Para biblioteca, isso importa ainda mais, porque vira uma linguagem comum. O Wasp já abriu uma issue para trocar o truque com useSyncExternalStore deles por essa API, justamente por causa do update síncrono e do re-render extra.

Fechando

Da próxima vez que você for escrever um typeof window ou um mounted, experimenta o use(browser()). Só não esquece de caprichar no fallback, porque é ele que o seu usuário vai ver primeiro.

A documentação da API é curta e vale a leitura. Se você já trocou algum hack por ela, me conta como ficou.