SSR環境でのダークモード実装:ハイドレーションエラーと画面のちらつきを防ぐ技術

DevRemix
Published at 3/10/2025Updated at 3/9/2025
SSR環境でのダークモード実装:ハイドレーションエラーと画面のちらつきを防ぐ技術

こんにちは、Ticklonです。前回の記事「モダンウェブ開発におけるダークモード実装 - Reactと各フレームワークの比較」では、様々なフレームワークでのダークモード実装方法について解説しました。今回はその補足として、特にRemix.jsなどのSSR(サーバーサイドレンダリング)環境で発生しがちな問題とその解決策に焦点を当てます。

SSR環境での2つの課題

SSR環境でダークモードを実装する際に、主に2つの課題に直面します:

  1. ハイドレーションエラー:サーバーとクライアントでレンダリングされるHTMLの不一致によるエラー
  2. 画面のちらつき(フラッシュ):初回読み込み時に一瞬ライトモードが表示されてからダークモードに切り替わる現象

前回の記事では、こうした問題への対処法としてCookieを用いる方法を簡単に紹介しましたが、今回はより実践的な解決策を私のプロジェクトの実装例と共に詳しく解説します。

ハイドレーションエラーとは何か

まず、ハイドレーションエラーについて理解しましょう。SSRアプリケーションでは、最初にサーバーがHTMLをレンダリングし、それをクライアントに送信します。クライアント側では、送られてきたHTMLに対して、Reactがイベントリスナーや状態管理などの機能を「ハイドレート(水分補給)」する処理を行います。

この際、サーバーがレンダリングしたHTMLとクライアントが期待するHTMLの構造が異なると、ハイドレーションエラーが発生します。ダークモード実装の場合、典型的な問題は以下のようなシナリオです:

  1. サーバーはユーザーの設定を知らないため、デフォルトのライトモードでHTMLをレンダリング
  2. クライアント側では、ローカルストレージを読み取ってダークモードだと判断
  3. クライアント側のReactが再レンダリングを行い、HTMLの構造が変わる
  4. サーバーとクライアントのHTMLの不一致により、ハイドレーションエラーが発生

画面のちらつき問題

もう一つの問題は、初回読み込み時の「ちらつき」です。ユーザーがダークモード設定をしていても、最初にサーバーからライトモードのHTMLが送られてくるため、一瞬ライトモードが表示された後にJavaScriptが実行されてダークモードに切り替わります。これは特に暗い環境でアプリを使用している場合、不快な体験になります。

実践的な解決策:インラインスクリプトによる先読み

以下が私のRemixプロジェクトでの解決策です。root.tsxファイルの重要な部分を見てみましょう:

// root.tsx (抜粋)
<html suppressHydrationWarning lang="en">
    <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <script dangerouslySetInnerHTML={{
            __html: ` if ( localStorage.getItem("site-ui-theme")==='dark' ||
                       ((!('site-ui-theme' in localStorage) || localStorage.getItem("site-ui-theme")==='system' ) &&
                        window.matchMedia('(prefers-color-scheme: dark)').matches) ) { 
                          document.documentElement.classList.add('dark') 
                       }
                       else { 
                          document.documentElement.classList.remove('dark') 
                       } `,
        }} />
        // ...その他のhead要素

このコードでは、2つの重要なテクニックを使用しています:

1. suppressHydrationWarning属性

<html suppressHydrationWarning lang="en">

この属性は、Reactにハイドレーションの不一致を警告しないように指示します。通常、ハイドレーションエラーは修正すべき重要な問題ですが、ダークモードの実装のように、初期レンダリングとクライアントレンダリングで意図的に違いを出したい場合には、この属性が役立ちます。

2. インラインスクリプトによる事前チェック

<script dangerouslySetInnerHTML={{
    __html: ` if ( localStorage.getItem("site-ui-theme")==='dark' ||
               ((!('site-ui-theme' in localStorage) || localStorage.getItem("site-ui-theme")==='system' ) &&
                window.matchMedia('(prefers-color-scheme: dark)').matches) ) { 
                  document.documentElement.classList.add('dark') 
               }
               else { 
                  document.documentElement.classList.remove('dark') 
               } `,
}} />

このインラインスクリプトは、ページのHTMLがパースされる段階で、Reactがレンダリングを開始する前に実行されます。スクリプトの内容を詳しく見てみましょう:

  1. localStorage.getItem("site-ui-theme")==='dark'

    • ユーザーが明示的にダークモードを選択している場合
  2. または以下の条件を満たす場合:

    • (!('site-ui-theme' in localStorage) || localStorage.getItem("site-ui-theme")==='system')
      • ユーザーがまだテーマを設定していない、またはシステム設定に従うよう設定している
    • window.matchMedia('(prefers-color-scheme: dark)').matches
      • システムがダークモードを使用している

これらの条件のいずれかが満たされる場合、document.documentElement.classList.add('dark')が実行され、HTMLのルート要素にdarkクラスが追加されます。それ以外の場合は、darkクラスが削除されます。

なぜこのアプローチが効果的なのか

このアプローチには以下の利点があります:

  1. ちらつきの防止:JavaScriptの実行を待たずに、HTMLのパース時に即座にテーマが適用されるため、ちらつきが発生しません。

  2. UXの向上:ユーザーは常に一貫したテーマを体験でき、不快な明るい画面の一瞬の表示を避けられます。

  3. 柔軟性:ユーザーの明示的な設定(site-ui-theme)とシステム設定の両方に対応できます。

  4. コード分離の維持:メインのテーマ切り替えロジックは通常のReactコンポーネント内に保持しつつ、初期化のみをインラインスクリプトで行います。

実装の詳細:ThemeProviderコンポーネント

上記のインラインスクリプトと連携するThemeProviderコンポーネントの実装例を見てみましょう:

// ThemeProvider.tsx
import { createContext, useContext, useEffect, useState } from "react";

type Theme = "light" | "dark" | "system";
type ThemeContextType = {
  theme: Theme;
  setTheme: (theme: Theme) => void;
};

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(() => {
    // サーバーサイドレンダリング時はシステム設定をデフォルトにする
    if (typeof window === "undefined") return "system";
    
    // クライアント側では保存された設定を使用
    return (localStorage.getItem("site-ui-theme") as Theme) || "system";
  });

  // テーマが変更されたら保存し、クラスを更新
  useEffect(() => {
    localStorage.setItem("site-ui-theme", theme);
    
    // システム設定に従う場合
    if (theme === "system") {
      const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
      document.documentElement.classList.toggle("dark", isDark);
      
      // システム設定の変更を監視
      const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
      const handler = (e: MediaQueryListEvent) => {
        document.documentElement.classList.toggle("dark", e.matches);
      };
      
      mediaQuery.addEventListener("change", handler);
      return () => mediaQuery.removeEventListener("change", handler);
    } else {
      // 明示的な設定の場合
      document.documentElement.classList.toggle("dark", theme === "dark");
    }
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

このコンポーネントは、インラインスクリプトで設定されたダークモードの初期状態を引き継ぎつつ、ユーザーがテーマを切り替えられるようにします。特筆すべき点は以下の通りです:

  1. サーバーサイドレンダリング時は"system"をデフォルトにし、クライアント側でのみローカルストレージから読み込みます。

  2. "system"テーマが選択されている場合、システムの設定変更をリアルタイムで監視し、テーマを自動的に更新します。

  3. テーマの状態はコンテキストを通じてアプリ全体で利用可能になります。

TailwindCSSとの統合

前回の記事で説明したように、TailwindCSSと組み合わせる場合は、tailwind.config.jsdarkMode: 'class'を設定する必要があります:

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...他の設定
}

これにより、HTMLのルート要素にdarkクラスが付いているときのみ、TailwindCSSのdark:プレフィックス付きのクラスが適用されます。

ユーザーインターフェースの実装

最後に、ユーザーがテーマを切り替えるためのUIを実装します:

// ThemeToggle.tsx
import { useTheme } from "./ThemeProvider";

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <div className="flex items-center gap-4">
      <select
        value={theme}
        onChange={(e) => setTheme(e.target.value as "light" | "dark" | "system")}
        className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded px-2 py-1"
      >
        <option value="light">ライト</option>
        <option value="dark">ダーク</option>
        <option value="system">システム設定に従う</option>
      </select>
      <span className="text-black dark:text-white">
        現在のテーマ: {theme === "system" 
          ? `システム設定 (${window.matchMedia("(prefers-color-scheme: dark)").matches ? "ダーク" : "ライト"})`
          : theme === "dark" ? "ダーク" : "ライト"}
      </span>
    </div>
  );
}

まとめ:高品質なダークモード実装のためのベストプラクティス

SSR環境でのダークモード実装におけるハイドレーションエラーと画面のちらつきを防ぐためのベストプラクティスは以下の通りです:

  1. インラインスクリプトを活用する:ページのパース時に即座にテーマを適用する

  2. suppressHydrationWarning属性を使用する:意図的なHTML不一致を許容する

  3. テーマ状態の三層構造を考慮する

    • ユーザーの明示的な設定(light/dark)
    • システム設定に従う選択肢(system)
    • 実際の表示状態(darkクラスの有無)
  4. システム設定の変更を監視するsystem設定時に動的にテーマを更新する

  5. セマンティックな変数でテーマカラーを管理する:メンテナンス性を高める

こうした細部への配慮がユーザー体験を大きく向上させます。ぜひ皆さんのプロジェクトにも取り入れてみてください。

Ticklon