2020年02月09日
Gatsby + Material-UI ダークモード実装方法
 Gatsby   +  Material-UI   でダークモードを実装する方法です。
Gatsby, Material-UI 及び  React フック  に関するある程度の理解がある人向けの内容になります。
概要
Material-UI 公式による Gatsby example をベースに、ダークモード切り替えの実装例を見ます。
(裏テーマ)
 コンテクスト  を使ってコンポーネントツリーの上層にある Material-UI のテーマ MuiTheme を下層から更新する。
準備と前提知識
この記事では Material-UI 公式による Gatsby example をプロジェクト構成のベースとして最小限のコードでダークモードを実装します。
既存のプロジェクトを持っている人はこのプロジェクト構成に合致しているか確かめて(特に後述する gatsby-plugin-top-layout を使っているかどうか)、ここから始める人は Gatsby example を 手順に従ってダウンロードしてください。
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
バージョン
- 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   という自作プラグインが組み込まれています。
Material-UI の基本ですが、mui コンポーネントを使うには、コンポーネントツリーの上層に MuiTheme のプロバイダとなる  <ThemeProvider>   を必要とします。
この gatsby-plugin-top-layout という自作プラグインは開発・ビルドの際に自動で各ページのコンポーネントツリーの最上位に <ThemeProvider> を位置付けてくれます。つまり作成する全てのページで自由に mui コンポーネントを使うことができるようになります。
この記事では gatsby-plugin-top-layout を使用していることが前提となります。
// 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>
  );
}
MuiTheme のダークモードの仕様
MuiTheme には元々 ダークモードの仕様  があります。
createMuiTheme の引数オブジェクトの palette.type を 'dark' に設定するだけで、背景と文字の色が反転するダークモードになります。 (デフォルトは 'light')
const theme = createMuiTheme({
  palette: {
    type: "dark", // 'light'(default) or 'dark'
  },
});
サイトをただ単にダークモードにするのであれば、パレットタイプを 'dark' にすればいいのですが、実際に必要な機能はダークモードの on/off の切り替えです。
ここまでは準備と前提知識を見てきました。次の章からダークモード切り替えの実装に入ります。
ダークモード切り替えを実装する
MuiTheme を使ったダークモード切り替えの実装にあたり、コンポーネントツリーの最上位(しかもプラグインの中)で扱う MuiTheme をステート管理の対象とする方法が問題になります。今回は React の機能である コンテクスト  を使ったステート管理の方法を紹介します。
ダークモード実装の概要
各ページのコンポーネントツリー最上位に位置する plugins/gatsby-plugin-top-layout/TopLayout.js に リデューサ   (reducer) を導入し、その dispatch 関数を コンテクスト  として下層のコンポーネントに受け渡す。コンテクストを使ったカスタムフックを作成し他のコンポーネント内から呼び出す。
編集するのは以下の4ファイルです。
- 作成: 
src/themeReducer.js - 作成: 
src/DispatchContext.js - 変更: 
plugins/gatsby-plugin-top-layout/TopLayout.js - 作成: 
src/components/DarkModeButton.js 
(デモではその他にレイアウトコンポーネントを作成していますが、本筋とは関係がないので省きます)
1. 作成: src/themeReducer.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
// 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 公式のドキュメントをお読みください。
さて、ここで作成した useToggleDarkMode は カスタムフック  です。
コンテクストを使ったフックにすることでパレットタイプの切り替えをコンポーネントツリー内のどこからでも使うことができます。
3. 変更: plugins/gatsby-plugin-top-layout/TopLayout.js
作成したリデューサとコンテクストを <TopLayout> コンポーネント内で使用します。
// 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を使ってステートに応じたMuiThemeを再生産 <ThemeProvider>の子要素にプロバイダコンポーネントである<DispatchContext.Provider>を挿入<DispatchContext.Provider>の値にdispatch関数を設定
dispatch はコンテクストとして、子要素に渡されます。子要素からステートが変更された時に、<TopLayout> 内で MuiTheme を再生産し、ページ全体のデザインを更新します。
これでダークモードの実装、コンポーネントツリー下層から最上層の MuiTheme の更新という裏テーマを達成しました。
先ほど作成したカスタムフック useToggleDarkMode でパレットタイプ切り替えのアイコンボタンを作ってみましょう。
4. 作成: src/components/DarkModeButton.js
@material-ui/icons をインストールします。
yarn add @material-ui/icons
// 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> を好きな場所に置くだけでダークモードの切り替えが可能になります。
 デモページ  では作成したレイアウトコンポーネントの中に <DarkModeButton> を設置しています。
コードの元ネタ
解説ですが、実は実装が上手くいった後に仕組みを調べているので至らない部分があると思います。
このコードの元ネタとなったのは MuiTheme のパレット変更とダークモードを実装している Material-UI ドキュメント の ソース です。このドキュメントは Next.js 製ですが、このコードを真似して、Gatsby での実装が上手くいった後にどのような仕組みで動いているのか調べました。
参考
今回はダークモードの切り替えのみに絞って簡略化した形で実装しましたが、元ネタのコードは、ダークモードの他に パレットの配色変更 や 余白の変更 、 複数の要素を変更するための合理的なフックの作成 などを実装していて、大変勉強になります。
発展編
今回はシンプルなダークモードの実装をしました。
ダークモードをより良いUIにするためには様々な改良が必要になります。
パレットカラーを調整する
ダークモードでパレットをそのまま使うと視認性が悪い可能性があります。パレットタイプ切り替え時に、プライマリーカラーとアクセントカラーのメインをやや明るめにしておくと視認性が改善されます。
// 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]);
参考
メディアクエリを使う
CSS メディアクエリの  prefers-color-scheme   は現在のシステムがライトテーマかダークテーマかを判別します。
また、Material-UI には JavaScript から CSS のメディアクエリを使うカスタムフック  useMediaQuery   があります。
Material-UI のドキュメントには useMediaQuery と React.useMemo を組み合わせて  MuiTheme のパレットテーマを動的に変更するサンプルコード  が載っています。
参考
Cookie や Local Storage を使って設定を保存
このデモでは、ダークモードのページを一度閉じてしまうと、次に開いた場合にまたライトテーマが適用されてしまいます。前回閉じた画面と同じテーマを表示するためには、 Cookie や Local Storage を使って設定を保存する必要があります。
現在 Cookie に対して風当たりが強いご時世なので Local Storage の方がいいかなと思いますが、Material-UI 公式や Twitter Web App ではテーマの保存にまだ Cookie が使われているので一応紹介しておきます。ちなみに、Gatsby 公式ドキュメントは Local Storage を使っていました。
Local Storage を使うときは、 React フックのレシピ集である  useHooks   に載っている  useLocalStorage   を使うといいと思います。
まとめ
ダークモード実装のため、コンポーネントツリー最上位に位置する TopLayout.js に リデューサ   (reducer) を導入し、その dispatch 関数を コンテクスト  として下層のコンポーネントに受け渡すことで MuiTheme を下層から更新できるようにしました。また、コンテクストを利用した カスタムフック  を作成することで、他のどのコンポーネント内からでもパレットタイプの切り替えが可能になりました。
props で受け渡すのではなくコンテクストとフックを使った実装なので、既存プロジェクトでも小さい改修コストで導入できます。
今まであまり使っていなかった コンテクスト や カスタムフック に対する知見が得られてとても勉強になりました。
参考
- Material-UI Gatsby example - GitHub
 - フックの導入 - React
 - コンテクスト - React
 - Theming - Material-UI
 - Palette - Material-UI
 - カスタムフックの作成 - React
 - Using React Context API with Gatsby - Gatsby
 
もう踊れない / 宇宙まお (2020)
Material-UI
Material-UI (MUI)はGoogleのマテリアルデザインをReactで実装するUIライブラリです。
https://mui.com/