6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2023-04-08

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

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

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

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.jsMaterial UI のセットアップ

Material UI の GitHub リポジトリにある examples にサンプルがありますので、サンプルに従って _app.tsx _document.tsx を作成します。
個人的には Typescript をオススメしますが、Javascript 版も同階層にあります。
Material UI はバージョン5からスタイリングに Emotion が利用されるようになっていますので関連した設定が必要なのでコピー&ペーストしましょう。

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

themes/
├── index.ts
├── light.theme.ts
└── dark.theme.ts

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

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.tsdark.theme.ts ではそれぞれ createTheme でテーマを作成しているだけなので説明は割愛します。
_app.tsxuseMediaQuery を使用し、OSの外観モードに合わせてテーマが切り替わるようにします。

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 へのアップデート方法は公式に重厚なドキュメントが公開されています。

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

npm i -D css-loader style-loader util

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

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

npm i -D @storybook/addon-styling
.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 のモジュールパスエイリアスを利用しているので Storybook も同様の設定をしています。
'@': path.resolve(...) の記述)
この記述は環境によって異なりますので webpackFinal:... の部分は削除するかお好みのパスに書き換えてください。
tsconfig-paths-webpack-pluginを使えば tsconfig ファイルからパスを呼び出せるようですが単純に合わせています。
Storybook のドキュメントの自動生成を有効にしたい場合1は以下を追加してください。

  docs: {
    autodocs: true
  }

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

.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,
  }),
]

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

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

npm run storybook

コンポーネントの作成

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

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 ではプロパティを変更するためのコントロールを定義することができます。
チェックボックスや日付など色々なコントロールを指定できるので公式ドキュメントを参照してください。
args ではコンポーネントに渡す初期値を設定できます。

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
今回の記事は以上となります。
ここでは紹介していませんが Storybook は Jest/Testing LibraryCypress Playwright といったテストツールと連携でき AngularVue でも使える非常に強力なツールですので使いこなして開発を加速させましょう。

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

  1. https://storybook.js.org/docs/react/writing-docs/autodocs

6
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?