📘

【Next.js】Storybookにふれる(1)

2024/09/17に公開

Storybookとは

Atomic Designなどの考え方に基づいてコンポーネント開発を行う場合、個々のコンポーネントの状態を確認するには、Pagesコンポーネントなどの画面表示用ファイルにコンポーネントを配置し、そのpropsに対して文脈に応じた値や関数を渡す必要があります。

こんな時にStorybookを使うことで、アプリケーション全体のコンテキストや他のコンポーネントの状態に依存することなく、デモデータを利用して個々のコンポーネントのUIテストを行うことができます。
開発が進むにつれてファイルが増え、管理が煩雑になりがちなコンポーネントの管理もStorybookを利用することで、視覚的、カタログ的に整理して管理できるようになります。

以下ではReact/Next.js環境でStorybookを導入する手順をまとめていますが、VueやAngularなどのフレームワークにも対応しているそうです。

実行環境

  • Next.js: 14.2.5
  • React: 18.0.0
  • Styled-components: 6.1.12
  • Storybook: 8.2.9

Storybookのインストールと起動

まずはプロジェクトディレクトリにて、以下コマンドを実行し、Storybookを起動するのに必要なパッケージをインストールします。

npx sb init

# 常に最新版をインストールするには以下
npx sb@latest init

Storybookを起動するためのスクリプトも自動でpackage.jsonに追加されるので、初期化後は以下コマンドでStorybookをすぐ起動できるようになります。

npm run storybook

起動が完了すると自動でブラウザの新しいページが開き、Storybookが表示されます。
自分が試したときはnpx sb initのコマンドだけでブラウザ画面が開きました。

以下はStorybookブラウザの画面例です。

ここではStorybook初期化時にデフォルトで生成されるStorybookコンポーネント(Storyと呼ぶらしいので、本記事でも以下Storyと呼ばせていただきます)が表示されています。

コンポーネントを用意する

ここからはStorybookの画面上に表示させるコンポーネント(Story)を用意していきます。
Storybookにコンポーネントを表示させる場合、開発用コンポーネントファイルとは別に、Storybook用のファイルを用意する必要があります。
実際の開発ではすでに用意されているコンポーネントに対応するStoryを用意していく…という流れになるのかもしれませんが、今回は元となるコンポーネントファイルが存在しないので、まずは開発用コンポーネントの用意から始めていきます。

プロジェクトルートにcomponentsディレクトリを用意します。
そこにcomponents/StyledButton/index.tsxというファイルを作ります。
このファイルではボタンコンポーネント用にスタイルを定義したstyled-componentsをexportしています。
このコンポーネントはpropsとして外部から値を渡せるようになっており、その値によって、ボタンの色が変わるようになっています。

import styled, { css } from "styled-components"

const variants = {
  blue: {
    color: '#ffffff',
    backgroundColor: '#1D3461',
    border: 'none',
  },
  green: {
    color: '#ffffff',
    backgroundColor: '#5AB203',
    border: 'none',
  },
  transparent: {
    color: '#111111',
    backgroundColor: 'transparent',
    border: '1px solid black',
  },
} as const

type StyledButtonProps = {
  variant: keyof typeof variants,
}

export const StyledButton = styled.button<StyledButtonProps>`
  ${({variant}) => {
    const style = variants[variant]

    return css`
      color: ${style.color};
      background-color: ${style.backgroundColor};
      border: ${style.border};
    `;
  }}

  border-radius: 12px;
  font-size: 14px;
  height: 38px;
  line-height: 22px;
  letter-spacing: 0;
  cursor: pointer;

  &:focus {
    outline: none;
  }
`

話が少し脱線してしまいますが、このサンプルコードで使用しているTypeScriptの記法について解説させてください。初見ではコードの構造を理解できなかった(今も難しいと感じています)ため、知識の定着を図るためにも、次に進む前に、このサンプルコード内で使用しているTypeScriptの記法について詳しく説明したいと思います。

脱線1:as const

上記のコードでvariantsというオブジェクトを定義していますが、その末尾にas constをつけています。
このas constは、TypeScriptに対して、このオブジェクトのプロパティや値をリテラル型として認識させるようにするためのキーワードです。
TypeScriptでは通常、オブジェクトのプロパティの値は広い型(stringやnumberなど)として推論されますが、as constを付けることで、各プロパティの値が固定され、文字通りの値がそのまま型として扱われるようになります。

例えば、as constをつけない状態でvariantsを定義し、後続のコードでvariantsの値を参照する場合、variants.blue.colorの型はstringと推論されます。一方で、as constをつけることで、variants.blue.colorの型はリテラル型である"#ffffff"であるということが保証され、これにより、後続のコードで誤った値を使ってしまうというリスクを減らすことができます。

脱線2:typeofとkeyof

type StyledButtonProps = {
  variant: keyof typeof variants,
}

この部分も一目見るだけでは動きがイメージしづらいです。。。
この、type StyledButtonPropsはStyledButtonコンポーネントに渡されるべきpropsの型定義をしていますが、その値であるvariantに付与された型定義が少々複雑であります。

まずは typeof variantsの部分に注目してみます。
typeofは通常のJavaScriptでも変数の型情報を参照する際に使用される演算子ですが、TypeScriptでは変数から型を抽出する際に使用されます。
https://typescriptbook.jp/symbols-and-keywords#typeof-typeof演算子-typeof-js
今回は変数variantsから型を抽出するということですが、上記のas constで見たとおりvariantsの型はリテラル型で保証されているため、抽出される型はvariantsにネストされたオブジェクトである blue: { ... }green: { ... }transparent: { ... }のいずれかということになります。

次に keyofについて見てみます。
keyofは以下のように説明されています。

オブジェクトの型からプロパティ名を型として返す型演算子です。
https://typescriptbook.jp/symbols-and-keywords#keyof-keyof型演算子-keyof-ts

typeof variantsによって保証される型は blue: { ... }green: { ... }transparent: { ... }のいずれかであるということはすでに確認しました。しかし、StyledButtonコンポーネントを別のコンポーネントから呼び出す実際の場面を考えてみますと、
(StyledButtonコンポーネントは以下のように記述します)

<StyledButton variant="blue">青いボタン</StyledButton>
<StyledButton variant="green">緑のボタン</StyledButton>
<StyledButton variant="transparent">透明なボタン</StyledButton>

variantにはbluegreentrasparentのいずれかの情報さえ渡すことができれば良いので、blue: { ... }green: { ... }transparent: { ... }という情報はvariantの型としては過剰な気がします。そのために、typeof variantsに対してさらにkeyofをつけることで、「オブジェクトの型からプロパティ名を型として取得」することができ、StyledButtonコンポーネントに渡されるべきpropsの型を適切に定義できます。

ここまでの型定義によって、StyledButtonコンポーネントを呼び出す際は以下のように型チェックが可能になりました。

<StyledButton variant="blue">青いボタン</StyledButton>   // OK
<StyledButton variant="green">緑のボタン</StyledButton>  // OK
<StyledButton variant="pritty">かわいいボタン</StyledButton>  // エラー: "pritty" は "blue" | "green" | "transparent” のいずれかの型に含まれないので「かわいいボタン」はNG

コンポーネントをStorybookに表示させるためのファイルを用意する

サンプルコードの構造についてわかってきたところで、引き続きこのコンポーネントをStorybook上で表示させるための設定を進めていきましょう。
npx sb initで初期化すると、プロジェクトファイルにstoriesというディレクトリが追加されています。Storybook用のコンポーネントはこのディレクトリ配下にファイルを作成していきます。

ファイル名の命名規則としてはコンポーネント名.stories.tsx というような形になります。
今回はStyledButton.stories.tsxを作成します。

以下は先ほど定義したStyledButtonコンポーネントをStorybookのUI上に表示させるためのコード例となります。
propsにblue, green, transparentそれぞれの値を受け取るコンポーネントをそれぞれ個別にエクスポートしています。

import { Meta, StoryFn } from '@storybook/react'
import { StyledButton, StyledButtonProps } from '@/components/StyledButton'

export default {
  title: 'StyledButton',
  component: StyledButton
} as Meta<typeof StyledButton>

export const ButtonBlue: StoryFn<StyledButtonProps> = (props) => {
  return (
    <StyledButton {...props} variant="blue">
      青いボタン
    </StyledButton>
  )
}

export const ButtonGreen: StoryFn<StyledButtonProps> = (props) => {
  return (
    <StyledButton {...props} variant="green">
      緑のボタン
    </StyledButton>
  )
}

export const ButtonTransparent: StoryFn<StyledButtonProps> = (props) => {
  return (
    <StyledButton {...props} variant="transparent">
      透明なボタン
    </StyledButton>
  )
}

以下詳細について解説していきます。

export default {...} as Meta の表現

この部分ではStoryのメタデータを定義しています。storybookからMeta型をインポートし、定義した内容をMeta型としてエクスポートすることで、Storybook側にStoryのメタデータとして渡すことができます。
先ほど、typeof variantsの表現について触れましたが、このMeta型にはコンポーネント自体の型(関数やクラスとしての型) が期待されているため、typeof StyledButtonを指定します。
メタデータ内部の定義に関しては、titleにはStorybook画面のメニュー部分に表示されるコンポーネントの名前を指定し、componentには表示させるコンポーネントのコンポーネント名を指定します。
メタデータには他にも設定を追加できるのですが、その詳細については後ほどご説明させていただきます。

StoryFn型

次に個々のStoryについて見ていきます。
ここでは、Storybook UI上に実際に表示されるコンポーネント(Story)を定義しています。Storyがpropsとして受け取る値の型はStoryFnを使って参照することになります。
ここではStoryFn<StyledButtonProps>という形components/StyledButton/index.tsx で定義したStyledButtonPropsの型を参照しています。
(先ほどのcomponents/StyledButton/index.tsxのサンプルコードでは、StyledButtonPropsの型定義にexportの指定を忘れてしまっていました。このタイミングで追加しておきます!)

これで準備が整いましたので、Storybookのブラウザ画面を確認してみましょう。StyledButton.stories.tsxでエクスポートした個々のStoryが表示されているはずです。


StorybookではCotnrolタブから画面に表示されたStoryに対してpropsの値を操作することができるのですが、この段階ではまだUI上からそれぞれのStoryを操作することはできません。
引き続き、Controlタブを使用可能にする設定を行なっていきます。

メタデータにargTypesを設定してStoryにpropsを渡せるようにする

Storyのメタデータを定義しているexport default { … }の部分にargTypesという設定を追加します。

export default {
  title: 'StyledButton',
  component: StyledButton,
  argTypes: {
    variant: {
      control: { type: 'radio' },
      options: ['blue', 'green', 'transparent'],
    },
    children: {
      control: { type: 'text' },
    },
  },
} as Meta<typeof StyledButton>

argTypesオプションではStorybookのControlタブからStoryに対して値を渡すための設定を行います。ここではStoryのvariantとchildrenに対して設定を行なっています。
contorolオプションではControlタブからどのような入力形式でpropsを渡すかということを定義しており、上記の場合だと、variantsの値に対してはラジオボタンによる選択形式、childrenに関してはテキスト入力形式で値を渡すような設定を行なっています。
またラジオボタンで値を入力する場合はその選択肢をoptionsで指定することができます。

childrenはボタン内部の文字列を意図しているのですが、この設定に関してひとつ注意点があります。
ここまでのサンプルコードではStyledButtonコンポーネント内部の文字列表示に関してはコンポーネントを呼び出す際に、StyledButtonタグで文字を直接ネストする形でボタン内部の文字列を表現していました。
しかし今回はpropsを経由してボタン内部の文字列の値を渡すため、StyledButtonPropsの型定義に型情報を追加する必要があります。
このままでは適切にStoryの設定ができないので、components/StyledButton/index.tsxtype StyledButtonPropsの部分を以下のように修正しておきます。

type StyledButtonProps = {
  variant: keyof typeof variants,
  children?: React.ReactNode,
}

これまで通りStyledButtonタグで直接文字をネストする形でコンポーネントを呼び出す可能性もあるのかなと思ったので、念の為childrenには?をつけておきます。(この部分最適な方法がわからなかったので引き続き確認してみたいと思います)

メタデータにargTypesのオプションが追加できたら、次にテンプレートとなるコンポーネントを定義します。

const Template: StoryFn<StyledButtonProps> = (args) => <StyledButton {...args} />

export const TemplateTest = Template.bind({})

TemplateコンポーネントではStyledButtonPropsの型通りにpropsを受け取り、受け取った値をスプレッド構文を使ってまとめてコンポーネントに渡しています。
そしてそのTemplateコンポーネントに対して.bind({})を実行しエクスポートすることで、元の Template コンポーネントをコピーする形で複数のStoryを作成することができます。

…失礼しました。
このままではpropsの値として何も渡せていない状態なので、propsの初期値の設定もしておきましょう。
Storyに対して初期値を設定するには以下のように定義します。

TemplateTest.args = {
  variant: 'blue',
  children: '青いボタン',
}

これでようやくStorybook UI上にブラウザから値を操作可能なコンポーネントを表示することができました。

初期表示時

緑のボタンにしてみます。

透明のボタンにも簡単に変更できます。

Button Blue、Button Green、Button Transparentの個別のStoryは一旦不要になるので、StyledButton.stories.tsx からは削除しておきます。

ここまでの内容を以下コードにまとめます。

import { Meta, StoryFn } from '@storybook/react'
import { StyledButton, StyledButtonProps } from '@/components/StyledButton'

export default {
  title: 'StyledButton',
  component: StyledButton,
  argTypes: {
    variant: {
      control: { type: 'radio' },
      options: ['blue', 'green', 'transparent'],
    },
    children: {
      control: { type: 'text' },
    },
  },
} as Meta<typeof StyledButton>

const Template: StoryFn<StyledButtonProps> = (args) => <StyledButton {...args} />

export const TemplateTest = Template.bind({})

TemplateTest.args = {
  variant: 'blue',
  children: '青いボタン',
}

以上が、Next.jsのコンポーネント開発においてStorybookを導入する手順です。
まだ触り始めたばかりなので、いろいろ試しながら操作に慣れていきたいですね。

StorybookではControlタブからコンポーネントに渡される値の操作だけでなく、Actionを使ったコールバックのハンドリングやコンポーネントのカタログ管理に役立つドキュメント作成の機能もあるので、それらの機能については次回触れてみたいと思います。

Discussion