Jotai atomWithStorage で SSR ハイドレーション mismatch が起きる原因と修正パターン

Jotai React SSR ハイドレーション Next.js Astro
結論

atomWithStorage のミスマッチはSSR で localStorage にアクセスできないが原因。dynamic(ssr: false) または client:only で解決します。

ミスマッチが起きる仕組み

サーバー → localStorage = undefined → initialValue(例: 'light')で HTML を生成
クライアント → localStorage = 'dark'(保存済み)→ 'dark' で初期化

この差分が React のハイドレーション検証に引っかかり、以下のエラーが出ます。

Warning: Text content did not match. Server: "Light Mode" Client: "Dark Mode"
Error: Hydration failed because the initial UI does not match what was rendered on the server.

修正手順

  1. ストレージ値を表示するコンポーネントを特定する(テーマ切替・ユーザー設定表示等)
  2. Next.js の場合は dynamic(() => import('./Component'), { ssr: false }) でそのコンポーネントをクライアント専用にする
  3. Astro の場合は client:only="react" ディレクティブでそのコンポーネントをサーバーレンダリングから除外する
  4. 修正後にローカル開発サーバーでハイドレーション警告がコンソールから消えたことを確認する

修正パターン1:Next.js - dynamic(ssr: false)

ストレージの値を表示するコンポーネントをクライアント専用でロードします。

// app/components/ThemeSwitcher.tsx(クライアントコンポーネント)
'use client';
import { useAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');

export default function ThemeSwitcher() {
  const [theme, setTheme] = useAtom(themeAtom);
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      {theme === 'light' ? 'Light Mode' : 'Dark Mode'}
    </button>
  );
}
// app/layout.tsx または ThemeSwitcherを使う親コンポーネント
import dynamic from 'next/dynamic';

const ThemeSwitcher = dynamic(() => import('./components/ThemeSwitcher'), {
  ssr: false,
  loading: () => <button>Loading...</button>, // ローディング状態を指定
});

export default function Layout({ children }) {
  return (
    <html>
      <body>
        <ThemeSwitcher /> {/* SSR されずクライアントでのみ描画 */}
        {children}
      </body>
    </html>
  );
}

修正パターン2:Astro - client:only

---
// src/pages/index.astro
---

<!-- ✅ client:only でクライアントサイド専用レンダリング -->
<ThemeSwitcher client:only="react" />

<!-- ❌ client:load だとサーバーサイドでも実行されミスマッチが起きる -->
<!-- <ThemeSwitcher client:load /> -->

client:only="react" を指定すると、そのコンポーネントはサーバーでレンダリングされず、ブラウザでのみ描画されます。

修正パターン3:カスタムストレージに typeof window ガードを追加

ストレージのアクセス部分に typeof window !== 'undefined' ガードを追加することで、サーバーサイドでのエラーを防ぎます。ただしこれだけではミスマッチは解消されません(初期値の差は残る)。あくまでエラーの防止のみが目的です。

import { atomWithStorage, createJSONStorage } from 'jotai/utils';

// typeof window ガードで SSR 時のアクセスを防ぐ
const safeLocalStorage = createJSONStorage<string>(() => {
  if (typeof window === 'undefined') {
    // サーバーサイドではダミーのストレージを返す
    return {
      getItem: () => null,
      setItem: () => {},
      removeItem: () => {},
    };
  }
  return window.localStorage;
});

export const themeAtom = atomWithStorage('theme', 'light', safeLocalStorage);

useHydrateAtoms は解決策にならない

はサーバーから渡ってくる props(getServerSideProps や getStaticProps の結果)をアトムに注入するための API です。 のような純粋にクライアント側のデータには使えません。

// ❌ 誤用:atomWithStorage のミスマッチを useHydrateAtoms で解決しようとする
useHydrateAtoms([[themeAtom, 'light']]); // サーバー値を初期値として強制するだけ
// localStorage の 'dark' との差分は残ったまま

設計上の原則

atomWithStorage はクライアント専用の状態管理ツールです。SSR フレームワークで使う場合は、そのアトムの値を表示する UI コンポーネントを完全にクライアント専用にすることが根本的な解決策です。

UI が…解決策
サーバーとクライアントで同じ表示で良いatomWithStorage は使わない。Cookie または RSC の props で渡す
クライアントのみで良いdynamic(ssr: false) または client:only
初期はスケルトン・ロード後に実値を表示dynamic(ssr: false) + ローディング UI

Failure Boundary:テーマ切り替えの FOUC(Flash Of Unstyled Content)

ssr: false にするとコンポーネントのマウントまでの間、ロード中の表示(loading prop)が出ます。テーマの場合、この瞬間にデフォルトテーマが一瞬見えてしまう FOUC が発生します。テーマ切り替えを FOUC なしで実現したい場合は、<script is:inline>next/scriptbeforeInteractive でローカルストレージを読むスクリプトを <html> タグのクラスに即時適用するアプローチが必要です。