Gatsby + Material-UI ダークモード実装方法

[Gatsby] + [Material-UI] でダークモードを実装する方法です。
Gatsby, Material-UI 及び [React フック]に関するある程度の理解がある人向けの内容になります。

## 概要

[Material-UI 公式による Gatsby example][Gatsby example] をベースに、ダークモード切り替えの実装例を見ます。

(裏テーマ)
[コンテクスト]を使ってコンポーネントツリーの上層にある Material-UI のテーマ `MuiTheme` を下層から更新する。

[ダークモード導入後のデモページ](https://cieloazul310.github.io/gatsby-material-ui-darkmode/)
[ダークモード導入後のプロジェクト例](https://github.com/cieloazul310/gatsby-material-ui-darkmode/)

## 準備と前提知識

この記事では Material-UI 公式による Gatsby example をプロジェクト構成のベースとして最小限のコードでダークモードを実装します。
既存のプロジェクトを持っている人はこのプロジェクト構成に合致しているか確かめて(特に後述する `gatsby-plugin-top-layout` を使っているかどうか)、ここから始める人は Gatsby example を 手順に従ってダウンロードしてください。

```shell
curl https://codeload.github.com/mui-org/material-ui/tar.gz/master | tar -xz --strip=2  material-ui-master/examples/gatsby
mv gatsby [新しいプロジェクト名]
cd [新しいプロジェクト名]
yarn install
```

### バージョン

```txt
- react@16.12.0
- gatsby@2.19.12
- @material-ui/core@4.9.1
```

### `gatsby-plugin-top-layout` について

Material-UI 公式による Gatsby example では、プロジェクト内の plugins ディレクトリに [`gatsby-plugin-top-layout`](https://github.com/mui-org/material-ui/tree/master/examples/gatsby/plugins/gatsby-plugin-top-layout) という自作プラグインが組み込まれています。

Material-UI の基本ですが、mui コンポーネントを使うには、コンポーネントツリーの上層に `MuiTheme` のプロバイダとなる [`<ThemeProvider>`](https://material-ui.com/customization/theming/#theme-provider) を必要とします。
この `gatsby-plugin-top-layout` という自作プラグインは開発・ビルドの際に自動で各ページのコンポーネントツリーの最上位に `<ThemeProvider>` を位置付けてくれます。つまり作成する全てのページで自由に mui コンポーネントを使うことができるようになります。

この記事では `gatsby-plugin-top-layout` を使用していることが前提となります。

```jsx:plugins/gatsby-plugin-top-layout/TopLayout.js
function TopLayout(props) {
  return (
    <React.Fragment>
      <ThemeProvider theme={theme}>
        <CssBaseline /> // normarize.css のようなもの
        {props.children} // => ここで mui コンポーネントが使える
      </ThemeProvider>
    </React.Fragment>
  )
}
```

参考
[Theming - Material-UI](https://material-ui.com/customization/theming/)

### MuiTheme のダークモードの仕様

`MuiTheme` には元々[ダークモードの仕様](https://material-ui.com/customization/palette/#dark-mode)があります。
`createMuiTheme` の引数オブジェクトの `palette.type` を `'dark'` に設定するだけで、背景と文字の色が反転するダークモードになります。 (デフォルトは `'light'`)

```jsx
const theme = createMuiTheme({
  palette: {
    type: 'dark' // 'light'(default) or 'dark'
  }
});
```

参考
[Palette - Material-UI](https://material-ui.com/customization/palette/)

サイトをただ単にダークモードにするのであれば、パレットタイプを `'dark'` にすればいいのですが、実際に必要な機能はダークモードの on/off の切り替えです。

ここまでは準備と前提知識を見てきました。次の章からダークモード切り替えの実装に入ります。

## ダークモード切り替えを実装する

`MuiTheme` を使ったダークモード切り替えの実装にあたり、コンポーネントツリーの最上位(しかもプラグインの中)で扱う `MuiTheme` を**ステート管理の対象とする**方法が問題になります。今回は React の機能である[コンテクスト]を使ったステート管理の方法を紹介します。

### ダークモード実装の概要

各ページのコンポーネントツリー最上位に位置する `plugins/gatsby-plugin-top-layout/TopLayout.js` に[リデューサ] (reducer) を導入し、その `dispatch` 関数を[コンテクスト]として下層のコンポーネントに受け渡す。コンテクストを使ったカスタムフックを作成し他のコンポーネント内から呼び出す。

編集するのは以下の4ファイルです。

1. **作成**: `src/themeReducer.js`
2. **作成**: `src/DispatchContext.js`
3. **変更**: `plugins/gatsby-plugin-top-layout/TopLayout.js`
4. **作成**: `src/components/DarkModeButton.js`

(デモではその他にレイアウトコンポーネントを作成していますが、本筋とは関係がないので省きます)

#### 1. 作成: `src/themeReducer.js`

[リデューサ]を作成し、テーマのステート、アクションを定義します。

```js:src/themeReducer.js
/** TypeScript を使う場合
 *  
 *  export interface ThemeState {
 *    darkMode: boolean;
 *  }
 *  export type Action = { type: 'TOGGLE_DARKMODE' };
 */

// 初期ステートを定義
export const initialState = {
  darkMode: false
};

// リデューサの定義
export const themeReducer = (state, action) => {
  switch (action.type) {
    case 'TOGGLE_DARKMODE':
      return {
        ...state,
        darkMode: !state.darkMode
      };
    default:
      throw new Error();
  }
};
```

#### 2. 作成: `src/DispatchContext.js`

[コンテクスト]と[カスタムフック]の作成をおこないます。

```jsx:src/DispatchContext.js
import React from 'react';

/** TypeScript を使う場合
 *
 *  import { Action } from './themeReducer';
 *  
 *  const DispatchContext =
 *  React.createContext<React.Dispatch<Action>>(...)
 */

// コンテクストの作成
export const DispatchContext = React.createContext(() => {
  throw new Error();
});

// カスタムフックの作成
export function useToggleDarkMode() {
  const dispatch = React.useContext(DispatchContext);
  return React.useCallback(() => dispatch({ type: 'TOGGLE_DARKMODE' }), [dispatch]);
}
```

[コンテクスト] については解説を省きます。なんせ自分も今回初めて使ったものですから。詳しくは React 公式のドキュメントをお読みください。

参考
[コンテクスト - React][コンテクスト]
[React.useContext - React](https://ja.reactjs.org/docs/hooks-reference.html#usecontext)

さて、ここで作成した `useToggleDarkMode` は[カスタムフック]です。
コンテクストを使ったフックにすることでパレットタイプの切り替えをコンポーネントツリー内の**どこからでも**使うことができます。

参考
[カスタムフック - React][カスタムフック]
[React.useCallback - React](https://ja.reactjs.org/docs/hooks-reference.html#usecallback)

#### 3. 変更: `plugins/gatsby-plugin-top-layout/TopLayout.js`

作成したリデューサとコンテクストを `<TopLayout>` コンポーネント内で使用します。

```jsx:plugins/gatsby-plugin-top-layout/TopLayout.js
import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import CssBaseline from '@material-ui/core/CssBaseline';
import { ThemeProvider, createMuiTheme } from '@material-ui/core/styles';

import initialTheme from '../../src/theme';
import { themeReducer, initialState } from '../../src/themeReducer';
import { DispatchContext } from '../../src/DispatchContext';

export default function TopLayout(props) {
  // React.useReducer でリデューサの導入
  const [state, dispatch] = React.useReducer(themeReducer, initialState);
  const { darkMode } = state;
  // React.useMemo でステートに応じた theme を作成
  const theme = React.useMemo(() => {
    return createMuiTheme({
      ...initialTheme,
      palette: {
        type: darkMode ? 'dark' : 'light',
        primary: initialTheme.palette.primary,
        secondary: initialTheme.palette.secondary,
      }
    });
  }, [darkMode]);

  return (
    <React.Fragment>
      <Helmet>
        <meta
          name="viewport"
          content="minimum-scale=1, initial-scale=1, width=device-width"
        />
        <link
          href="https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap"
          rel="stylesheet"
        />
      </Helmet>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        {/* DispatchContext.Provider を挿入 */}
        <DispatchContext.Provider value={dispatch}>
          {props.children}
        </DispatchContext.Provider>
      </ThemeProvider>
    </React.Fragment>
  );
}

TopLayout.propTypes = {
  children: PropTypes.node,
};
```

- [`React.useReducer`][リデューサ] を使って `<TopLayout>` にステートを作成
- [`React.useMemo`](https://ja.reactjs.org/docs/hooks-reference.html#usememo) を使ってステートに応じた `MuiTheme` を再生産
- `<ThemeProvider>` の子要素にプロバイダコンポーネントである [`<DispatchContext.Provider>`](https://ja.reactjs.org/docs/context.html#contextprovider) を挿入
- `<DispatchContext.Provider>` の値に `dispatch` 関数を設定

`dispatch` はコンテクストとして、子要素に渡されます。子要素からステートが変更された時に、`<TopLayout>` 内で `MuiTheme` を再生産し、ページ全体のデザインを更新します。
これでダークモードの実装、コンポーネントツリー下層から最上層の `MuiTheme` の更新という裏テーマを達成しました。

参考
[useReducer - React][リデューサ]
[useMemo - React](https://ja.reactjs.org/docs/hooks-reference.html#usememo)
[Context.Provider - React](https://ja.reactjs.org/docs/context.html#contextprovider)

先ほど作成したカスタムフック `useToggleDarkMode` でパレットタイプ切り替えのアイコンボタンを作ってみましょう。

#### 4. 作成: `src/components/DarkModeButton.js`

`@material-ui/icons` をインストールします。

```sh
yarn add @material-ui/icons
```

```jsx:src/components/DarkModeButton.js
import React from 'react';
import IconButton from '@material-ui/core/IconButton';
import useTheme from '@material-ui/core/styles/useTheme';
import Brightness4 from '@material-ui/icons/Brightness4';
import Brightness5 from '@material-ui/icons/Brightness5';
import { useToggleDarkMode } from '../themeReducer';

function DarkModeButton(props) {
  // パレットタイプを取得
  const paletteType = useTheme().palette.type;
  // パレットタイプ切り替えのフックを使用
  const _toggleDarkMode = useToggleDarkMode();
  
  return (
    <IconButton onClick={_toggleDarkMode} {...props}>
      {paletteType === 'dark' ? <Brightness5 /> : <Brightness4 />}
    </IconButton>
  );
}

export default DarkModeButton;
```

Material-UI の `MuiTheme` もコンテクストです。`useTheme` というカスタムフックでどのコンポーネントからでも `MuiTheme` を取得できます。現在のパレットタイプ取得には `useTheme` フックを使います。
ここで定義した `_toggleDarkMode` という変数は、パレットタイプ切り替えを実行するコールバック関数です。

簡潔なコードで書かれたこの `<DarkModeButton>` を好きな場所に置くだけでダークモードの切り替えが可能になります。
[デモページ](https://cieloazul310.github.io/gatsby-material-ui-darkmode/)では作成したレイアウトコンポーネントの中に `<DarkModeButton>` を設置しています。

参考
[Icons - Material-UI](https://material-ui.com/components/icons/)
[useTheme - Material-UI](https://material-ui.com/styles/api/#usetheme-theme)

### コードの元ネタ

解説ですが、**実は実装が上手くいった後に仕組みを調べている**ので至らない部分があると思います。

このコードの元ネタとなったのは MuiTheme のパレット変更とダークモードを実装している [Material-UI ドキュメント][Material-UI]の[ソース](https://github.com/mui-org/material-ui/tree/master/docs)です。このドキュメントは [Next.js](https://nextjs.org/) 製ですが、このコードを真似して、Gatsby での実装が上手くいった後にどのような仕組みで動いているのか調べました。

参考
[material-ui/docs/src/modules/components/ThemeContext.js](https://github.com/mui-org/material-ui/blob/master/docs/src/modules/components/ThemeContext.js)

今回はダークモードの切り替えのみに絞って簡略化した形で実装しましたが、元ネタのコードは、ダークモードの他に[パレットの配色変更](https://github.com/mui-org/material-ui/blob/master/docs/src/modules/components/ThemeContext.js#L138)や[余白の変更](https://github.com/mui-org/material-ui/blob/master/docs/src/modules/components/ThemeContext.js#L110)、[複数の要素を変更するための合理的なフックの作成](https://github.com/mui-org/material-ui/blob/master/docs/src/modules/components/ThemeContext.js#L236)などを実装していて、大変勉強になります。

## 発展編

今回はシンプルなダークモードの実装をしました。
ダークモードをより良いUIにするためには様々な改良が必要になります。

### パレットカラーを調整する

ダークモードでパレットをそのまま使うと視認性が悪い可能性があります。パレットタイプ切り替え時に、プライマリーカラーとアクセントカラーのメインをやや明るめにしておくと視認性が改善されます。

```js:plugins/gatsby-plugin-top-layout/TopLayout.js
const theme = React.useMemo(() => {
    return createMuiTheme({
      ...initialTheme,
      palette: {
        type: darkMode ? 'dark' : 'light',
        primary: {
          main: darkMode ? initialTheme.palette.primary[300] : initialTheme.palette.primary.main
        },
        secondary: {
          main: darkMode ? initialTheme.palette.secondary[300] : initialTheme.palette.secondary.main
        },
      }
    });
  }, [darkMode]);
```

参考
[Color - Material-UI](https://material-ui.com/customization/color/)

### メディアクエリを使う

CSS メディアクエリの [`prefers-color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) は現在のシステムがライトテーマかダークテーマかを判別します。
また、Material-UI には JavaScript から CSS のメディアクエリを使うカスタムフック [`useMediaQuery`](https://material-ui.com/components/use-media-query/) があります。

Material-UI のドキュメントには `useMediaQuery` と `React.useMemo` を組み合わせて [MuiTheme のパレットテーマを動的に変更するサンプルコード](https://material-ui.com/customization/palette/#user-preference)が載っています。

参考
[useMediaQuery - Material-UI](https://material-ui.com/components/use-media-query/)
[prefers-color-scheme - MDN web docs](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme)

### Cookie や Local Storage を使って設定を保存

このデモでは、ダークモードのページを一度閉じてしまうと、次に開いた場合にまたライトテーマが適用されてしまいます。前回閉じた画面と同じテーマを表示するためには、[Cookie](https://developer.mozilla.org/ja/docs/Web/API/Document/cookie) や [Local Storage](https://developer.mozilla.org/ja/docs/Web/API/Window/localStorage) を使って設定を保存する必要があります。

現在 Cookie に対して風当たりが強いご時世なので Local Storage の方がいいかなと思いますが、Material-UI 公式や Twitter Web App ではテーマの保存にまだ Cookie が使われているので一応紹介しておきます。ちなみに、Gatsby 公式ドキュメントは Local Storage を使っていました。

Local Storage を使うときは、 React フックのレシピ集である [useHooks](https://usehooks.com) に載っている [`useLocalStorage`](https://usehooks.com/useLocalStorage) を使うといいと思います。

参
[Cookie - MDN web docs](https://developer.mozilla.org/ja/docs/Web/API/Document/cookie)
[LocalStorage - MDN web docs](https://developer.mozilla.org/ja/docs/Web/API/Window/localStorage)
[useLocalStorage - useHooks](https://usehooks.com/useLocalStorage)
[useDarkMode - useHooks](https://usehooks.com/useDarkMode/)

## まとめ

ダークモード実装のため、コンポーネントツリー最上位に位置する `TopLayout.js` に[リデューサ] (reducer) を導入し、その `dispatch` 関数を[コンテクスト]として下層のコンポーネントに受け渡すことで `MuiTheme` を下層から更新できるようにしました。また、コンテクストを利用した[カスタムフック]を作成することで、他のどのコンポーネント内からでもパレットタイプの切り替えが可能になりました。

props で受け渡すのではなくコンテクストとフックを使った実装なので、既存プロジェクトでも小さい改修コストで導入できます。

今まであまり使っていなかった[コンテクスト]や[カスタムフック]に対する知見が得られてとても勉強になりました。

## 参考

- [Material-UI Gatsby example - GitHub][Gatsby example]
- [フックの導入 - React][React フック]
- [コンテクスト - React][コンテクスト]
- [Theming - Material-UI](https://material-ui.com/customization/theming/)
- [Palette - Material-UI](https://material-ui.com/customization/palette/)
- [カスタムフックの作成 - React][カスタムフック]
- [Using React Context API with Gatsby - Gatsby](https://www.gatsbyjs.org/blog/2019-01-31-using-react-context-api-with-gatsby/)

<!-- References -->
[Gatsby example]: https://github.com/mui-org/material-ui/tree/master/examples/gatsby "mui-org/material-ui/examples/gatsby - GitHub"

[React フック]: https://ja.reactjs.org/docs/hooks-intro.html "フックの導入 - React"
[コンテクスト]: https://ja.reactjs.org/docs/context.html "コンテクスト - React"
[リデューサ]: https://ja.reactjs.org/docs/hooks-reference.html#usereducer "useReducer - React"
[useReducer]: https://ja.reactjs.org/docs/hooks-reference.html#usereducer "フックAPIリファレンス - React"
[カスタムフック]: https://ja.reactjs.org/docs/hooks-custom.html "カスタムフックの作成 - React"

[React]: https://reactjs.org/ "React"
[Material-UI]: https://material-ui.com "Material-UI"
[Gatsby]: https://www.gatsbyjs.org "Gatsby"