Produced by FOURIER

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

OhashiOhashi calender 2022.8.17

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

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

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

https://github.com/takuya829/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} />
}

先ほど作成したテーマをThemeProviderthemeプロパティに渡すことで反映されます。注意点として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.tsAtomを定義します。この時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 関数を定義しそれをpaletteModeStateeffects に追加します。

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 / Engineer

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