Next.js + TypeScriptにStorybookを導入して遭遇したエラーを全て共有します
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
を作成して、以下のように記載します。
{
"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
に追加します。
{
"scripts": {
// ...
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
}
}
次に、プロジェクトルートに.storybook
ディレクトリを作成してmain.js
を作成します。stories に Storybook のコンポーネントを配置しているディレクトリのパスを記述します。
module.exports = {
stories: ['../src/**/*.stories.tsx'],
}
以上で準備ができました。
Next.js + Storybookが動くまでに発生したエラーと解消法を発生順に列挙していく
$ npm run storybook
を実行するとStorybookが立ち上がり、http://localhost:6006
で表示できます。
しかし、現時点でコンポーネントは何も表示されません。そこであえて一番複雑なコンポーネントを表示しようと思ったら、見事にたくさんのエラーに遭遇しました。
なぜなら、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
で指定しましょう。
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
を作成し、以下の記述を追加します。
import { addDecorator } from '@storybook/react'
import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'
addDecorator((Story) => (
<MemoryRouterProvider>
<Story />
</MemoryRouterProvider>
))
これで useRouter を呼び出しているコンポーネントも Storybook で表示することができます。
Storybook で next/image をモックする
Storybook はコンポーネントの表示に特化したアプリケーションであり、本番で動作しているアプリケーションとは独立した実行環境を提供しています。一方、Next.js の Image Optimization(画像最適化)機能である next/image を使うためには、Next.js のサーバーが起動していることが必要です。
Storybook だけを動かせば UI を確認できることが強みであるので、画像最適化機能のためだけに Next.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 のコンポーネントのサンプルコードです。
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
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',
},
}
参考: Get started with Storybook and Next.js
Storybookでコンポーネント表示エリアをダークモード対応にする
ダークモードに対応しているコンポーネントをデザインするとき、Storybook の背景色がデフォルトの白のままだと開発しづらいです。
例えば、本ブログは背景色を黒く設定しているので、Storybook も同様の色になるようにしています。
これを実現する方法は、.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を読み込めます。
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
に以下の設定を追加します。
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から、モジュールを絶対パスでインポートができるようになりました。
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
に以下の記述を追加します。
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から実行時に値を読み取れます。
module.exports = {
serverRuntimeConfig: {
// serverでの利用可能
mySecret: 'secret',
secondSecret: process.env.SECOND_SECRET,
},
publicRuntimeConfig: {
// serverとclient両方で利用可能
appHost: 'next-storybook-app.com',
},
}
これでconfig.ts
でappHost
の値を取得できます。
import getConfig from 'next/config'
const { publicRuntimeConfig } = getConfig()
const { appHost } = publicRuntimeConfig
しかし、この機能を使っていると、Storybookで以下のエラーが表示されました。
「Cannot read property 'publicRuntimeConfig' of undefined」は、.storybook/preview.js
に以下のように記述することで解決できます。
import { setConfig } from 'next/config';
import { publicRuntimeConfig } from '../next.config';
setConfig({ publicRuntimeConfig });
また、publicRuntimeConfigを呼び出している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の各コンポーネントに適用されます。
import '../src/assets/scss/style.scss'
Google Analyticsをモックする
Next.jsでGoogle Analyticsを使っている場合は、gtagモックをpreview.js
に記述します。
window.gtag = () => {}
関連記事: Next.jsでGoogle Analyticsを使えるようにする
これでGoogle Analyticsのイベントをモックできました。
StorybookでuseContext(Context API)を使えるようにする
useContextを使っているコンポーネントをStorybookから呼び出したい場合は、StorybookのDecoratorを利用します。このDecoratorを使って、Contextを利用するコンポーネント(Consumer)をProviderでラップします。
Headerコンポーネントでstoreからユーザー名を取得しているケースを例に解説します。
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
は以下のようになります。
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のコンポーネント側で自由に設定できるようなラッパー関数を作りました。
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を渡すだけで様々な状態を表現できるようになりました。
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ファイルがあるコンポーネントを記載するだけです。
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を値として取得できます。
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をモックすることでエラーを解決できます。
import * as nextRouter from 'next/router'
// ダミーデータは適宜変更する
nextRouter.useRouter = () => ({
route: "/",
pathname: "/about",
query: { query: 'Next.js Storybook' },
asPath: "",
basePath: "",
})
また、Routerオブジェクトを使っている場合は以下のようにモックします。
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)
まとめ
エラーの発生時とエラーの解消なんて簡単なものですよ。未知のエラーに遭遇した時は、以下のステップをたどるだけです。
- 未知のエラーが発生する
- エラー文をコピーしてGoogle検索する
- GitHubのissue、もしくはStack Overflowを読む。なければissueを立てる
- スレッドの最初(問題提起)と流れを把握し、一番リアクションの多いリプライを読む
- 解決策を手元で試す
- 解決する
- 次のエラーが発生。1に戻る
この手順は歴としたLoop文ですね。breakがないからLoopを抜けられない?その通りです。あなたがエンジニアである限りはね。
我々にあるのはcoffee breakだけです。
Happy Coding 🎉