browser()localStorage に保存した下書きを textarea に表示するコンポーネントを例にします。これまではだいたい次の2パターンのどちらかで書いていました。
1つ目は typeof window で分岐するパターンです。
"use client";function SavedDraft() { const [draft, setDraft] = useState(() => typeof window !== "undefined" ? (localStorage.getItem("draft") ?? "") // [!code error] : "", ); return ( <div> <textarea value={draft} onChange={/* ... */} /> <p>{draft.length} 文字</p> {/* [!code error] */} </div> );}サーバーでは空文字、ブラウザでは保存済みの下書きになるので、下書きを保存してからリロードすると、文字数の表示がずれてハイドレーションエラーになります。
面白かったのは、最初に textarea だけで試したときはエラーが出なかったことです。フォームの value のずれは React が黙ってブラウザ側の値に合わせてくれるようで、テキストノードがずれて初めてエラーになりました。エラーが出ないからといって、サーバーとブラウザで同じものを描画できているとは限らない、というのは覚えておきたいポイントです。
2つ目は useEffect で mounted フラグを立てるパターンです。
"use client";function SavedDraft() { const [mounted, setMounted] = useState(false); const [draft, setDraft] = useState(""); useEffect(() => { setDraft(localStorage.getItem("draft") ?? ""); // [!code warning] setMounted(true); // [!code warning] }, []); if (!mounted) return <p>下書きを読み込み中...</p>; return <textarea value={draft} onChange={/* ... */} />;}エラーにはなりませんが、ブラウザ側では「プレースホルダーで1回描画 → useEffect で state 更新 → 本物をもう1回描画」という流れになります。実際に console.log を仕込むと、mounted: false → mounted: true と2回レンダーされていました1。
さらに、動作確認用のページを作ったとき、このパターンは ESLint(eslint-plugin-react-hooks)の react-hooks/set-state-in-effect で弾かれました。「effect の中で同期的に setState すると連鎖的な再レンダーを起こす」という指摘で、公式としても推奨されない書き方になってきています。
browser() は react-dom から import し、use() に渡して使います。
"use client";import { Suspense, use, useState } from "react";import { browser } from "react-dom"; // [!code highlight]function SavedDraft() { use(browser("The draft is stored in localStorage.")); // [!code highlight] const [draft, setDraft] = useState( () => localStorage.getItem("draft") ?? "", ); return <textarea value={draft} onChange={/* ... */} />;}export default function Page() { return ( <> <h1>Saved draft</h1> <Suspense fallback={<p>下書きを読み込み中...</p>}> {/* [!code highlight] */} <SavedDraft /> </Suspense> </> );}use(browser()) 以降は「ブラウザでしか実行されない」ことが保証されるので、localStorage をそのまま useState の初期化関数で読めます。typeof window の分岐も mounted フラグも要りません。
引数の reason は省略可能で、「なぜブラウザでしか描画できないか」を文字列(または関数)で渡します。書いておくと、なぜ SSR から外しているのかがコードを読む人にも伝わります。
⚠️ 使うときのルールは3つです。
browser() の戻り値は必ず use() に渡す(browser() を呼ぶだけ、throw するのはNG)"use client")から呼ぶ。Server Component では使えない<Suspense> の内側にあること(無いとサーバーレンダリングが失敗する)use(browser()) は、サーバーとブラウザで振る舞いが変わります。
サーバー(SSR) ブラウザ(ハイドレーション)─────────────────────── ─────────────────────────────<h1>Saved draft</h1> ─ そのまま出力 → <h1>Saved draft</h1><Suspense> <Suspense> <SavedDraft /> <SavedDraft /> use(browser()) use(browser()) → ここで描画を中断 → undefined が返るだけ fallback を HTML に出力 ─────────────→ → 続きを描画して本物に差し替え実際に react-dom/server の renderToPipeableStream で出力を確認すると、Suspense 境界の中身は fallback になっていて、「クライアントで描画に切り替えた」という印の <template> が付いていました。
<h1>Saved draft</h1><!--$!--><template data-msg="Switched to client rendering because a component requested it."></template><p>下書きを読み込み中...</p><!--/$-->ブラウザ側では use(browser()) は suspend せず undefined を返すだけなので、1回のレンダーでそのまま本物が描画されます。mounted パターンのように「プレースホルダーで描画してから effect でもう一度」という往復がありません。
👉 サーバー HTML に入るのは Suspense の fallback だけなので、「サーバーとブラウザで中身がずれる」という状況自体が起きません。
use は他のフックと違い、条件分岐や早期 return の後でも呼べます。これを活かして、値が分かっているときだけサーバーでも描画するという書き方ができます。
function useTimeZone(defaultTimeZone?: string) { if (defaultTimeZone !== undefined) { return defaultTimeZone; // [!code highlight] } use(browser("No default time zone was provided.")); // [!code highlight] return Intl.DateTimeFormat().resolvedOptions().timeZone;}function TimeZone({ label, defaultTimeZone }: { label: string; defaultTimeZone?: string }) { const timeZone = useTimeZone(defaultTimeZone); return <p>{label}: {timeZone}</p>;}<TimeZone label="イベントのタイムゾーン" defaultTimeZone="America/New_York" /><Suspense fallback={<p>タイムゾーンを取得中...</p>}> <TimeZone label="あなたのタイムゾーン" /></Suspense>上の TimeZone は defaultTimeZone があるので普通に SSR され、HTML に America/New_York が入ります。下の TimeZone はサーバーでは fallback になり、ブラウザでユーザーのタイムゾーンが表示されます。
公式ブログでは、同じ考え方をデータ取得のフックに応用した例も紹介されています。
function useBrowserQuery(query, options) { if (options.initialData === undefined) { use(browser("useBrowserQuery: No initial data was provided.")); } return useQuery(query, options);}initialData が渡されていればサーバーで描画し、無ければブラウザで取得する、という切り替えを呼び出し側の props だけで制御できるのが便利なところです。
一番ハマりやすいと感じたのがここです。use(browser()) でサーバー側の描画が止まると、最も近い <Suspense> の fallback が使われます。
Suspense がどこにも無い場合は、サーバーレンダリングそのものが失敗します。renderToPipeableStream で試すと、onShellError に次のエラーが来ました。
The server render could not complete because client rendering was requested outside a Suspense boundary.厄介なのは、Suspense はあるけど遠いケースです。今回動作確認に使った Next.js のアプリでは、認証チェックのために template.tsx でページ全体を <Suspense fallback={null}> で包んでいました。
// app/(protected)/template.tsxexport default function Template({ children }: TemplateProps) { return ( <Suspense fallback={null}> {/* [!code warning] */} <Guarded>{children}</Guarded> </Suspense> );}この状態でページ側に Suspense を置き忘れると、エラーにはならない代わりに、template.tsx の fallback={null} まで巻き戻って、ページ全体がサーバー HTML から消えます。タイトルや説明文など、本来 SSR できる部分まで道連れになります。
<Suspense fallback={null}> ← ここまで巻き戻る(HTML は空) <Page> <h1>タイトル</h1> ← 本来 SSR できるのに消える <SavedDraft /> ← use(browser())エラーにならないぶん気づきにくいので、use(browser()) を使うコンポーネントは、その直近に自分で <Suspense> を置くのを基本にしておくのがよさそうです。
最後に、これまでのやり方と比べてみます。
| 観点 | typeof window | mounted フラグ | use(browser()) |
|---|---|---|---|
| サーバー HTML | サーバー用の値で描画 | プレースホルダーを描画 | Suspense の fallback |
| ハイドレーションエラー | 値がずれると起きる | 起きない | 起きない |
| ブラウザでのレンダー回数 | 1回 | 2回(effect で再描画) | 1回 |
| props で SSR する・しないを切り替え | 分岐を自分で書く | 分岐を自分で書く | use を条件付きで呼ぶだけ |
| lint(react-hooks) | — | set-state-in-effect でエラー | — |
個人的に一番大きいと感じたのは、「このコンポーネントはブラウザ専用」という意図がコード上で1行で表明できる点です。typeof window や mounted フラグは「なぜそう書いているのか」がコードから読み取りにくく、reason を添えた use(browser("...")) の方が、後から読んだ人にも意図が伝わります。
これまで「ブラウザでしか決まらない値」は、mounted フラグで一度プレースホルダーを出してから差し替えるのが半ば定番でしたが、browser() を使うと Suspense の仕組みに乗せて素直に書けるようになりました。
一方で、実際に試してみると Suspense 境界がどこにあるかで結果が大きく変わることも分かりました。特に Next.js の layout.tsx / template.tsx / loading.tsx など、自分で書いた覚えのない場所に Suspense があるケースでは、ページ全体が SSR から外れていても気づきにくいので注意が必要です。
以前の記事でも SSR と CSR のタイムゾーンのずれに悩みましたが、同じような場面に出くわしたら、まず browser() を検討してみてください。
開発環境では Strict Mode によって各レンダーが2重に実行されるため、ログはそれぞれ2回ずつ(2回目は薄く)表示されます。ここで言う「2回」は Strict Mode の重複を除いた回数です。 ↩