記事一覧
Topに戻る
  • TOP
  • Next.js
  • Next.js + TypeScriptにStorybookを導入して遭遇したエラーを全て共有します

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

更新日:

by @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のExamplesのページ には、StorybookをJSで表示するものと、TypeScriptを使うもの2種類のサンプルがあります。今回の設定はTypeScript版を参照したよ。

それでも頑張って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": false,
    "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/preset-typescript

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

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

次に、ローダーを追加します。

$npm install -D babel-loader @babel/core ts-loader

Next.jsのプロジェクトルートに.storybookディレクトリを作成し、main.jsを追加します。

.storybook/main.js
module.exports = {
  addons: ['@storybook/preset-typescript'],
}

また、同ディレクトリにpreview.jsを追加し、Storybook用コンポーネントでTypeScriptのファイル*.stories.tsxを読み込む記述を追加します。

.storybook/preview.js
import { configure } from '@storybook/react'

const req = require.context('../stories', true, /.stories.tsx?$/)

configure(req, module)

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

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

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

Storybookの画面

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

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

解説パンダくん
解説パンダくん

$ npm run storybook を実行するたびにエラーが発生したので、1つずつ潰していったんだ。Next.jsの機能をフル活用している稼働中のアプリならではのエラーだったよ。

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

「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 = {
  addons: ['@storybook/preset-typescript'],
  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 = {
  addons: ['@storybook/preset-typescript'],
  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」は、.story/preview.jsに以下のように記述することで解決できます。

.story/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

「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」というエラーが表示されます。

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

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

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

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

.story/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)

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

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

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

Google Analyticsをモックする

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

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

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

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

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

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

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

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

configure([req1, req2], module)

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.FC<Props> = (props) => (
  <div>
    Hello,{' '}
    {props.loggedIn ? `${props.username}` : 'ゲスト' }さん
  </div>
)

const Container: React.FC<{}> = () => {
  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のコンポーネント側で自由に設定できるようなラッパー関数を作りました。

.story/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)

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

まとめ

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

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

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

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

解説パンダくん
解説パンダくん

......。お後がよろしいようで

Happy Coding 🎉