【React】browser APIとSuspenseで、SSR時にブラウザ専用のコンポーネントを扱う

杉田侑祐
杉田侑祐約16分で読めます
はてなブックマーク

はじめに

この記事の概要

こんにちは、株式会社TOKOSの杉田です!

React 19.3が2026年9月9日にリリースされ、browserが追加されました。
このAPIはlocalStorageのようにブラウザ側にしかないAPIを使うコンポーネントを、サーバーレンダリングの対象から外します。

この記事では、SSRでブラウザ専用のAPIを使うときに起きる問題を整理してから、Suspenseとuseを組み合わせたbrowserの使い方を紹介します!

browser – React

The library for web and native user interfaces

react.dev

対象読者

  • ReactやNext.jsを使用するFEの方
  • browserを使った実装をしたい方
  • browserをSSRで使うときの注意点を知りたい方

この記事で扱う内容、扱わない内容

扱う内容は次のとおりです。

  • SSRでブラウザ専用のAPIを使うときの問題点
  • browserの仕組みと、Suspense・useを使った実装例
  • browserをSSRで使うときの注意点

扱わない内容は次のとおりです。

  • Suspenseとuseの基本的な使い方
  • Server Componentsの概要

Suspenseとuseの基本は、下記の記事で紹介しています。

【Next.js】Suspenseの初歩的な使い方

Next.jsでのSuspenseの基本的な使い方を解説。サーバーコンポーネントでのawaitパターンやクライアントコンポーネントでのuse hookを用いた実装方法を、コード例とともに紹介します。

検証環境

  • React・React DOM: 19.3.0
  • Next.js: 16.3.6
  • Node.js: 24.21.0

SSRでブラウザ専用のAPIを使うときの問題点

まずは、SSR時の流れを確認します。
1つのコンポーネントが2つの環境でレンダリングされます。

  1. サーバー側でレンダリングして、最初に返すHTMLを作る
  2. ブラウザ側でもう一度レンダリングして、そのHTMLにイベントハンドラーを設定する

この2の過程をハイドレーションと呼びます。
ハイドレーションではブラウザ側での最初のレンダリング結果が、サーバー側で作ったHTMLと一致している必要があります。
もし一致しなければ、ハイドレーションエラーが発生します。

ただしlocalStorageやブラウザ側のタイムゾーンのように、サーバー側には存在しない情報を使うコンポーネントもあります。
そのため、こうしたコンポーネントはサーバー側ではHTMLを作れません。

ここからは、localStorageに保存した下書きを表示するコンポーネントを例に、これまでの書き方で起きる問題を3つ見ていきます!

問題点1: サーバー側でlocalStorageを読むとエラーになる

まず、Next.jsの"use client"を付けたClient Componentも、SSRのときはサーバー側で実行されます。
そのためlocalStorageをそのまま読むとサーバー側でエラーになります。

app/naive/SavedDraft.tsx
"use client"
 
import { useState } from "react"
 
export function SavedDraft() {
  const [draft, setDraft] = useState(() => localStorage.getItem("draft") ?? "")
 
  return (
    <textarea
      value={draft}
      onChange={(event) => {
        setDraft(event.target.value)
        localStorage.setItem("draft", event.target.value)
      }}
    />
  )
}

Next.jsでビルドすると、プリレンダリングで6行目がエラーになってビルドが止まりました。

next buildの出力
Error occurred prerendering page "/naive". Read more: https://nextjs.org/docs/messages/prerender-error
ReferenceError: localStorage is not defined

また、リクエストのたびにレンダリングするページでも同じエラーが起き、ステータスコード500が返ります。

問題点2: typeof windowで分岐するとハイドレーションエラーになる

次に、サーバー側でのエラーを避けるために、typeof windowでブラウザ側かどうかを判定する書き方もよく見かけます。

app/typeof/SavedDraft.tsx
"use client"
 
import { useState } from "react"
 
export function SavedDraft() {
  const [draft, setDraft] = useState(() => (typeof window !== "undefined" ? (localStorage.getItem("draft") ?? "") : ""))
 
  return (
    <>
      <p>{draft === "" ? "下書きはありません" : `下書き: ${draft}`}</p>
      <textarea
        value={draft}
        onChange={(event) => {
          setDraft(event.target.value)
          localStorage.setItem("draft", event.target.value)
        }}
      />
    </>
  )
}

サーバー側ではwindowがないため、エラーにはならず空文字が初期値になります。
一方でブラウザ側ではlocalStorageに保存した値が初期値になります。
そのため、下書きが保存されている場合、サーバー側のHTMLは「下書きはありません」になります。
しかし、ブラウザ側での最初のレンダリング結果は「下書き: 明日の会議メモ」になり、内容が一致しません。

その結果、開発環境で開くと次のハイドレーションエラーが表示されました。

そして、エラーメッセージの1つ目の候補にはtypeof windowによる分岐が挙がっています。
このとき、画面には最終的にブラウザ側の内容が表示されます。
ただし、サーバー側で作ったHTMLは使われず、ブラウザ側で描画し直されます。

問題点3: useEffectでマウントを待つと、コンポーネントごとに状態が必要になる

では、ハイドレーションエラーを避ける際はuseEffectを使用することが多いのではないでしょうか?

app/mounted/SavedDraft.tsx
"use client"
 
import { useEffect, useState } from "react"
 
export function SavedDraft() {
  const [draft, setDraft] = useState("")
  const [mounted, setMounted] = useState(false)
 
  useEffect(() => {
    setDraft(localStorage.getItem("draft") ?? "")
    setMounted(true)
  }, [])
 
  if (!mounted) {
    return <p>下書きを読み込み中...</p>
  }
 
  return (
    <textarea
      value={draft}
      onChange={(event) => {
        setDraft(event.target.value)
        localStorage.setItem("draft", event.target.value)
      }}
    />
  )
}

この書き方はエラーなく動きます。
これは、サーバー側とブラウザ側の最初のレンダリング結果がどちらも「下書きを読み込み中...」になり、マウント後に下書きの表示へ切り替わるためです。

ただし、この書き方には次のような問題があります。

  • ブラウザ専用のコンポーネントごとに、mountedの状態とuseEffectを書く必要がある
  • マウントした後に、もう一度レンダリングが走る
  • 読み込み中の表示をコンポーネントの中で持つため、Suspenseで待つほかのコンポーネントとまとめて扱えない

browserを使った解決方法

browserの仕組み

そこで、React 19.3では問題点3のような処理をReactの機能として書けるようにbrowserが追加されました!
公式ドキュメントでは、次のように説明されています。

browser lets you mark a component as browser-only during server rendering.

和訳すると「browserを使うと、サーバーレンダリングのときにコンポーネントをブラウザ専用として扱える」です。

ただしbrowserは単体では使わず、戻り値をuseに渡して呼び出します。

use(browser(reason?))

呼び出すと、use(browser())の動きは実行される環境によって変わります。

環境use(browser())の動き
サーバー側コンポーネントのレンダリングを止め、一番近いSuspenseのfallbackをHTMLに出力する
ブラウザ側undefinedを返し、コンポーネントはそのままレンダリングされる

React 19.3のリリースブログでは、この動きを「サーバー側ではSuspenseを発生させ、ブラウザ側では発生させない」と説明しています。
つまり、サーバー側で作ったHTMLにはfallbackが入り、ブラウザ側では本来のコンポーネントが描画されます。

React 19.3 – React

The library for web and native user interfaces

react.dev

また、引数のreasonは省略できます。
ブラウザ側で描画する理由を文字列か関数で渡すとonBrowserBailoutで記録するときに、エラーのcauseとして受け取れます。
このとき関数を渡すと、サーバー側だけでその関数を呼び出し、ブラウザ側では呼び出しません。

このonBrowserBailoutは、renderToPipeableStreamなどのサーバーレンダリングのAPIに渡すオプションです。
使い方は公式ドキュメントで紹介されています。

browser – React

The library for web and native user interfaces

react.dev

Suspenseとuse(browser())で下書きをブラウザ側だけで描画する

まずは、問題点の例と同じ下書きのコンポーネントをbrowserで書き直します。

app/browser/SavedDraft.tsx
"use client"
 
import { use, useState } from "react"
import { browser } from "react-dom"
 
export function SavedDraft() {
  use(browser("下書きはlocalStorageに保存しているため"))
  const [draft, setDraft] = useState(() => localStorage.getItem("draft") ?? "")
 
  return (
    <textarea
      value={draft}
      onChange={(event) => {
        setDraft(event.target.value)
        localStorage.setItem("draft", event.target.value)
      }}
    />
  )
}

4行目でreact-domからbrowserを読み込み、7行目でuseに渡しています。
use(browser())以降の行はブラウザ側でしか実行されないので、8行目でlocalStorageを直接読んでも問題ありません。
そのため、typeof windowの判定やmountedの状態は要りません。

次に、呼び出す側ではSavedDraftをSuspenseで囲みます。

app/browser/page.tsx
import { Suspense } from "react"
import { SavedDraft } from "./SavedDraft"
 
export default function Page() {
  return (
    <main>
      <h1>下書き</h1>
      <Suspense fallback={<p>下書きを読み込み中...</p>}>
        <SavedDraft />
      </Suspense>
    </main>
  )
}

ページを開くと、JavaScriptが動くまでは次のように表示されます。

この時点では、見出しはHTMLに含まれていますが、SavedDraftの位置にはfallbackの内容が入っています。

一方でハイドレーション後はlocalStorageの下書きが表示され、ハイドレーションエラーも出ていません。

杉田
杉田

typeofによる判定もmountedの状態もなくなり、コンポーネントのコードが下書きの処理だけになって読みやすくなりました!

条件によってサーバー側で描画するかを切り替える

またuseはほかのHookと違い、if文の中や早期returnの後でも呼び出せます。
そのためuse(browser())も条件付きで呼べます。

たとえば、公式ドキュメントの例を参考に、タイムゾーンを返すカスタムフックを作ります。

app/timezone/useTimeZone.ts
import { use } from "react"
import { browser } from "react-dom"
 
export const useTimeZone = (defaultTimeZone?: string) => {
  if (defaultTimeZone !== undefined) {
    return defaultTimeZone
  }
 
  use(browser("タイムゾーンの初期値が渡されていないため"))
  return Intl.DateTimeFormat().resolvedOptions().timeZone
}

初期値が渡されたときは、5〜7行目でその値をそのまま返します。
この場合はサーバー側でも描画できるので、HTMLにタイムゾーンが含まれます。
一方、初期値がないときだけ、9行目のuse(browser())でサーバー側での描画を止めます。

app/timezone/TimeZone.tsx
"use client"
 
import { useTimeZone } from "./useTimeZone"
 
type Props = {
  label: string
  defaultTimeZone?: string
}
 
export function TimeZone({ label, defaultTimeZone }: Props) {
  const timeZone = useTimeZone(defaultTimeZone)
 
  return <p>{`${label}: ${timeZone}`}</p>
}
app/timezone/page.tsx
import { Suspense } from "react"
import { TimeZone } from "./TimeZone"
 
export default function Page() {
  return (
    <main>
      <h1>イベント詳細</h1>
      <TimeZone label="開催地のタイムゾーン" defaultTimeZone="America/New_York" />
      <Suspense fallback={<p>あなたのタイムゾーンを取得中...</p>}>
        <TimeZone label="あなたのタイムゾーン" />
      </Suspense>
    </main>
  )
}

実際に開発環境で開くと次のように表示されます。

ここでは、8行目は初期値を渡し、9〜11行目は初期値を渡さずにSuspenseで囲んでいます。
サーバー側が返したHTMLは次の通りです。

サーバー側が返したHTML(main要素を抜粋して整形)
<main>
  <h1>イベント詳細</h1>
  <p>開催地のタイムゾーン: America/New_York</p>
  <p>あなたのタイムゾーンを取得中...</p>
</main>

同じTimeZoneでも、初期値を渡したほうはHTMLに出力され、渡していないほうはfallbackに置き換わっています。
初期値を渡すときはuse(browser())を呼ばないので、Suspenseで囲む必要はありません。

杉田
杉田

条件付きで呼べるので、「データがあればサーバー側で描画し、なければブラウザ側に任せる」という切り替えをフックの中にまとめて書けるのが便利だと感じました!

use(browser())とuse(Promise)を同じSuspenseで待つ

browserを使うと、読み込み中の表示をSuspenseに任せられます。
React 19.3のリリースブログでも、browserを使えば、Suspenseで待つほかのコンポーネントと同じ仕組みでローディング状態を扱えると説明されています。

ここではlocalStorageに保存した「最近見た記事」のIDをもとに、記事のタイトルを取得して表示します。
このとき記事のIDはブラウザ側にしかないため、サーバー側ではデータを取得できません。

app/promise/RecentPosts.tsx
"use client"
 
import { use } from "react"
import { browser } from "react-dom"
 
type Post = {
  id: number
  title: string
}
 
const postsCache = new Map<string, Promise<Post[]>>()
 
const fetchPosts = (ids: number[]) => {
  const key = ids.join(",")
  const cached = postsCache.get(key)
  if (cached) return cached
 
  const promise = Promise.all(
    ids.map((id) =>
      fetch(`https://jsonplaceholder.typicode.com/posts/${id}`).then((res) => res.json() as Promise<Post>),
    ),
  )
  postsCache.set(key, promise)
  return promise
}
 
export function RecentPosts() {
  use(browser("最近見た記事のIDはlocalStorageに保存しているため"))
  const ids: number[] = JSON.parse(localStorage.getItem("recent-post-ids") ?? "[]")
  const posts = use(fetchPosts(ids))
 
  if (posts.length === 0) {
    return <p>最近見た記事はありません</p>
  }
 
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
app/promise/page.tsx
import { Suspense } from "react"
import { RecentPosts } from "./RecentPosts"
 
export default function Page() {
  return (
    <main>
      <h1>最近見た記事</h1>
      <Suspense fallback={<p>最近見た記事を読み込み中...</p>}>
        <RecentPosts />
      </Suspense>
    </main>
  )
}

サーバー側では28行目でレンダリングが止まり、fallbackがHTMLに入ります。
一方、ブラウザ側では28行目を通過し、30行目のuseでデータの取得を待つ間も同じSuspenseがfallbackを表示します。
このように、サーバー側で描画しないこととデータの取得を待つことを、1つのSuspenseでまとめて扱えます!

ただし、11行目でPromiseをMapに保存しているのは、Client Componentのレンダリング中に作ったPromiseがレンダリングのたびに作り直されるためです。
キャッシュせずにuse(Promise.all(...))と書いたところ、6秒間で418回レンダリングされ、そのたびにfetchが呼ばれました。
その結果、表示はfallbackの「読み込み中...」のまま終わりませんでした。
これはuseがPromiseの完了を待ってからコンポーネントを再レンダリングし、そのたびに新しいPromiseが作られてしまうためです。

use – React

The library for web and native user interfaces

react.dev

browserをSSRで使うときの注意点

ここからは、browserをSSRで使うときの注意点を3つ紹介します。

  1. Suspenseがないとサーバーレンダリングが失敗する
  2. 一番近いSuspenseの内側が、すべてHTMLに含まれなくなる
  3. Server Componentでは呼び出せない

注意点1: Suspenseがないとサーバーレンダリングが失敗する

use(browser())を呼ぶコンポーネントより上にSuspenseがないと、サーバー側でのレンダリングは失敗します。
実際に、先ほどのSavedDraftをSuspenseで囲まずに置くと、Next.jsのビルドが次のエラーで止まりました。

next buildの出力
Error occurred prerendering page "/no-suspense". Read more: https://nextjs.org/docs/messages/prerender-error
Error: The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.

また、リクエストのたびにレンダリングするページでは、ステータスコード500が返ります。
そのため、browserを使うコンポーネントはSuspenseで囲むところまでを1セットと考えましょう!

注意点2: 一番近いSuspenseの内側が、すべてHTMLに含まれなくなる

use(browser())がHTMLに残すのは、一番近いSuspenseのfallbackです。
そのためSuspenseを外側に置くほど、HTMLに含まれない範囲が広がります。

Next.jsのloading.tsxは、ページ全体をSuspenseで囲みます。
そのためloading.tsxがあるルートでSuspenseを置き忘れると、ページ全体がloading.tsxの内容に置き換わります。

app/loading-boundary/page.tsx
import { SavedDraft } from "./SavedDraft"
 
export default function Page() {
  return (
    <main>
      <h1>下書き</h1>
      <p>この段落もサーバーで描画したい本文です。</p>
      <SavedDraft />
    </main>
  )
}
app/loading-boundary/loading.tsx
export default function Loading() {
  return <p>ページを読み込み中...</p>
}

SavedDraftは、先ほどbrowserで書き直したものと同じコンポーネントです。
このページを開くと、JavaScriptが動くまでは次のように表示されます。

その結果、HTMLには見出しと本文のどちらも含まれていません。
ブラウザ側で描画が終われば表示されますが、JavaScriptを実行しないクローラーやリンクプレビューからは本文が見えなくなります。
そのためSuspenseはbrowserを使うコンポーネントのすぐ外側に置きましょう。

注意点3: Server Componentでは呼び出せない

use(browser())は、Client Componentから呼び出す必要があります。
実際に、Server Componentの中で呼ぶと、Next.jsの開発サーバーで次のエラーになりました。

next devの出力
TypeError: (0 , react_dom__WEBPACK_IMPORTED_MODULE_2__.browser) is not a function

Server Component向けのreact-domにはbrowserが含まれていないため、関数として呼び出せません。
App RouterはServer Componentsが標準なので、ファイルの先頭に"use client"を書くか、browserを使う部分を子のClient Componentに切り出します。

Client Componentから呼び出す必要があることは、公式ドキュメントの注意事項にも書かれています。

browser – React

The library for web and native user interfaces

react.dev

最後に

React 19.3で追加されたbrowserを、SSRでブラウザ専用のAPIを使うときの問題点と併せて紹介しました!

typeof windowの分岐はハイドレーションエラーになり、useEffectでマウントを待つ書き方はコンポーネントごとに状態が必要でした。
一方、use(browser())を使うとサーバー側での描画を止める処理を1行で書けて、読み込み中の表示をSuspenseに任せられます。
また、条件付きで呼べるので、初期値があるときだけサーバー側で描画するといった切り替えも書けます。

一方で、SSRで使うときは次の点に注意が必要です!

  • Suspenseがないとサーバーレンダリングが失敗する
  • Suspenseの位置が外側にあると、HTMLに含まれない範囲が広がる
  • Client Componentからしか呼び出せない

React 19.3のリリースブログでも、browserはサーバー側で意味のあるUIを作れない、まれなケースのためのAPIとして紹介されています。
そのため、サーバー側で描画できる部分にまでbrowserを使うと、SSRで得られるはずのHTMLが減ってしまいます。
まずは既存のmountedの状態やtypeof windowの分岐を探して、置き換えられるところから試してみてください!

参考リンク

この記事を書いた人

杉田侑祐
杉田侑祐

TOKOSのフロントエンドエンジニア兼UI/UXデザイナー。このブログではフロントエンドメインで投稿しています。HIPHOPとゲームが好きです✌️