Next.jsでlocalStorageがhydration errorになる原因と解決法


Next.js で localStorage を使うと hydration error(ハイドレーションエラー)が出るのは、サーバー側に localStorage が存在せず、サーバーが生成したHTMLと、ブラウザが localStorage を読んで描画した結果が食い違うためです。解決の基本は、localStorage の読み取りを useEffect 内(=クライアントでのみ実行される場所)に寄せ、初回レンダリングの結果をサーバーと一致させることです。

「ローカルでは動くのに、Next.jsにすると Hydration failed と出る」「ダークモードの初期値を localStorage から読んだら警告が出た」——SSR(サーバーサイドレンダリング)を行うNext.jsでは定番のつまずきです。原因はブラウザAPIである localStorage の性質と、Next.jsのレンダリングの仕組みのすれ違いにあります。

この記事では、hydration error がなぜ起きるのかを仕組みから説明し、useEffect・マウント判定・next/dynamicuseSyncExternalStore という4つの解決法を、再利用できるカスタムフックまで含めて整理します。そもそも localStorage を使うべきかという保存先の判断は localStorage・sessionStorage・Cookie・IndexedDBの使い分け を参照してください。


結論:なぜhydration errorが起きるのか

Next.js は、ページをまずサーバー側でHTMLとして生成し(SSR)、ブラウザに送ります。ブラウザはそのHTMLを表示したあと、Reactが同じ内容を再構築してイベントなどを紐づけます。この後半の処理がハイドレーション(hydration)です。

ハイドレーションが正しく行われる前提は、「サーバーが作ったHTML」と「クライアントが初回に描画する内容」が完全に一致していることです。ところが localStorage はブラウザにしか存在しないため、サーバーでは読めません。その結果、次のようなズレが生まれます。

サーバー(SSR)クライアント(初回描画)
localStorageの値読めない(undefined)保存された値が読める
描画されるHTMLデフォルト値で生成localStorageの値で生成
結果——サーバーと不一致 → hydration error

つまり hydration error の正体は、「サーバーとクライアントで初回の描画結果が違う」ことへのReactの警告です。localStorage はそのズレを生む代表例にすぎません。解決の方向性は一つで、初回描画をサーバーと揃え、localStorageの反映はマウント後に回すことです。

なお、同じ理由から Date.now()Math.random()window.innerWidth のようなサーバーとクライアントで結果が変わる値も hydration mismatch を起こします。「実行する環境によって変わるものは、初回描画に含めずマウント後に反映する」という原則は、localStorage 以外にもそのまま当てはまります。


エラーが起きる典型コードとメッセージ

最も多いのが、useState の初期値で直接 localStorage を読んでしまうパターンです。一見自然ですが、これがそのまま不一致の原因になります。

// ❌ hydration error を起こす典型例
"use client";
import { useState } from "react";

export function Theme() {
  // サーバーでは localStorage が無い → エラー or undefined
  // クライアント初回は値が入る → サーバーと不一致
  const [theme, setTheme] = useState(
    typeof window !== "undefined"
      ? localStorage.getItem("theme") ?? "light"
      : "light"
  );
  return <div>現在のテーマ: {theme}</div>;
}

このコードをSSR環境で動かすと、コンソールに次のような警告が表示されます(メッセージはバージョンにより多少異なります)。

Hydration failed because the initial UI does not match
what was rendered on the server.

Warning: Text content did not match.
Server: "light" Client: "dark"

サーバーは localStorage を読めず "light" で描画する一方、ブラウザでは保存済みの "dark" で描画しようとするため、テキストが一致せずエラーになります。typeof window でガードしても、初回描画の不一致そのものは解消されない点がこの問題の厄介なところです。

開発時は、ブラウザに表示されるNext.jsのエラーオーバーレイに「Server」と「Client」で描画された値の差分が示されます。どの要素が食い違っているかを把握する手がかりになるので、まずはここでズレているテキストや属性を特定し、その値の出どころ(多くは localStorage やブラウザ依存の値)を辿るのが解決の早道です。


解決法1:useEffectで読み取る(基本)

最も基本的な解決法は、初期値はサーバーと同じデフォルト値にしておき、localStorage の読み取りは useEffect の中で行うことです。useEffect はクライアントでマウントされた後にだけ実行されるため、サーバーの描画には影響しません。

"use client";
import { useState, useEffect } from "react";

export function Theme() {
  const [theme, setTheme] = useState("light"); // サーバーと同じ初期値

  useEffect(() => {
    // マウント後(クライアントのみ)に読み込んで反映
    const saved = localStorage.getItem("theme");
    if (saved) setTheme(saved);
  }, []);

  return <div>現在のテーマ: {theme}</div>;
}

これでサーバーもクライアント初回も "light" で描画され、ハイドレーションは一致します。保存値の反映はマウント直後に行われます。ただし、一瞬デフォルト値が表示されてから切り替わる「ちらつき」が起きる点には注意が必要です。値の見た目の差が大きい場合は、次のマウント判定と組み合わせます。


解決法2:マウント判定でガードする

ちらつきを避けたい、あるいは localStorage の値が無いと表示が成立しない場合は、マウントが完了するまで描画を保留する方法が有効です。mounted フラグを用意し、クライアントでマウントされるまではサーバーと同じ内容(プレースホルダーや null)を返します。

"use client";
import { useState, useEffect } from "react";

export function Theme() {
  const [mounted, setMounted] = useState(false);
  const [theme, setTheme] = useState("light");

  useEffect(() => {
    setTheme(localStorage.getItem("theme") ?? "light");
    setMounted(true);
  }, []);

  // マウント前はサーバーと同じものを返す(不一致を防ぐ)
  if (!mounted) return null;

  return <div>現在のテーマ: {theme}</div>;
}

mountedfalse の間はサーバーと同じ null を返すため、ハイドレーションは一致します。マウント後に本来の内容を描画します。null の代わりにスケルトンやローディング表示を返せば、空白も避けられます。レイアウトのガタつきが気になる場合はこちらを選びます。


解決法3:next/dynamicでクライアント限定描画

コンポーネント全体がブラウザAPIに強く依存していて、そもそもサーバーで描画する意味がない場合は、next/dynamicssr: false を指定し、クライアントでのみ読み込むのが手っ取り早い解決です。

"use client";
import dynamic from "next/dynamic";

// このコンポーネントはサーバーでは描画されない
const Theme = dynamic(() => import("./Theme"), { ssr: false });

export function Page() {
  return <Theme />;
}

ssr: false を付けると、そのコンポーネントはサーバー側のHTMLに含まれず、ハイドレーションの対象から外れるため不一致が起きません。ただしSSRの恩恵(初期表示の速さやSEO上のメリット)を捨てることになるので、ページの主要なコンテンツではなく、ウィジェットなど局所的な要素に使うのが適切です。


解決法4:useSyncExternalStoreで正攻法に対応する

React には、localStorage のような外部の可変ストアを安全に読むための専用フック useSyncExternalStore が用意されています。これは getSnapshot(クライアントの値)と getServerSnapshot(サーバー時の値)を別々に渡せるため、SSRとクライアントの食い違いを設計レベルで吸収できます。

"use client";
import { useSyncExternalStore } from "react";

function subscribe(callback: () => void) {
  // 他タブでの変更も storage イベントで購読できる
  window.addEventListener("storage", callback);
  return () => window.removeEventListener("storage", callback);
}

export function useThemeStorage() {
  return useSyncExternalStore(
    subscribe,
    () => localStorage.getItem("theme") ?? "light", // クライアント
    () => "light"                                    // サーバー(固定値)
  );
}

getServerSnapshot がサーバー時の値を固定で返すため、初回はサーバーと一致し、マウント後にクライアントの値へ更新されます。加えて subscribestorage イベントを購読しておけば、別タブでの localStorage の変更にも自動で追従できます。やや高度ですが、Reactが想定する「外部ストアの正しい読み方」です。


再利用するならカスタムフックにまとめる

localStorage を複数箇所で使うなら、これまでの対策を useLocalStorage のようなカスタムフックに閉じ込めると、呼び出し側がハイドレーションを意識せずに済みます。

"use client";
import { useState, useEffect, useCallback } from "react";

export function useLocalStorage(key: string, initial: string) {
  const [value, setValue] = useState(initial); // SSRと一致する初期値

  useEffect(() => {
    const saved = localStorage.getItem(key);
    if (saved !== null) setValue(saved);
  }, [key]);

  const update = useCallback((next: string) => {
    setValue(next);
    localStorage.setItem(key, next); // 書き込みもまとめる
  }, [key]);

  return [value, update] as const;
}

呼び出し側は const [theme, setTheme] = useLocalStorage("theme", "light") のように使うだけで、初回の不一致を気にせず localStorage を扱えます。読み書きのロジックが1か所に集まるため、保守もしやすくなります。


4つの解決法の使い分け

ここまでの4つの解決法は、どれか1つが正解というより、場面に応じて選ぶものです。判断軸を表に整理します。

解決法向いている場面注意点
useEffectほとんどのケースの基本一瞬デフォルト値が見える
マウント判定ちらつきを消したい/値が無いと描画できないマウントまで内容が出ない
next/dynamicコンポーネントが完全にブラウザ依存SSRの利点を失う
useSyncExternalStore正攻法で扱いたい/タブ間同期も欲しいやや実装が複雑

迷ったら、まずは useEffect で初期値をサーバーと揃える基本形から始め、ちらつきが気になるならマウント判定を足す、という順で考えると失敗しません。外部ストアとして堅実に扱いたい場面では useSyncExternalStore が最も筋の良い選択です。


App Router特有の注意点

Next.js の App Router では、コンポーネントはデフォルトでServer Componentとして扱われます。Server Component はサーバーでのみ実行されるため、localStoragewindowdocument といったブラウザAPIには一切触れられません。

  • "use client" を先頭に付けるuseStateuseEffect、localStorage を使うコンポーネントは Client Component にする必要がある
  • ブラウザAPIは必ずマウント後に:Client Component でも初回はサーバーでHTML化されるため、localStorage の読み取りは useEffect 以降に置く
  • Server Component では扱わない:データ取得はサーバー側(fetch・DB)で行い、ブラウザ固有の状態は Client Component に分離する

"use client" を付ければサーバーで実行されない」と誤解されがちですが、Client Component も初回はサーバーでレンダリングされHTMLになる点は変わりません。だからこそ、localStorage の反映はマウント後に回す必要があります。


やりがちな間違い

hydration error の対処でよく見かける、効果が薄い・かえって問題を隠してしまう対応を挙げます。

  • typeof window チェックだけで済ませる:初期値の分岐に使っても、サーバーとクライアント初回の描画が違えば不一致は残る。読み取りを useEffect に移すのが本質的な解決
  • suppressHydrationWarning を多用する:警告を黙らせるだけで不一致自体は残る。日時表示などごく限定的な箇所以外では使わない
  • ページ全体を ssr: false にする:SSR/SSGの利点を丸ごと捨ててしまう。クライアント依存の局所要素だけに留める
  • エラーを無視して放置する:hydration の不一致は、表示崩れや状態のズレなど別の不具合につながることがある。警告は必ず解消する

よくある質問

Q. なぜlocalStorageを初期値に使うとhydration errorになるのですか?

サーバー側には localStorage が存在しないため、サーバーはデフォルト値でHTMLを生成します。一方ブラウザは保存済みの値で描画しようとするため、初回の描画結果が食い違い、Reactがハイドレーションの不一致として警告します。読み取りを useEffect 内(マウント後)に移すと解消します。

Q. “use client” を付ければhydration errorは出なくなりますか?

いいえ。Client Component も初回はサーバーでHTMLにレンダリングされるため、"use client" を付けただけでは不一致は防げません。localStorage の反映は、サーバーでは実行されない useEffect の中で行う必要があります。

Q. SSGやISRでもhydration errorは起きますか?

はい、起きます。SSG(静的生成)やISRも、ビルド時やサーバー側であらかじめHTMLを生成する点は同じで、その段階では localStorage は存在しません。生成済みHTMLとブラウザの初回描画が食い違えば、同様に hydration error になります。対処法も本記事と同じく、localStorage の読み取りを useEffect 以降に回すことです。

Q. ちらつき(一瞬デフォルト値が見える)をなくすには?

マウントが完了するまで描画を保留する「マウント判定」を使い、mountedtrue になるまでは null やスケルトンを返します。これにより、デフォルト値が一瞬見える現象を防げます。レイアウトのガタつきが気になる場合はスケルトン表示が有効です。

Q. sessionStorageやCookieでも同じ問題は起きますか?

sessionStorage も localStorage と同様にブラウザ専用のため、初期値に使うと同じ不一致が起きます。一方 Cookie はサーバーでも読めるため、サーバー側で値を取得して初期描画に含めれば不一致を避けられます。保存先ごとの違いは ストレージの使い分け を参照してください。

Q. 認証トークンをlocalStorageから読んで使うのは問題ありますか?

hydration の観点に加えて、セキュリティの観点でも localStorage への認証トークン保存は推奨されません。詳細は localStorageにJWTを保存すべきか|XSSリスクと安全なトークン管理 で解説しています。


まとめ

Next.js で localStorage が hydration error を起こすのは、サーバーには localStorage が無く、初回の描画結果がクライアントと食い違うからです。解決の本質は一つで、初期値はサーバーと同じデフォルトにし、localStorage の反映はマウント後(useEffect 以降)に回すことに尽きます。

用途に応じて、基本の useEffect、ちらつきを抑えるマウント判定、割り切ってクライアント限定にする next/dynamic、外部ストアを正攻法で扱う useSyncExternalStore を使い分けてください。複数箇所で使うならカスタムフックにまとめると、呼び出し側がハイドレーションを意識せずに済みます。そして Date.now() などブラウザ依存の値も同じ原則で扱えると気づければ、hydration error は怖くなくなります。エラーメッセージの公式な解説は Next.js公式ドキュメントも参照してください。

関連記事

【スーパーSALE限定 76%OFF】【楽天1位!… 【スーパーSALE限定 76%OFF】【楽天1位!… ¥31,320 【国内生産・公式】 新品 大画面 NEC ノー… 【国内生産・公式】 新品 大画面 NEC ノー… ¥152,800 【公式限定2年保証】 モニター 23インチ … 【公式限定2年保証】 モニター 23インチ … ¥14,500 【180日安心保証】22インチ,23インチ,24イ… 【180日安心保証】22インチ,23インチ,24イ… ¥5,980 ロジクール ワイヤレスキーボード K295GP … ロジクール ワイヤレスキーボード K295GP … ¥3,201 【楽天スーパーSALE大特価】ロジクール ワ… 【楽天スーパーSALE大特価】ロジクール ワ… ¥13,481
Rakuten Web Service Center