
こちらで使用したサンプルコードは下記にありますので詳しく知りたい方はご参照ください。
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.tsx
は RecoilRoot
に囲われていないため 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.tsx
で ThemeProvider
以下を先ほど作成した Theme
コンポーネントに置き換えます。
import { Theme } from '../components/Theme';

function MyApp({ Component, pageProps }: AppProps) {
 return (
 <RecoilRoot>
 <Theme>
 <Component {...pageProps} />
 </Theme>
 </RecoilRoot>);
}
以上で完成になります。サイトにアクセスし左上に表示されているスイッチをクリックすることで、ライトモードとダークモードが切り替えできることを確認してみましょう。
さいごに
今回は、ダークモードを Next.js ・ MUI ・ Recoil を使用してローカルストレージにモードを保持する方法について紹介しました。
最近ではダークモードが実装されているサイトが増えてきたように思います。この記事がダークモードを実装する際の参考になりましたら幸いです。