Next.js + TypeScriptにStorybookを導入して遭遇したエラーを全て共有します

@Panda_Program

Next.jsにStorybookを導入してTypeScriptで書けるようにする

この記事では、Next.jsにStorybookを導入してTypeScriptでReactコンポーネントを書けるようにする手順を紹介します。またその際に、私が踏み抜いたバグと解消法を全て共有します。

Next.jsとは、Vercelが作成しているReactのフレームワークです。 SSRにも対応しており、Reactで開発するならNext.jsかFacebook製のCreate React Appを使うのがスタンダードになっています。また、面倒な設定を書かなくてもすぐに使えるZero Configを標榜しており、実際にWebpackやTypeScriptと一緒にReactを書く際にも特別な準備は不要です。

Storybookとは、UIコンポーネントのカタログを作るツールです。 Storybookの実行環境はメインのアプリケーションとは独立しているため、UI作成時に試行錯誤をしてもメインのアプリに影響を及ぼさないのが大きなメリットです。Storybookはエンジニアとデザイナーの橋渡しをしてくれるツールであり、ReactやVue、Angularなどコンポーネント指向のフレームワークと併用することが多いです。

Next.jsで作ったアプリケーションがリリース済みで本番稼働中であったため、Storybookの導入は一筋縄ではいかなかったです。Next.js公式のStorybookの導入サンプルは序章に過ぎなかったんや...。

それでも頑張ってNext.js + TypeScriptの環境ではStorybookが動作するところまで持っていったので、同じようなエラーを踏んで困っている方のお役に立てれば幸いです。

前準備

Next.jsにTypeScriptを導入する

Next.jsのプロジェクトのセットアップが終わっているとします。TypeScriptとReact、Node.jsの型をインストールします。

$ npm install --save-dev typescript @types/react @types/node

次に、ディレクトリルートにtsconfig.jsonを作成して、以下のように記載します。

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "exclude": ["node_modules"],
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}

Next.jsにTypeScriptの導入する手順は以上です。

Next.jsにStorybookを導入する

Next.jsにStorybookを導入しましょう。説明を簡単にするために今回はアドオンを追加しません。

$ npm install -D @storybook/react

Storybookのインストールを終えたら、以下のコマンドをpackage.jsonに追加します。

package.json
{
  "scripts": {
    // ...
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  }
}

次に、プロジェクトルートに.storybookディレクトリを作成してmain.jsを作成します。stories に Storybook のコンポーネントを配置しているディレクトリのパスを記述します。

.storybook/main.js
module.exports = {
  stories: ['../src/**/*.stories.tsx'],
}

以上で準備ができました。

Next.js + Storybookが動くまでに発生したエラーと解消法を発生順に列挙していく

$ npm run storybookを実行するとStorybookが立ち上がり、http://localhost:6006で表示できます。

Storybookの画面

しかし、現時点でコンポーネントは何も表示されません。そこであえて一番複雑なコンポーネントを表示しようと思ったら、見事にたくさんのエラーに遭遇しました。

なぜなら、Next.jsでReactのuseContext(Context API)を使っていたり、Next Routerを使っていたり、Google Analyticsを設定していたり、CSS Modulesを使っていたからです。

以下ではそれぞれのエラー内容、エラーの原因、解決法を解説していきます。

(追記)Next.jsがv12.1、@storybook/react がv6.4のときのエラーと解消法

2022年2月20日時点の最新の Next.js のバージョンは v12.1.0、@storybook/react のバージョンは v6.4.19 です。本記事は元記事に追記をしており、追記したケースは左のバージョンで遭遇したエラーとその解消法です。

Storybook で Next.js の public ディレクトリ配下の画像などの静的ファイルを読み込みたい

main.js にstaticDirsで指定しましょう。

main.js
module.exports = {
  //...
  staticDirs: ['../public'],
}

参考: Serving static files via Storybook Configuration

Storybook で next/router をモックする

Storybook で next/router をモックする方法は2通りあります。1つは、Storybook のアドオンであるStorybook Addon Next Routerを使う方法。もう1つは、next-router-mockというパッケージを使う方法です。

前者は Storybook 専用であるのに対して、後者は Jest や Vitest と testing library react を使ったコンポーネントテストでも活用できます。このため、今回は後者を使うことにします。

以下のコマンドでインストールしましょう。

npm i -D next-router-mock

次に、.storybook/preview.jsを作成し、以下の記述を追加します。

.storybook/preview.js
import { addDecorator } from '@storybook/react'
import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'

addDecorator((Story) => (
  <MemoryRouterProvider>
    <Story />
  </MemoryRouterProvider>
))

これで useRouter を呼び出しているコンポーネントも Storybook で表示することができます。

参考: Usage with Storybook

Storybook で next/image をモックする

Storybook はコンポーネントの表示に特化したアプリケーションであり、本番で動作しているアプリケーションとは独立した実行環境を提供しています。一方、Next.js の Image Optimization(画像最適化)機能である next/image を使うためには、Next.js のサーバーが起動していることが必要です。

Storybook だけを動かせば UI を確認できることが強みであるので、画像最適化機能のためだけに Next.js のサーバーを立ち上げたくはありません。

そこで、以下の記述を.storybook/preview.jsに追加します。

.storybook/preview.js
import * as NextImage from 'next/image'

const OriginalNextImage = NextImage.default

Object.defineProperty(NextImage, 'default', {
  configurable: true,
  value: (props) => <OriginalNextImage {...props} placeholder={undefined} unoptimized />,
})

これで画像を表示できました。以下は React と Storybook のコンポーネントのサンプルコードです。

PostImage.tsx
import Image from 'next/image'

type Props = {
  src: string
  alt: string
}

const PostImage: React.VFC<Props> = ({ src, alt }) => {
  return (
    <Image width={400} height={300} src={src} alt={alt} />
  )
}

export default PostImage
PostImage.stories.tsx
import React, { ComponentProps } from 'react'

import PostImage from './PostImage'

export default {
  title: 'Domain/Post/PostImage',
}

export const Default = {
  render: ({ ...args }: ComponentProps<typeof PostImage>) => 
    <PostImage {...args} />,
  args: {
    src: '/img/2022/02/01/tailwind-ui.png',
    alt: 'text',
  },
}

Image コンポーネントをStorybookで表示できている

参考: Get started with Storybook and Next.js

Storybookでコンポーネント表示エリアをダークモード対応にする

ダークモードに対応しているコンポーネントをデザインするとき、Storybook の背景色がデフォルトの白のままだと開発しづらいです。

例えば、本ブログは背景色を黒く設定しているので、Storybook も同様の色になるようにしています。

Storybookで背景をダークモードにする

これを実現する方法は、.storybook に preview-body.htmlを作成し、以下の記述を追加するだけです。

.storybook/preview-body.html
<style>
    body {
        /* Tailwind CSS の bg-gray-900 */
        background-color: rgb(17 24 39);
    }
</style>

Storybook のテーマでも色変更をする方法はあるのですが、対象が左カラムの色など Storybook 自体の色の変更だったのでこの機能は使っていません。

恐らくこの機能を使って同様のことができると思うのですが、body に background-color を指定する方法の方が簡単なのでこちらを採用しています。

参考: Adding to body

Next.jsがv9.5、@storybook/react がv5.3のときのエラーと解消法

なお、これ以下のケースは記事を最初に執筆した2020年7月時点のものです。

この時の Next.js のバージョンは v9.5、@storybook/react のバージョンは v5.3 であり、他により良い解決法がある場合は Twitter で教えていただけると幸いです。

「Module parse failed: Unexpected character '@' (1:0)」でSCSSが読み込めない

Next.jsは9.3からCSS Modulesをサポートしています。この機能を使うと、Next.jsがビルドするReactコンポーネントからSCSSを読み込めます。

Header.tsx
import styles from './Header.module.scss'

export function Header() {
  return (
    <nav className={styles.error}>
      <p>Header</p>
    </nav>
  )
}

しかし、StorybookがSCSSを読み込めないため、以下のエラーが表示されます。

Module parse failed: Unexpected character '@' (1:0)
You may need an appropriate loader to handle this file type,
 currently no loaders are configured to process this file.
 See https://webpack.js.org/concepts#loaders
> @import '../../Assets/scss/lib/color';
| @import '../../Assets/scss/lib/variable';
| @import '../../Assets/scss/lib/icon';

Next.js + StorybookでSCSS(CSS Modules)を使うために、CSSに関するローダーを追加します。

$ npm install -D sass css-loader sass-loader style-loader

次に、StorybookのWebpackでこれらのローダーを使えるようにします。main.jsに以下の設定を追加します。

.storybook/main.js
module.exports = {
  webpackFinal: async (baseConfig) => {
    // scss を読み込む
    baseConfig.module.rules.push({
      test: /\.scss$/,
      use: [
        'style-loader',
        {
          loader: 'css-loader',
          options: {
            importLoaders: 1, // 1 => postcss-loader
            modules: {
              localIdentName: '[local]___[hash:base64:2]',
            },
          },
        },
        'sass-loader',
      ],
    });

    return {...baseConfig};
  }
}

これでSCSSが読み込めない解消されました。

「Module not found: Error: Can’t resolve ‘src/hooks/useCounter.ts’」で絶対パスでのインポートができない(Absolute imports)

Next.jsの9.4から、モジュールを絶対パスでインポートができるようになりました。

Counter.tsx
import useCounter from 'src/hooks/useCounter'

export function Counter() {
  const [count, increment] = useCounter()

  return
    <>
      <p>{count}</p>
      <button onClick={() => increment()}>
        +
      </button>
    </>
  )
}

この機能を使っていると、以下のエラーが表示されました。

ERROR in ./src/Components/Header/Header.tsx
Module not found: Error: Can’t resolve ‘src/hooks/useCounter.ts’
 in ‘/Users/panda/nextjs/app/src/Components/Header’

Storybookでも絶対パスでモジュールをインポート(Absolute imports)するためには、.storybook/main.jsに以下の記述を追加します。

.storybook/main.js
module.exports = {
  webpackFinal: async (baseConfig) => {
    // @see https://github.com/storybookjs/storybook/issues/3916#issuecomment-407681239
    baseConfig.resolve.modules = [
      ...(baseConfig.resolve.modules || []),
      path.resolve('./'),
    ]

    // scss を読み込む
    // ...
  }
}

これで絶対パスでモジュールを読み込むことができました。(参考: I can not import with absolute path in Storybook

「Cannot read property 'publicRuntimeConfig' of undefined」でpublicRuntimeConfigから値を取得できない

publicRuntimeConfigを使うことにより、Next.jsでnext.config.jsから実行時に値を読み取れます。

next.config.js
module.exports = {
  serverRuntimeConfig: {
    // serverでの利用可能
    mySecret: 'secret',
    secondSecret: process.env.SECOND_SECRET,
  },
  publicRuntimeConfig: {
    // serverとclient両方で利用可能
    appHost: 'next-storybook-app.com',
  },
}

これでconfig.tsappHostの値を取得できます。

config.ts
import getConfig from 'next/config'

const { publicRuntimeConfig } = getConfig()
const { appHost } = publicRuntimeConfig

しかし、この機能を使っていると、Storybookで以下のエラーが表示されました。

Storybookのエラー

「Cannot read property 'publicRuntimeConfig' of undefined」は、.storybook/preview.jsに以下のように記述することで解決できます。

.storybook/preview.js
import { setConfig } from 'next/config';
import { publicRuntimeConfig } from '../next.config';

setConfig({ publicRuntimeConfig });

また、publicRuntimeConfigを呼び出しているconfig.tsも以下のように書き換えます。

config.ts
import getConfig from 'next/config'

const { publicRuntimeConfig = {} } = getConfig() || {}
const { appHost } = publicRuntimeConfig

これでStorybookでNext.jsのpublicRuntimeConfigを使っているファイルを読み込めました。(参考: publicRuntimeConfig undefined when using Storybook with Next.js

_app.tsxで読み込んでいるスタイルが当たらない

普段_app.tsxで読み込むようなscssファイルは、preview.jsで読み込むことでStorybookの各コンポーネントに適用されます。

.storybook/preview.js
import '../src/assets/scss/style.scss'

Google Analyticsをモックする

Next.jsでGoogle Analyticsを使っている場合は、gtagモックをpreview.jsに記述します。

.storybook/preview.js
window.gtag = () => {}

関連記事: Next.jsでGoogle Analyticsを使えるようにする

これでGoogle Analyticsのイベントをモックできました。

StorybookでuseContext(Context API)を使えるようにする

useContextを使っているコンポーネントをStorybookから呼び出したい場合は、StorybookのDecoratorを利用します。このDecoratorを使って、Contextを利用するコンポーネント(Consumer)をProviderでラップします。

Headerコンポーネントでstoreからユーザー名を取得しているケースを例に解説します。

Header.tsx
import React, { useContext } from 'react'
import { Context } from 'src/lib/store/context'

type Props = {
  username: string | null
  loggedIn: boolean
}

const Component: React.VFC<Props> = (props) => (
  <div>
    Hello,{' '}
    {props.loggedIn ? `${props.username}` : 'ゲスト' }さん
  </div>
)

const Container: React.VFC = () => {
  const { state } = useContext(Context)
  const loggedIn = state.user.loggedIn === true

  return <Component
    username={state.user.name}
    loggedIn={loggedIn}
  />
}

Container.displayName = 'Header'

export default Container

Storybook側のHeader.stories.tsxは以下のようになります。

Header.stories.tsx
import React from 'react'
import Header from './Header'
import { Context } from 'src/lib/store/context'

export default {
  title: 'Header',
}

export const header = () => <Header />

const store = {
  state: { user: { name: 'パンダ', loggedIn: true } },
  dispatch: () => {},
}

header.story = {
  decorators: [storyFn =>
    <Context.Provider value={store}>
      {storyFn()}
    </Context.Provider>
  ]
}

これでStorybookでuseContextを使っているReactコンポーネントを描画できました。

Storybookで使えるstoreのモックを作成する

上記でStoreをモックできましたが、毎回全てのコンポーネントにdecoratorを記述するのは面倒です。

Header.stories.tsxの中で、「非ログイン状態」と「ログイン済みでユーザー名がある状態」の2つのコンポーネントを作るときには、2つのdecoratorを記述しなければなりません。

しかも、コンポーネントの数はある状態の数×別の状態の数であるように、状態(state)の数の掛け算で増えていきます。

何度も同じコードを書くことは「繰り返しを避ける」というDRY原則に違反します。この問題を解決するために、Storeに格納する値をStorybookのコンポーネント側で自由に設定できるようなラッパー関数を作りました。

.storybook/store.ts
import React from "react";
import { Context, Store } from "../../src/Lib/store/context";
import { initialState } from "../../src/Lib/store/reducer";

type State = typeof initialState

// initialStateと外から与えられた値をマージする
const mockState = (state): State => ({...initialState, ...state})

const mockContextValue = (state): Store => ({
  state: mockState(state),
  // dispatcherをモックする
  dispatch: () => {},
})

export const withStore =
  (comp: React.ReactElement, state: Partial<State> | {} = {} ) => {
    const Component = () => comp
    // Providerでラップする
    Component.story = {
      decorators: [storyFn =>
        <Context.Provider value={mockContextValue(state)}>
          {storyFn()}
        </Context.Provider>
      ],
    }

    return Component
  }

withStore関数を使うことで、HeaderコンポーネントにモックのStoreを渡すだけで様々な状態を表現できるようになりました。

Header.stories.tsx
import React from 'react'
import Header from './Header'
import { Context } from 'src/lib/store/context'

export default {
  title: 'Header',
}

// 非ログイン
const guestStore = {
  user: {
    loggedIn: false
    name: null
  },
}

export const guest = withStore(<Header />, userSearchStore)

// ログイン済み
const userStore = {
  user: {
    loggedIn: true
    name: 'パンダ'
  },
}

export const guest = withStore(<Header />, userSearchStore)

ぜひ使ってみてください。

以下は古いバージョンで発生したエラーです

ここから以下は、 Next.js v9.5, Storybook v5.3 で発生したエラーとその解消法です。備忘録的に残しています。

この記事の更新日時点では、エラーが発生しなかったり、違う解決法があったりするので、ここより上の記述を参考にしてください。

(deprecated)Storybookのコンポーネントを複数のディレクトリから読み込む

Storybookのコンポーネントを複数のディレクトリから読み込むユースケースはそれほど多くないと思いますが、方法を記載しておきます。

preview.jsにstories.tsxファイルがあるコンポーネントを記載するだけです。

.storybook/preview.js
const req1 = require.context('../src', true, /.stories.tsx?$/)
const req2 = require.context('../stories', true, /.stories.tsx?$/)

configure([req1, req2], module)

(deprecated)「Cannot read property 'pathname' of null」でuseRouterが読み込めない

Next.jsでは、React HooksのuseRouterを使うとURLのpathnameやqueryを値として取得できます。

App.tsx
import { useRouter } from 'next/router'

export function App() {
  const { pathname } = useRouter()

  return <p>{pathname}</p>
  )
}

しかし、このままではStorybookで「Cannot read property 'pathname' of null」というエラーが表示されます。

そこで、.storybook/preview.jsに以下のように記述しNext.jsのuseRouterをモックすることでエラーを解決できます。

.storybook/preview.js
import * as nextRouter from 'next/router'

// ダミーデータは適宜変更する
nextRouter.useRouter = () => ({
  route: "/",
  pathname: "/about",
  query: { query: 'Next.js Storybook' },
  asPath: "",
  basePath: "",
})

また、Routerオブジェクトを使っている場合は以下のようにモックします。

.storybook/preview.js
import * as nextRouter from 'next/router'
import Router from 'next/router'

nextRouter.useRouter = () => ({
  // ...
})

nextRouter.router = {
  push: () => {},
  prefetch: () => new Promise((resolve, reject) => {}),
};

これで、StorybookでNext.jsのRouterをMockできました。(参考: How to mock useRouter?How to mock next/router in Storybook)

まとめ

エラーの発生時とエラーの解消なんて簡単なものですよ。未知のエラーに遭遇した時は、以下のステップをたどるだけです。

  1. 未知のエラーが発生する
  2. エラー文をコピーしてGoogle検索する
  3. GitHubのissue、もしくはStack Overflowを読む。なければissueを立てる
  4. スレッドの最初(問題提起)と流れを把握し、一番リアクションの多いリプライを読む
  5. 解決策を手元で試す
  6. 解決する
  7. 次のエラーが発生。1に戻る

この手順は歴としたLoop文ですね。breakがないからLoopを抜けられない?その通りです。あなたがエンジニアである限りはね。

我々にあるのはcoffee breakだけです。

Happy Coding 🎉

パンダのイラスト
パンダ

記事が面白いと思ったらツイートやはてブをお願いします!皆さんの感想が執筆のモチベーションになります。最後まで読んでくれてありがとう。

  • Share on Hatena
  • Share on Twitter
  • Share on Line
  • Copy to clipboard