⚙️

MUIのカスタムThemeをStorybookで確認できるようにする【スタート編】

2021/12/24に公開

はじめに

本記事では、ReactのUIライブラリであるMUIについて、
カスタムThemeやカスタムcomponentをStorybookで確認できるようにしたく、
環境設定を試した内容を記載しています。

私はStorybookは初めてなのと、MaterialUIv4(MUIの前身。2021年秋頃にv5にアップデート)は使ってきたのですが、MUIになってCSS周りのライブラリが刷新され、emotionが使われるようになったので、そのあたりも勉強しながら、という記事になっています。
怪しい点は是非お気軽に指摘いただければと思います。
以降MUIと書いた場合にはMaterialUIのバージョン5のことを指しています。
MUIについては
https://mui.com/
こちら。

概要

既存のNext.jsのプロジェクトに追加で環境のセットアップを行い、themeの設定が反映されるところまでが書かれています。

  1. Storybookの導入
  2. MUIを扱うための下準備
  3. themeを使うための2つの方法の紹介

という章立てになっています。
themeのカスタムそのものや、テストについては今回は取り扱いません。
ハマりポイントについてもまとめています。
また、Next.jsのプロジェクトに追加でStorybookを導入したため、emotionのライブラリは少し余分なものが入っている気がします。
基本的にはTypescriptを用いる環境で試しています。

ライブラリのバージョン等

  • mui関連
    • @mui/material@5.2.5
    • @mui/styles@5.2.3
    • @emotion/cache@11.7.1
    • @emotion/react@11.7.1
    • @emotion/server@11.4.0
    • @emotion/styled@11.6.0
  • Storybook
    • @storybook/addon-actions@6.4.9
    • @storybook/addon-essentials@6.4.9
    • @storybook/addon-links@6.4.9
    • @storybook/react@6.4.9

環境のセットアップ

0. Next.jsとMUI

詳細は省きますが、0からNext.js + MUIの環境を構築したい場合は
https://github.com/mui-org/material-ui/tree/master/examples/nextjs
https://github.com/mui-org/material-ui/tree/master/examples/nextjs-with-typescript
こちらにMUIがサンプルを公開してくれています。(上がjavascript、下がTypescript)
exampleの一部なのでコピーが必要ですが、便利だと思います。
SSR用の設定も既に書かれています。
上記概要のMui関連のライブラリはこちらですべてlatestが指定されています
今回とりあつかうthemeファイルはsrc/styles/theme.tsというパスに存在しています。

1. Storybookの導入

プロジェクトのルートディレクトリにて
$ npx sb init
Storybookがインストールされていない場合はインストールかどうかが聞かれ、
yで進めると勝手にReactプロジェクトであることを認識して諸々のインストールがされます。
インストールが終了すると下記のようにルートディレクトリに.storybook、srcの中にstoriesが作成されます

2. .storybookの中のファイルの整備

StorybookとMUIのemotionバージョンの違いの問題の吸収する
ために、.storybook/main.jsを編集します。

module.exports = {
  "stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
+  webpackFinal(config) {
+    delete config.resolve.alias['emotion-theming'];
+    delete config.resolve.alias['@emotion/styled'];
+    delete config.resolve.alias['@emotion/core'];
+    return config;
+  },
  "framework": "@storybook/react"
}

3-1. 個別にthemeを呼び出す場合のstories.tsxの書き方

今回は試しにButtonコンポーネントをカスタマイズしてみます。
src/storiesの中身を一旦空にして、Button.stories.tsxを下記のようにしてみましょう
src/stories/Button.stories.tsxの中身に

  1. ThemeProviderをつかってthemeを流し込むdecorators
  2. Buttonのpropsの設定

を書いていきます。

src/stories/Button.stories.tsx
import {ThemeProvider} from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import theme from "../styles/theme"
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Button, ButtonProps } from '@mui/material'

export default {
  title: "Example/Button",
  component: Button,
  decorators: [
    (Story) => {
      return (
        <ThemeProvider theme={theme}>
          <CssBaseline />
          <Story />
        </ThemeProvider>
      )
    },
  ],
} as ComponentMeta<typeof Button>

const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;

export const Primary = Template.bind({});
const primaryProps: ButtonProps ={
  color: "primary",
  variant: "contained",
  children: "test"
}
Primary.args= primaryProps

src/theme.tsがデフォルトのままで、
npm run storybook
すると以下のように表示されます。

ここでtheme.tsを書き換えてみると・・・

src/styles/theme.ts
import { createTheme } from '@mui/material/styles';
import { green, pink, red } from '@mui/material/colors';

// Create a theme instance.
const theme = createTheme({
  palette: {
    primary: {
      main: green[800],
    },
    secondary: {
      main: '#19857b',
    },
    background: {
      default: pink[300],
    },
    error: {
      main: red.A400,
    },
  },
  components: {
    MuiButton: {
      styleOverrides: {
        root: {
          borderRadius: "2em",
        },
      },
    },
  }
});
export default theme;

無事、変更が反映されました!
複数のテーマを確認したい場合は
stylesにthemeを新しく準備して・・・

src/styles/theme2.ts
import { createTheme } from '@mui/material/styles';
import { blue, red } from '@mui/material/colors';

// Create a theme instance.
const theme2 = createTheme({
  palette: {
    primary: {
      main: red[100],
    },
    secondary: {
      main: '#19857b',
    },
    background: {
      default: blue[900],
    },
    error: {
      main: red.A400,
    },
  },
  components: {
    MuiButton: {
      styleOverrides: {
        root: {
          border: "3px solid black"
        },
      },
    },
  }
});

export default theme2;

新しくstoryファイルを作ると・・・

src/stories/Button_theme2.stories.tsx
import {ThemeProvider} from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import theme2 from "../styles/theme2"
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Button, ButtonProps } from '@mui/material'

export default {
  title: "Theme2/Button",
  component: Button,
  decorators: [
    (Story) => {
      return (
        <ThemeProvider theme={theme2}>
          <CssBaseline />
          <Story />
        </ThemeProvider>
      )
    },
  ],
} as ComponentMeta<typeof Button>

const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;

export const Primary = Template.bind({});
const primaryProps: ButtonProps ={
  color: "primary",
  variant: "contained",
  children: "test"
}
Primary.args= primaryProps


themeを切り替えて表示できるようになります。

3-2. 1つのthemeをすべてのファイルに適用したい場合

前節では、個別のstoryファイルでdecoratorsを設定することで、themeを切り替えましたが、一つのthemeしか使わない構成を想定する場合は、毎回storyファイルにdecoratorsを書かなくてはならなくて、面倒です。
https://storybook.js.org/docs/react/configure/overview#configure-story-rendering
Storybookでは.storybook/preview.jsを編集することでglobal decoratorsを設定できるのでこれを利用して、共通のtheme読み込みをしてみます。

.storybook/preview.js
import {ThemeProvider} from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import theme from "../src/styles/theme"

export const decorators = [
    (Story) => {
      return (
        <ThemeProvider theme={theme}>
          <CssBaseline />
          <Story />
        </ThemeProvider>
      )
    },
]

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
}
src/stories/Button.stories.tsx
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Button, ButtonProps } from '@mui/material'

export default {
  title: "defaultTheme/Button",
  component: Button,
} as ComponentMeta<typeof Button>

const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;

export const Primary = Template.bind({});
const primaryProps: ButtonProps ={
  color: "primary",
  variant: "contained",
  children: "test"
}
Primary.args= primaryProps


こちらでもthemeが反映されています!
ちなみにtheme2側は3-1のファイルのままですが、こちらもちゃんとtheme2が反映されているようですので、個別ファイルの設定は上書きできるようです。

3-1/3-2どちらにするかはお好みになるのかな、と思います。

ハマりポイントや疑問ポイント

.storybook/main.jsの設定

https://github.com/mui-org/material-ui/issues/24282#issuecomment-951015101
MUIの中で議論がされていて、こちらを参考にしました。
ちなみにその後の議論に
https://github.com/mui-org/material-ui/issues/24282#issuecomment-967747802
このような投稿があり、将来的にはこの記述でいくのかな?と思います。

Then this should work as expected using the latest pre-release

とある通り、今のバージョンではまだこの記載をしてもエラーになってしまったので、まだ使えない気がします。

decoratorには何を書くべきか?

今回紹介したコードではdecoratorは

  (Story) => {
    return (
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <Story/>
      </ThemeProvider>
    )
  }

としました。
悩んだポイントとして

  • emotionのCacheProviderは書くべきか?
  • CssBaselineの必要性
    があります。

CacheProviderについて

環境構築の0番で紹介したNext.jsの_app.tsxでは

src/_app.tsx
import * as React from 'react';
import Head from 'next/head';
import { AppProps } from 'next/app';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { CacheProvider, EmotionCache } from '@emotion/react';
import theme from '../styles/theme';
import createEmotionCache from '../utils/createEmotionCache';

// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();

interface MyAppProps extends AppProps {
  emotionCache?: EmotionCache;
}

export default function MyApp(props: MyAppProps) {
  const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
  return (
    <CacheProvider value={emotionCache}>
      <Head>
        <title>Change title in _app.tsx</title>
        <meta name="viewport" content="initial-scale=1, width=device-width" />
      </Head>
      <ThemeProvider theme={theme}>
        {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
        <CssBaseline />
        <Component {...pageProps} />
      </ThemeProvider>
    </CacheProvider>
  );
}

とかかれていて、CacheProviderが呼ばれています。
コメントにある通り、同一セッション中でのキャッシュを作って高速化する意図なのかな?と思います。
https://emotion.sh/docs/cache-provider
emotion側の説明はこちらにあるのですが、プラグインを挿入するために使われているようにも思えていて、名前の通りのキャッシュ以上の使われ方がされているような気もします。
今回は手元の環境ではCacheProviderの有無で表示には差がなかったため、無しとしました。
今後作業をする中で副作用を発見するかもしれないので、その際には更新していこうと思います。
このあたり詳しい方いらっしゃったら是非効能や、Storybookのdecolatorとして必要か、等教えていただけると助かります。

CssBaselineについて

こちらはCssBaselineを呼び出さないとthemeの背景色の設定が反映されません。

こちらはStorybookのdevtoolsの画面ですが、iframeの中のbodyにbackground-colorが設定されているのがわかると思います。
CssBaselineが抜けているとこの設定がされないため、今回は記載しました。

おわりに

記事を読んでいただいてありがとうございました。
一人エンジニアとして普段は作業をしていて、なかなか情報共有ができなくて、さみしい部分があります。
是非twitterやコメントで情報共有いただけたら嬉しいです!

Discussion