Next.js 13 + Material UI v5 + Storybook v7 でコンポーネント駆動開発を加速させる

社会課題を解決する新規事業立ち上げに取り組んでいる [@aoyagi](https://twitter.com/aoyagi9936) です。
BizDev 側の作業の合間に開発環境をアップデートしたのでその知見を共有します。

なんでも１人でやらなければならないのが新規事業開発の辛いところですが、肝心のプロダクトをスピーディに作っていく上でも快適な開発環境は欠かせません。
Angular を書いていた頃は [Karma](https://karma-runner.github.io/latest/index.html) で UI のテストをしていたのですが、React(Next.js) を書くようになってからは [Storybook](https://storybook.js.org/) を利用しています。
先日 [Storybook のバージョン7がリリース](https://github.com/storybookjs/storybook/releases/tag/v7.0.2) されましたが、リリース前から先行して v6.5 からアップデートしておりその時に詰まったところもあったのでざっくりと解説します。

サンプルプロジェクトも公開しましたので記事で不足している部分はこちらを参照してください。

https://github.com/aoyagi9936/nextjs-materialui-storybook-example

**2023年8月27日更新**
本記事やサンプルコードは Next.js 13.4 から安定版となった `App Router` には対応していません。Material UI も今後は App Router が推奨となっていくようですのでいずれ検証したいと思います。

**2023年10月22日更新**
`addon-styling` が廃止されていたので `addon-themes` に変更しました。

**2024年2月25日更新**
Material UI を Next.js に統合できるパッケージ `@mui/material-nextjs` に対応しました。
Emotion 周りの面倒な設定が一切不要になりました。

# `Next.js` と `Material UI` のセットアップ
[Material UI](https://mui.com/material-ui/getting-started/overview/) の GitHub リポジトリにある [examples](https://github.com/mui/material-ui/tree/master/examples/material-ui-nextjs-pages-router-ts) にサンプルがありますので、サンプルに従って `_app.tsx` `_document.tsx` を作成します。
個人的には `Typescript` をオススメしますが、Javascript 版も同階層にあります。
Material UI はバージョン5からスタイリングに [Emotion](https://emotion.sh/docs/introduction) が利用されるようになっていますので関連した設定が必要なのでコピー＆ペーストしましょう。

さらに今回はダークモードのテストも兼ねてシステム設定によるテーマ切替を追加してみます。
フォルダ名は任意で構いませんが、`themes` フォルダにライトテーマとダークテーマをそれぞれ作成します。
```
themes/
├── index.ts
├── light.theme.ts
└── dark.theme.ts
```

`index.ts` では Next.js 13 から利用できるようになったフォントのセルフホスティング機能をさっそく使ってみます。
`next/font/google` を使って利用するフォントをインポートします。
フォントはビルド時にダウンロードされセルフホスティングされるのでパフォーマンスとプライバシーを向上させることができます。

```typescript:themes/index.ts
import { Roboto } from 'next/font/google';
import { lightTheme } from './light.theme';
import { darkTheme } from './dark.theme';

export const roboto = Roboto({
    weight: ['300', '400', '500', '700'],
    subsets: ['latin'],
    display: 'swap',
    fallback: ['Helvetica', 'Arial', 'sans-serif'],
});

export const themes = {
    light: lightTheme,
    dark: darkTheme,
};

export default themes;
```

`light.theme.ts` と `dark.theme.ts` ではそれぞれ `createTheme` でテーマを作成しているだけなので説明は割愛します。
`_app.tsx` で `useMediaQuery` を使用し、OSの外観モードに合わせてテーマが切り替わるようにします。

```tsx:pages/_app.tsx
// ...
export default function MyApp(props: MyAppProps) {
    // ここから
    const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
    const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
    const theme = React.useMemo(
        () => prefersDarkMode ? themes.dark : themes.light,
        [prefersDarkMode],
    );
    // ここまで
    return (
      <AppCacheProvider {...props}>
        <Head>
          <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>
      </AppCacheProvider>
    );
}
// ...
```

`_document.tsx` でも PWA アプリにした際のツールバーの色をテーマに合わせるために同じ記述を加えています。
（冗長なので共通化した方が良いですがここでは分かりやすさを優先しています）
余談ではありますが以前は `Head` に `<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/>` という記述がありましたが今ではなくなっています。

# `Storybook` のセットアップ
Storybook v6.5から v7 へのアップデート方法は公式に重厚なドキュメントが公開されています。

https://github.com/storybookjs/storybook/blob/next/MIGRATION.md

私も全ては読みきれていませんが、手っ取り早く Next.js + Material UI でコンポーネントがテストできる状態を目指します。
v7 が正式にリリースされる前はアップグレードには `npx storybook@latest upgrade --prerelease` と `--prerelease` オプションが必要でしたが現在では不要なはずです（試していませんが）。
実行すると対話式でアップグレードが実行されますが、`.storybook/main.js` 等のファイルが大きく書き変わりますのでご注意ください。
また、自動アップグレードをしても私の場合は以下の依存関係が不足していたので追加で実行する必要がありました。

```bash
npm i -D css-loader style-loader util
```

Storybook の設定ファイルは以下の通りです。

**2023年5月更新**
~~以下のリポジトリを参考にさせていただきました。~~
~~https://github.com/Integrayshaun/storybook-mui-example~~
アドオンを利用すると `preview.js` をシンプルに記述できるようなので試してみました。

https://storybook.js.org/recipes/@mui/material

```shell
npm i -D @storybook/addon-styling
```

```javascript:.storybook/main.js
const path = require('path');
const toPath = (filePath) => path.join(process.cwd(), filePath);

const config = {
  webpackFinal: async (config) => {
    return {
      ...config,
      resolve: {
        ...config.resolve,
        alias: {
          ...config.resolve.alias,
          '@': path.resolve(__dirname, '../src'), // 環境によって異なります
        },
      },
    }
  },
  stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
  addons: [
      "@storybook/addon-links",
      "@storybook/addon-essentials",
      "@storybook/addon-interactions",
      "@storybook/addon-mdx-gfm", // MDX を GitHub Flavored Markdown で表現したい場合は追加
      "@storybook/addon-themes"  // 追加
  ],
  framework: {
    name: "@storybook/nextjs",
    options: {}
  },
  features: {
    emotionAlias: false,
  },
  typescript: {
    check: false,
    checkOptions: {},
    reactDocgen: "react-docgen-typescript",
    reactDocgenTypescriptOptions: {
      allowSyntheticDefaultImports: false, // speeds up storybook build time
      esModuleInterop: false, // speeds up storybook build time
      shouldExtractLiteralValuesFromEnum: true, // makes union prop types like variant and size appear as select controls
      shouldRemoveUndefinedFromOptional: true, // makes string and boolean types that can be undefined appear as inputs and switches
      propFilter: (prop) =>
        prop.parent
          ? !/node_modules\/(?!@mui)/.test(prop.parent.fileName)
          : true,
    },
  },
};
export default config;
```

私は [Typescript のモジュールパスエイリアス](https://nextjs.org/docs/advanced-features/module-path-aliases)を利用しているので Storybook も同様の設定をしています。
（`'@': path.resolve(...)` の記述）
この記述は環境によって異なりますので `webpackFinal:...` の部分は削除するかお好みのパスに書き換えてください。
[tsconfig-paths-webpack-plugin](https://www.npmjs.com/package/tsconfig-paths-webpack-plugin)を使えば tsconfig ファイルからパスを呼び出せるようですが単純に合わせています。
Storybook のドキュメントの自動生成を有効にしたい場合[^1]は以下を追加してください。
[^1]:https://storybook.js.org/docs/react/writing-docs/autodocs
```javascript
  docs: {
    autodocs: true
  }
```

`preview.js` はアドオンを利用することにより非常にシンプルになりました。

```javascript:.storybook/preview.js
// .storybook/preview.js
import { CssBaseline, ThemeProvider } from '@mui/material'
import { withThemeFromJSXProvider } from '@storybook/addon-themes'

import { themes } from '@/themes'

// Load Roboto fonts
import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css'
import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css'
import '@fontsource/material-icons'

const preview = {
  parameters: {
      actions: { argTypesRegex: "^on[A-Z].*" },
      controls: {
          expanded: true,
          hideNoControlsWarning: true,
          matchers: {
              color: /(background|color)$/i,
              date: /Date$/,
          },
      },
  },
};
export default preview;

export const decorators = [
  withThemeFromJSXProvider({
    themes,
    defaultTheme: 'light',
    Provider: ThemeProvider,
    GlobalStyles: CssBaseline,
  }),
]
```

アドオンの詳しい利用方法についてはリンク先をご参照ください。

https://github.com/storybookjs/addon-styling/tree/main/docs

ここまで準備ができたら Storybook を起動しましょう。
`localhost:6006` で Storybook が表示されます。

```bash
npm run storybook
```

# コンポーネントの作成
最後にテスト用のコンポーネントを作成します。
Material UI の [Basic card](https://mui.com/material-ui/react-card/#basic-card) を改変して一部のプロパティを変更できるようにします。
`タイトル` `最小の横幅` `ボタンをクリックした時の遷移先` `ボタンの色` を可変のプロパティとして定義しています。
**パターンを示すために適当に定義しているため実際の開発の参考にはしないでください。**

```tsx:src/components/MyCard.tsx
import { Box, Button, Card, CardActions, CardContent, Typography } from '@mui/material';
import { NextLinkComposed } from './Link';

const bull = (
    <Box
        component="span"
        sx={{ display: 'inline-block', mx: '2px', transform: 'scale(0.8)' }}
    >
        •
    </Box>
);

interface MyCardProps {
    title: string,
    minWidth?: number,
    path?: string,
    color?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'
}

export default function MyCard({ title, minWidth = 275, path = '/', color = 'primary' }: MyCardProps) {
    return (
        <Card sx={{ minWidth: minWidth }}>
            <CardContent>
                <Typography sx={{ fontSize: 14 }} color="text.secondary" gutterBottom>
                    {title}
                </Typography>
                <Typography variant="h5" component="div">
                    be{bull}nev{bull}o{bull}lent
                </Typography>
                <Typography sx={{ mb: 1.5 }} color="text.secondary">
                    adjective
                </Typography>
                <Typography variant="body2">
                    well meaning and kindly.
                    <br />
                    {'"a benevolent smile"'}
                </Typography>
            </CardContent>
            <CardActions>
                <Button size="small"
                    color={color}
                    component={NextLinkComposed}
                    to={{
                        pathname: path
                    }}
                >Learn More</Button>
            </CardActions>
        </Card>
    );
}
```

コンポーネントを Storybook でテストするために `stories` フォルダの下に `*.stories.tsx` を作成します。
`argTypes` ではプロパティを変更するためのコントロールを定義することができます。
チェックボックスや日付など色々なコントロールを指定できるので[公式ドキュメント](https://storybook.js.org/docs/react/essentials/controls#annotation)を参照してください。
`args` ではコンポーネントに渡す初期値を設定できます。

```tsx:stories/MyCard.stories.tsx
// MyCard.stories.ts|tsx

import type { Meta, StoryObj } from '@storybook/react';

import MyCard from '@/components/MyCard';

const meta: Meta<typeof MyCard> = {
    /* 👇 The title prop is optional.
     * See https://storybook.js.org/docs/7.0/react/configure/overview#configure-story-loading
     * to learn how to generate automatic titles
     */
    title: 'MyCard',
    component: MyCard,
    argTypes: {
        minWidth: {
            control: { type: 'number', min: 275, max: 400, step: 5 }
        },
        path: {
            control: 'text'
        },
        title: {
            control: 'text'
        },
        color: {
            options: ['inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning'],
            control: { type: 'select' },
        }
    }
};

export default meta;
type Story = StoryObj<typeof MyCard>;

/*
 *👇 Render functions are a framework specific feature to allow you control on how the component renders.
 * See https://storybook.js.org/docs/7.0/react/api/csf
 * to learn how to use render functions.
 */
export const Primary: Story = {
    args: {
        title: "Word of the Day",
        color: "primary",
    }
};
```

完成した Storybook は次のようになります。
実際のアプリケーションでは `ライトモード` `ダークモード` はOSの外観モードを変更しないと確認できませんが、Storybook 上で確認することができます。
また、コンポーネントのプロパティもコントロールで指定することでリアルタイムにデザインを確認できます。
（分かりにくくて申し訳ないですがボタンの色が変わってます）
![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2767381/d14a37c6-72ef-7e74-a86e-95946e52fd77.gif)
今回の記事は以上となります。
ここでは紹介していませんが Storybook は `Jest/Testing Library` や `Cypress` `Playwright` といったテストツールと連携でき `Angular` や `Vue` でも使える非常に強力なツールですので使いこなして開発を加速させましょう。

ここまでお読みいただきありがとうございました。
次回はバックエンド開発の記事を書きます。