Next.js+TypeScript 環境で Storybook を使う

1年ほどコードを書く業務から離れていたらだいぶ腕がなまっていて焦りながら学習をしています。neotag です。
久しぶりに Next.js 環境を最初から立ち上げていたら手元のメモが増えてきたのでちょっとずつブログにしていこうと思います。ポエムネタが切れてきたわけではないです。はい・・・。

今回はまっさらな Next.js+TypeScript 環境に Storybook を入れようとしてドキュメントを眺めていたら少しハマったのでできるだけ単純化してまとめます。
デモは下記に push してあります。

ちなみに答えはすべて下記に書いてありました。設定をみたいだけの方はこの記事よりも公式を見たほうが良いと思います。

Next.js + TypeScript の環境を立ち上げる

Next.js は進化がはやくて毎回使うたびにちょっとずつ手順が変わっているような気がします。
ということでまずはゼロから Next.js をインストールしてみます。
Next.js のインストール後に TypeScript 化をおこないます。公式ドキュメントとチュートリアルの下記あたりが参考になります。というかそのままです。

ここでの作業は下記の PR にまとめてあるのでよければあわせてご参照ください。

ちなみに今回つかったパッケージは以下の通りです。

  "dependencies": {
    "next": "9.4.4",
    "react": "16.13.1",
    "react-dom": "16.13.1"
  },
  "devDependencies": {
    "@storybook/addon-links": "^5.3.19",
    "@storybook/react": "^5.3.19",
    "@types/node": "^14.0.13",
    "@types/react": "^16.9.38",
    "babel-loader": "^8.1.0",
    "babel-preset-react-app": "^9.1.2",
    "typescript": "^3.9.5"
  }

create next-app

まずは create next-app を実行します。僕は普段 yarn を使っているので下記を実行します。

$ yarn create next-app app_name

このとき使用している Node のバージョンが古いと下記のように怒られます。今回は手元にたまたま入っていた v14.4.0 を使用します。

error watchpack@2.0.0-beta.13: The engine “node” is incompatible with this module. Expected version “>=10.13.0”. Got “10.12.0”

また、yarn create next-app ./ のように現在のディレクトリを指定できるのですが、その際に .node-version があると create next-app 中に conflict を起こします。 .node-version でバージョン指定をする際は一階層上のディレクトリに置くなりしましょう。

The directory app_name contains files that could conflict:
.node-version

正しく create next-app が実行できると template を使用するか聞かれます。ここでまっさらの App を作るか公式の Example を元に App を作るか選べます。
今回は Default starter app を選びました。

 Pick a template › - Use arrow-keys. Return to submit.
❯  Default starter app
   Example from the Next.js repo

TypeScript を有効にする

素のままで create next-app をした場合 TypeScript が無効になっているので有効にします。
yarn や yarn dev で使われている next コマンドは tsconfig.json の有無を見て TypeScript を使用するか判断しているようです。

まずは必要なパッケージをインストールし

$ yarn add -D typescript @types/react @types/node

空の tsconfig.json を作成します。

$ touch tsconfig.json

この状態で next コマンドを実行すれば TypeScript が有効になります。

$ yarn dev // 中身は next dev です。

すると、tsconfig.json が更新され next-env.d.ts が生成されます。
TypeScript で Next.js を transpile する際に使われるデフォルトの設定と必要な型定義ファイルが出力されました。
以前は ts 化も一苦労したような記憶がありますが便利ですね。しあわせ。

tsx ファイルを作る

デフォルトの Next.js では App コンポーネントを記述した _app.js は隠蔽されているので編集したい場合は作成する必要があります。
また create next-app で生成された pages/index は .js ファイルなので .tsx ファイルに差し替えます。(やらなくてもいいのですが、混在するのはいやなので。)

_app.tsx

Redux などを使うときに App の修正が必要なので Storybook には関係ないですが追加しておきます。
(そういえば arrow function にし忘れてますね。lint 設定してないので見落としていました。)

import { AppProps } from "next/app";

function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

export default App;

参照元: https://nextjs.org/learn/excel/typescript/nextjs-types

pages/index.tsx

Next.js 的には拡張子を .js から .tsx に変えるだけで十分なのですが、デフォルトのままだと Storybook から読んだときに React の import が出来ずビルドに失敗するので一部編集します。

diff --git a/pages/index.tsx b/pages/index.tsx
index 0303970..97eebee 100644
--- a/pages/index.tsx
+++ b/pages/index.tsx
@@ -1,4 +1,4 @@
-import { FC } from "react";
+import React, { FC } from "react";
 import Head from "next/head";
 
 const Home: FC = () => {

Storybook の WebPack 設定を Next.js に寄せられると良いと思うのですが、普段使っている Lint でも React を明示するように怒られるので気にせずこのままにしています。
これで Next.js + TypeScript 環境はできました。あらためて yarn dev してみましょう。

$ yarn dev

locahost:3000 で下記ページが表示されれば Next.js の準備は完了です。

Next.js を実行したトップページのスクリーンショット

Storybook の設定

すでに Next.js が立ち上がっているので極力 package.json を肥大化させず Next.js にあわせた形で Storybook をインストールします。
Next.js のリポジトリには examples/with-storybook というテンプレートがあるのですが、これは ts 化されていないので参考にしつつ追加の設定が必要になります。

ここでの作業は下記の PR にまとめてあるのであわせてご参照ください。

Storybook のインストール

使用するパッケージ一式をインストールします。
Next.js は transpile に babel-loader を使っているようなのであわせます。(ここちょっと自信ないので間違っていること書いているかもしれません。)

$ yarn add -D @storybook/react @storybook/addon-links babel-loader babel-preset-react-app

設定ファイルの追加

.storybook に設定を追加していきます。この設定が分からずハマりました。

.storybook/config.js

前述の examples/with-storybook を参考に config.js を作成します。
基本的には下記と同じですが、pages/ と components/ 以下の *.story.{js,ts} と .stories.{js,ts} を対象にするようにしています。

// via: https://github.com/vercel/next.js/blob/18a9c7e371efc4c487f9c3599c3211ce30009d6c/examples/with-storybook/.storybook/config.js

import { configure, addParameters } from "@storybook/react";

addParameters({
  options: {
    storySort: (a, b) => {
      // We want the Welcome story at the top
      if (a[1].kind === "Welcome") {
        return -1;
      }

      // Sort the other stories by ID
      // https://github.com/storybookjs/storybook/issues/548#issuecomment-530305279
      return a[1].kind === b[1].kind
        ? 0
        : a[1].id.localeCompare(b[1].id, { numeric: true });
    },
  },
});

// automatically import all files ending in *.stories.js or *.story.js
const req = [
  require.context("../pages", true, /.stor(ies|y).[tj]sx$/),
  require.context("../components", true, /.stor(ies|y).[tj]sx$/),
];

// the first argument can be an array too, so if you want to load from different locations or
// different extensions, you can do it like this: configure([req1, req2], module)
configure(req, module);

.storybook/main.js

webpack の設定がわからずハマっていましたが、Next.js が babel-loader を使っているっぽかったので、下記の Storybook 公式ドキュメントに沿って main.js を作成しました。

// from: https://storybook.js.org/docs/configurations/typescript-config/#setting-up-typescript-with-babel-loader

module.exports = {
  stories: ["../{pages,components}/**/*.stor{ies,y}.{t,j}sx"],
  webpackFinal: async (config) => {
    config.module.rules.push({
      test: /\.(ts|tsx)$/,
      loader: require.resolve("babel-loader"),
      options: {
        presets: [["react-app", { flow: false, typescript: true }]],
      },
    });
    config.resolve.extensions.push(".ts", ".tsx");
    return config;
  },
};

ついでに .storybook/addon.js

後述のサンプル用の story で @storybook/addon-links を使うので addon.js も追加します。

import "@storybook/addon-links/register";

サンプル追加

Storybook の Quick Start Guide どおりに実行しているとよく目にするデモの story を追加してみます。

components/storybook_demo/button.story.tsx

conponents/ 配下においてみます。

import React from "react";
import { Button } from "@storybook/react/demo";

export default { title: "Demo Button" };

export const withText = () => <Button>Hello Button</Button>;

export const withEmoji = () => (
  <Button>
    <span role="img" aria-label="so cool">
      😀 😎 👍 💯
    </span>
  </Button>
);

pages/storybook_demo/welcome.story.tsx

こちらは動作確認もかねて components/ ではなく pages/ 配下におきます。

import React from "react";

import { linkTo } from "@storybook/addon-links";
import { Welcome } from "@storybook/react/demo";

export default { title: "Welcome" };

export const toStorybook = () => <Welcome showApp={linkTo("Demo Button")} />;

storybook コマンドの追加と実行

package.json に scripts に storybook を追加します。
Next.js では /public フォルダーにスタティックファイルが入っているので -s オプションを指定します。
.tsx から import していれば問題ないのですが、Home 内にある <img src="/vercel.svg" alt="Vercel Logo" className="logo" /> のような指定方法で404が出るので忘れずに指定します。

start-storybook は引数なしで実行すると毎回ランダムで port を決定するので -p オプションでポートを固定します。ポート番号は伝統的(?)に Storybook で指定されている 6006 を使用します。

  "scripts": {
...
    "storybook": "start-storybook -s ./public -p 6006"
...
  },

余談ですが、 start-storybook は指定されたポートがすでに使われている場合ポート番号をインクリメントして空いている番号(この場合6007)を使うか提案してくれます。丁寧でとてもかわいいやつですね。

無事立ち上がったら localhost:6006 を確認します 🎉 
@storybook/addon-links を有効にしたので Welcome To Storybook にある stories リンクもちゃんと機能していますね。

Storybook のスクリーンキャスト

pages/index を story に追加してみる

pages/ 以下の Container Component を story に追加するのは Storybook の使い方としてどうなんだという気がしないでもないですが、 create next-app で生成された Home (pages/index) を Storybook に表示してみます。

pages/index.story.tsx

import React from "react";
import Home from "./index";

export default { title: "Welcome" };

export const toNext = () => <Home />;

これで Next.js のページも Storybook に追加できました 🎉

Storybook に Next.js の Home コンポーネントを追加した画面のスクリーンショット

今 Next.js の開発元が提供している Vercel というフロントエンド向けのホスティングサービスで遊んでいて、本当は Storybook と Next.js を Vercel にデプロイするところまでやりたかったので地味にハマり中でこの記事の締め切りまでにたどりつけませんでした。
解決したら Vercel にデプロイするところもまとめたいと思っています。

Storybook が好き

元々HTMLコーダーからマークアップエンジニアを経て、最近はフロントエンドのコードを書いたり社内の雑務をやったりしています。
その経歴もあってコンポーネント単位で関心の分離ができている疎結合なコンポーネントを作るのが好きです。そしてそれをスムースに実行できる storybook が大好きです。

最近は CSS in JS の普及などでコンポーネント開発はぐっと楽になりました。その分コンポーネント管理に割ける余力が増えればコンポーネントのカオス状態から脱して人間が十分に管理できる粒度のコンポーネントを維持できるのではと期待しています。
そのために Storybook のような可用性のたかいコンポーネントを管理できるシステムをより活用していきたいですね。

開発のお悩み、フロントエンドから解決しませんか?

あなたのチームのお悩みはなんですか? 「腕の良いエンジニアに重要でない作業まで任せてしまっている」「腕の良いデザイナーに主業務以外も任せてしまっている」「すべての手が足りず細かいことまで手が回らない」などなど… 。

そんなときは、相談相手としてGaji-Laboにお気軽にお声がけください。あなたの開発チームに足りていない役割や領域を適切に捉えてカバーすることで、チーム全体の生産性と品質をアップさせるお手伝いをします。

オンラインでのヒアリングとフルリモートでのプロセス支援に対応していますので、リモートワーク対応可のパートナーをお探しの場合もぜひ弊社にお問い合わせください!

お悩み相談はこちらから!


投稿者 原田 直貴

受託と事業会社の両方を経験し、沢山の事業を見てみたい気持ちで Gaji-Labo を共同創業。普段は雑用やったりプロジェクトマネジメントやったり、たまにフロントエンドのコードを書いたり。直近は Gaji-Labo をデザイン会社に転換していく課題に挑戦中。期待値コントロールにステ全振り。