React MUIのテーマを永続化する方法 | Next.js、TypeScript使用

MUI(Material-UI)を使用するReactプロジェクトで、ユーザーが選択したテーマ(ライトモード・ダークモード)をページ遷移後も維持する方法を解説する。テーマの永続化により、リロードやページ遷移時にデフォルトの状態に戻る問題を解決できる。

動作確認環境

  • @mui/material: ^5.14.18
  • react: ^17.0.0 || ^18.0.0
  • next: 14.2.12

この記事が解決する課題

  1. ページ遷移時にテーマがリセットされる
    • MUIのテーマ状態が各ページのレイアウトごとにリセットされるため、テーマ切り替えの利便性が損なわれる。
  2. 複数のレイアウト間でテーマロジックが重複する
    • テーマの状態管理や切り替え機能が各レイアウトで重複して実装されることで、保守性が低下する。
  3. テーマ設定の一貫性を確保
    • アプリ全体で同一のテーマ設定を共有し、簡単にカスタマイズ可能にする。

実装手順

以下の3つの手順で実現できる。

  1. コンテキストでテーマ状態を管理する
  2. アプリ全体にテーマを適用する
  3. レイアウトでテーマを利用する

1. コンテキストでテーマ状態を管理する

テーマ管理のロジックをThemeContextとして切り出し、プロジェクト全体で一元的に扱う。これにより、どのコンポーネントでもテーマの状態と切り替え関数を利用可能になる。

src/contexts/ThemeProvider.tsx

"use client";

import React, { createContext, useContext, useState, useEffect } from "react";
import { ThemeProvider as MuiThemeProvider, CssBaseline, PaletteMode } from "@mui/material";
import { createTheme } from "@mui/material/styles";

// **テーマ管理用のコンテキストを作成**
// - `mode`: 現在のテーマモード("light" または "dark")
// - `toggleColorMode`: テーマを切り替える関数
const ThemeContext = createContext<{
  mode: PaletteMode;
  toggleColorMode: () => void;
}>({
  mode: "light", // 初期値を "light" に設定
  toggleColorMode: () => {}, // 初期値として空の関数を設定
});

// **カスタムテーマプロバイダーコンポーネント**
// - アプリ全体でテーマを管理し、コンポーネントにテーマを適用
export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  // **テーマの状態を管理**
  // - 初期値は "light"
  const [mode, setMode] = useState<PaletteMode>("light");

  // **コンポーネントマウント時にテーマをローカルストレージから取得**
  // - ローカルストレージに保存されているテーマモードを復元
  useEffect(() => {
    const savedMode = localStorage.getItem("themeMode") as PaletteMode;
    if (savedMode) setMode(savedMode);
  }, []);

  // **テーマを切り替える関数**
  // - 現在のテーマモードを反転("light" → "dark" またはその逆)
  // - 切り替え後のテーマモードをローカルストレージに保存
  const toggleColorMode = () => {
    setMode((prev) => {
      const newMode = prev === "dark" ? "light" : "dark";
      localStorage.setItem("themeMode", newMode); // 保存
      return newMode;
    });
  };

  // **MUIテーマの設定を作成**
  // - 現在のテーマモードに基づき、カラーパレットを設定
  const customTheme = createTheme({
    palette: {
      mode, // 現在のテーマモード("light" または "dark")
      primary: {
        main: "#234090", // メインカラー
        light: "#5367c8", // メインより明るい色
        dark: "#001b5f", // メインより暗い色
      },
      error: {
        main: "#f26523", // エラーカラー
      },
    },
  });

  return (
    // **コンテキストプロバイダーでテーマ状態を提供**
    // - 子コンポーネントでテーマモードと切り替え関数を使用可能
    <ThemeContext.Provider value={{ mode, toggleColorMode }}>
      {/* MUIのテーマプロバイダーでテーマを適用 */}
      <MuiThemeProvider theme={customTheme}>
        {/* CSSリセットを適用 */}
        <CssBaseline />
        {/* 子コンポーネントを描画 */}
        {children}
      </MuiThemeProvider>
    </ThemeContext.Provider>
  );
};

// **カスタムフック: テーマコンテキストを簡単に利用するためのユーティリティ**
export const useThemeContext = () => useContext(ThemeContext);

解説ポイント

  1. ThemeContext:
    • テーマモードと切り替え関数を格納するコンテキスト。
    • アプリ全体でテーマを共有するために利用。
  2. CustomThemeProvider:
    • コンテキストプロバイダーを使って、テーマの状態と切り替え関数を子コンポーネントに渡す。
    • MUIのThemeProviderを使用して、カスタムテーマを適用。
  3. ローカルストレージの活用:
    • テーマモードをローカルストレージに保存し、次回のページロード時に復元。
  4. toggleColorMode:
    • テーマモードをlightdarkでトグル切り替え。
    • 切り替えたモードをローカルストレージにも保存。
  5. カスタムフック (useThemeContext):
    • テーマの状態と切り替え関数を簡単に取得するためのユーティリティ。

このコードにより、アプリ全体でテーマの一貫性を保ちながら、ユーザーが選択したテーマを永続化できるようになる。

2. アプリ全体にテーマを適用する

Next.jsの_app.tsxCustomThemeProviderをラップし、テーマ管理を全体で共有する。

_app.tsx

import type { AppProps } from "next/app";
import { CustomThemeProvider } from "@/contexts/ThemeProvider";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <CustomThemeProvider>
      <Component {...pageProps} />
    </CustomThemeProvider>
  );
}

3. レイアウトでテーマを利用する

各ページのレイアウトファイルでは、useThemeContextフックを使用してテーマを切り替えるボタンや表示を実装する。

src/app/layout.tsx

"use client";

import AppHeader from "@/components/common/section/Header";
import Footer from "@/components/common/section/Footer";
import { Box, Container, CssBaseline } from "@mui/material";
import { useThemeContext } from "@/contexts/ThemeProvider";

export default function Layout({ children }: { children: React.ReactNode }) {
  // **マウント状態を管理**
  // サーバーサイドレンダリング(SSR)環境でクライアント特有の処理を安全に実行するためのフラグ
  const [isMounted, setIsMounted] = React.useState(false);

  // **テーマコンテキストの利用**
  // - `mode`: 現在のテーマモード("light" または "dark")
  // - `toggleColorMode`: テーマ切り替え関数
  const { mode, toggleColorMode } = useThemeContext();

  // **クライアントマウント時にフラグを設定**
  // サーバーサイド環境で`localStorage`などのクライアント依存処理を防ぐ
  React.useEffect(() => {
    setIsMounted(true);
  }, []);

  // **SSR環境では何も描画しない**
  // テーマやその他クライアントサイドの処理が完了するまで遅延レンダリング
  if (!isMounted) {
    return null;
  }

  return (
    <>
      {/* MUIのCSSリセットを適用 */}
      <CssBaseline />
      {/* ヘッダー: 現在のテーマモードと切り替え関数を渡す */}
      <AppHeader mode={mode} toggleColorMode={toggleColorMode} />
      <Box
        sx={{
          bgcolor: "background.default", // 現在のテーマに応じた背景色を適用
          display: "flex",
          flexDirection: "column",
          minHeight: "100vh",
        }}
      >
        <Container
          sx={{
            width: "100%",
            maxWidth: "100%",
            pt: 12, // ページ内容の上部余白を設定
          }}
        >
          {children}
        </Container>
        <Footer />
      </Box>
    </>
  );
}

解説ポイント

  1. useThemeContextでテーマを取得
    • ThemeContextを利用して現在のテーマモード(mode)とテーマ切り替え関数(toggleColorMode)を取得。
    • この仕組みにより、テーマ切り替えロジックが各レイアウトで重複せず、コンポーネントに渡すだけで簡単にテーマ操作を実現。
  2. modeをUIに反映
    • MUIのbackground.defaultプロパティを利用し、現在のテーマに応じた背景色を自動適用。
  3. toggleColorModeの利用
    • AppHeaderにテーマ切り替え関数を渡すことで、ヘッダー内でボタン操作によるライト・ダークモードの切り替えを実現。
  4. isMountedの利用
    • SSR環境ではlocalStorageやクライアント依存のテーマ設定を安全に処理するため、マウント後に描画を行う。
    • この仕組みでテーマ永続化とSSRの整合性を確保。

これにより、レイアウト全体でテーマ状態を効率よく反映させる仕組みが実現できる。特に、useThemeContextを使うことでコードの重複を排除しつつ、テーマの永続化と切り替え機能を統一的に管理している点が重要。

まとめ

React MUIを使ったテーマ永続化は、ユーザー体験を向上させる重要な要素だ。ローカルストレージを活用してテーマ状態を保存し、ThemeContextを用いた一元管理により、テーマ切り替えをシンプルかつ効率的に実現できる。本記事で紹介した実装は、ページ遷移やリロード時にテーマがリセットされる問題を解決するだけでなく、複数レイアウト間でテーマロジックを統一するメリットもある。特にNext.jsやMaterial-UIを採用しているプロジェクトで効果的に活用可能だ。

コメント

この記事へのトラックバックはありません。