Produced by Fourier

Next.jsでMUIとRecoilを用いたダークモードの実装

Ohashi Ohashi カレンダーアイコン 2022.08.17

こちらで使用したサンプルコードは下記にありますので詳しく知りたい方はご参照ください。

GitHub - t98o84/dark-mode-with-nextjs-mui-recoil-localstorage thumbnail

GitHub - t98o84/dark-mode-with-nextjs-mui-recoil-localstorage

Contribute to t98o84/dark-mode-with-nextjs-mui-recoil-localstorage development by creating an account on GitHub.

https://github.com/t98o84/dark-mode-with-nextjs-mui-recoil-localstorage

概要

ダークモードを実装するにあたってさまざまな方法がありますが、今回は Next.js とUIフレームワークの MUI 、状態管理ライブラリの Recoil を使用しローカルストレージでモードを保持し実装する方法について解説したいと思います。

ライブラリのインストール

Recoilのインストール

yarn add recoil

MUIのインストール

yarn add @mui/material @emotion/react @emotion/styled

ディレクトリ構成

.
├── .eslintrc.json
├── .gitignore
├── README.md
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages
│   ├── _app.tsx
│   └── index.tsx
├── public
│   ├── favicon.ico
│   └── vercel.svg
├── styles
│   ├── Home.module.css
│   └── globals.css
├── tsconfig.json
└── yarn.lock

ダークモードの実装

MUIでダークモードを実装する方法

MUI にはデフォルトで light モードと dark モードが用意されており、 createTheme 関数でテーマを作成する際、以下のように引数でモードの指定を行うことで切り替えることが可能です。

import { createTheme } from '@mui/material';

function MyApp({ Component, pageProps }: AppProps) {
  const theme = createTheme({
    palette: {
      mode: 'dark',
    },
  });

  return <Component {...pageProps} />}

先ほど作成したテーマを ThemeProvider  の theme プロパティに渡すことで反映されます。注意点として CssBaseline  コンポーネントを配置しデフォルトCSSのリセットを行わないと、パレットモードを切り替えても色が変わらないため必ず配置する必要があります。

import { ThemeProvider } from '@mui/material/styles';

function MyApp({ Component, pageProps }: AppProps) {
  // 省略

  return (
    <ThemeProvider theme={theme}>
            <CssBaseline />
      <Component {...pageProps} />
    </ThemeProvider>);
}

Recoilを使用せずダークモードを実装

まずは Recoil を使用せずダークモードを実装していきます。パレットモードの状態を useState を使用し保持するよう変更します。また、デフォルトのパレットモードは useMediaQuery でユーザーのシステム設定から取得するようにします。

import { createTheme, CssBaseline, PaletteMode } from '@mui/material';
import { useState } from 'react';

function MyApp({ Component, pageProps }: AppProps) {
  const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
  const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);

  const theme = createTheme({
    palette: {
      mode: paletteMode,
    },
  });

  // 省略
}

パレットモードの状態を保持するよう変更したら、次はパレットモードを切り替えできるようにします。

import { createTheme, CssBaseline, PaletteMode, Switch } from '@mui/material';
import { useState } from 'react';

function MyApp({ Component, pageProps }: AppProps) {
  const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
  const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);
  const [isDarkMode, setIsDarkMode] = useState(paletteMode === 'dark');

  // 省略

  const handleChangePaletteMode =(event: React.ChangeEvent<HTMLInputElement>) => {
    setPaletteMode(event.target.checked ? 'dark' : 'light');
    setIsDarkMode(event.target.checked);
  };

  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <Box p={4}>
        Palette Mode :
        <Switch checked={isDarkMode} onChange={handleChangePaletteMode} />
        {paletteMode}
      </Box>
      <Component {...pageProps} />
    </ThemeProvider>);
}

これで左上に表示されているスイッチをクリックすることで、ライトモードとダークモードが切り替えできるようになりました。

しかしこのままだとリロードした際にパレットモードがリセットされてしまうので、ローカルストレージにパレットモードを保存するよう変更しましょう。コードが重複していますがすぐに不要になるのでこのまま進めていきます。

import { useEffect, useState } from 'react';

function MyApp({ Component, pageProps }: AppProps) {
  const paletteModeStorageKey = 'palette_mode';

  // 省略

  useEffect(() => {
    const paletteMode = localStorage.getItem(paletteModeStorageKey) ?? prefersPaletteMode;
    if (['light', 'dark'].includes(paletteMode)) {
      setPaletteMode(paletteMode as PaletteMode);
      setIsDarkMode(paletteMode === 'dark);
      localStorage.setItem(paletteModeStorageKey, paletteMode);
    }
  });

  const handleChangePaletteMode =(event: React.ChangeEvent<HTMLInputElement>) => {
    setPaletteMode(event.target.checked ? 'dark' : 'light');
    setIsDarkMode(event.target.checked);
    localStorage.setItem(paletteModeStorageKey, paletteMode);
  };

  // 省略
}

以上で Recoil を使用しない場合のダークモードの実装が完了しました。最終的なコードは以下になります。

最終的なコード

import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { ThemeProvider } from '@mui/material/styles';
import { Box, createTheme, CssBaseline, PaletteMode, Switch, useMediaQuery } from '@mui/material';
import { useEffect, useState } from 'react';

const paletteModes = ['light', 'dark'];
const paletteModeStorageKey = 'palette_mode';

function MyApp({ Component, pageProps }: AppProps) {
  const paletteModeStorageKey = 'palette_mode';
  const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
  const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);
  const [isDarkMode, setIsDarkMode] = useState(paletteMode === 'dark');

  const theme = createTheme({
    palette: {
      mode: paletteMode,
    },
  });

  useEffect(() => {
    const paletteMode = localStorage.getItem(paletteModeStorageKey) ?? prefersPaletteMode;
    if (['light', 'dark'].includes(paletteMode)) {
      setPaletteMode(paletteMode as PaletteMode);
      setIsDarkMode(paletteMode === 'dark);
      localStorage.setItem(paletteModeStorageKey, paletteMode);
    }
  });

  const handleChangePaletteMode =(event: React.ChangeEvent<HTMLInputElement>) => {
    setPaletteMode(event.target.checked ? 'dark' : 'light');
    setIsDarkMode(event.target.checked);
    localStorage.setItem(paletteModeStorageKey, paletteMode);
  };

  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <Box p={4}>
        Palette Mode :
        <Switch checked={isDarkMode} onChange={handleChangePaletteMode} />
        {paletteMode}
      </Box>
      <Component {...pageProps} />
    </ThemeProvider>);
}

export default MyApp

Recoilを使用するよう変更

それでは先ほどの実装から Recoil を使用した実装へ変更していきましょう。

まずはプロジェクトディレクトリの直下に store  ディレクトリを作成します。次に palette-mode.ts  ファイルを store ディレクトリの直下に作成します。

次に先ほど作成した palette-mode.ts  に Atom を定義します。この時 undefined  をデフォルト値とすることで、未設定の場合は useMediaQuery  でユーザーのシステム設定からパレットモードを設定できるようにしておきます。

import { atom } from 'recoil';
import { PaletteMode } from '@mui/material';

const paletteModeState = atom<PaletteMode | undefined>({
  key: 'PaletteMode',
  default: undefined,
});

これだけで、 useRecoilState  関数の引数に paletteModeState  を渡すことで値とセッターが取得できますが、ここではカスタムフックを定義したいと思います。

export type setPaletteModeType = (paletteMode: PaletteMode) => void;
export type usePaletteModeType = () => [PaletteMode, setPaletteModeType]

export const usePaletteMode: usePaletteModeType = () => {
  const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
  const [paletteMode, setPaletteMode] = useRecoilState(paletteModeState);

  return [
    paletteMode ?? prefersPaletteMode,
    (paletteMode: PaletteMode) => setPaletteMode(paletteMode),
  ];
};

カスタムフックを定義したら、ローカルストレージに値を保持するようにします。 まず、 localStorageEffect  関数を定義しそれを paletteModeState  の effects  に追加します。

import { atom, AtomEffect, useRecoilState } from 'recoil';

const PALETTE_MODE_STORAGE_KEY = 'palette_mode'

const localStorageEffect: (key: string) => AtomEffect<PaletteMode | undefined> =
  (key) =>
  ({ onSet }) => {
    onSet((newValue, _, isReset) => {
      if (isReset || newValue === undefined) {
        localStorage.removeItem(key);
        return;
      }

      localStorage.setItem(key, newValue);
    });
  };

const paletteModeState = atom<PaletteMode | undefined>({
  key: 'PaletteMode',
  default: undefined,
  effects: [localStorageEffect(PALETTE_MODE_STORAGE_KEY)],
});

そして、カスタムフックにパレットモードをローカルストレージから取得し有効な値であれば設定する処理を追加します。

export const usePaletteMode: usePaletteModeType = () => {
  // 省略

  useEffect(() => {
    const paletteMode = localStorage.getItem(PALETTE_MODE_STORAGE_KEY);

    if (paletteMode !== null && ['light', 'dark'].includes(paletteMode)) {
      setPaletteMode(paletteMode as PaletteMode)
    }
  });

  // 省略
};

以上で Recoil   を使用した実装が完了したのでこれを使用するよう変更しましょう。使用するためには RecoilRoot で囲われたコンポーネントで使用する必要があるので、まず RecoilRoot で囲います。

import { RecoilRoot } from 'recoil';

function MyApp({ Component, pageProps }: AppProps) {
  // 省略

  return (
    <RecoilRoot>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <Box p={4}>
          Palette Mode :
          <Switch checked={isDarkMode} onChange={handleChangePaletteMode} />
          {paletteMode}
        </Box>
        <Component {...pageProps} />
      </ThemeProvider>
    </RecoilRoot>);
}

次に _app.tsxRecoilRoot に囲われていないため usePaletteMode  が使用できないので、 ThemeProvider  以下をコンポーネント化します( <Component {...pageProps} />  は除く)。

まず、プロジェクトディレクトリ直下に componetnts ディレクトリを作成し、その直下に Theme.tsx ファイルを作成します。

ファイルを作成したら Theme コンポーネントを定義していきます。 _app.tsx のロジック部分と ThemeProvider  以下を全て移植します。この時 Theme コンポーネントの引数として ReactNode  を受け取るようにします。

import { Box, createTheme, CssBaseline, Switch, ThemeProvider } from "@mui/material";
import { ReactNode, useEffect, useState } from "react";
import { usePaletteMode } from "../store/palette-mode";

export const Theme = ({ children }: { children: ReactNode }) => {
  const paletteModeStorageKey = 'palette_mode';
  const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
  const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);
  const [isDarkMode, setIsDarkMode] = useState(paletteMode === 'dark');

  const theme = createTheme({
    palette: {
      mode: paletteMode,
    },
  });

  useEffect(() => {
    const paletteMode = localStorage.getItem(paletteModeStorageKey) ?? prefersPaletteMode;
    if (['light', 'dark'].includes(paletteMode)) {
      setPaletteMode(paletteMode as PaletteMode);
      setIsDarkMode(paletteMode === 'dark);
      localStorage.setItem(paletteModeStorageKey, paletteMode);
    }
  });

  const handleChangePaletteMode =(event: React.ChangeEvent<HTMLInputElement>) => {
    setPaletteMode(event.target.checked ? 'dark' : 'light');
    setIsDarkMode(event.target.checked);
    localStorage.setItem(paletteModeStorageKey, paletteMode);
  };

  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <Box p={4}>
        Palette Mode :
        <Switch checked={isDarkMode} onChange={handleChangePaletteMode} />
        {paletteMode}
      </Box>
      {children}
    </ThemeProvider>)
}

移植したら useEffect  を削除し、以下のコードを usePaletteMode  を使用するよう変更します。

// Before
const paletteModeStorageKey = 'palette_mode';
const prefersPaletteMode = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
const [paletteMode, setPaletteMode] = useState<PaletteMode>(prefersPaletteMode);

// After
const [paletteMode, setPaletteMode] = usePaletteMode();

次にサーバー側とクライアント側でパレットモードが異なる可能性があるので、 Theme  コンポーネントの useState(paletteMode === 'dark') の後に以下を追加しクライアント側でダークモードか否かを判定し再設定するようにします。

useEffect(() => setIsDarkMode(paletteMode === 'dark'));

最後に _app.tsxThemeProvider  以下を先ほど作成した Theme  コンポーネントに置き換えます。

import { Theme } from '../components/Theme';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <RecoilRoot>
      <Theme>
        <Component {...pageProps} />
      </Theme>
    </RecoilRoot>);
}

以上で完成になります。サイトにアクセスし左上に表示されているスイッチをクリックすることで、ライトモードとダークモードが切り替えできることを確認してみましょう。

さいごに

今回は、ダークモードを Next.jsMUIRecoil  を使用してローカルストレージにモードを保持する方法について紹介しました。

最近ではダークモードが実装されているサイトが増えてきたように思います。この記事がダークモードを実装する際の参考になりましたら幸いです。

Ohashi

Ohashi slash forward icon Engineer

主にLaravelなどのバックエンドを中心にサーバー周りも担当しています。目標は腕周り40cm 越え。

関連記事