Storybookを復活させる(+ with Apollo Client)

2025 04 20
15

storybookを復活させる

新しく入った現場で以前使っていたStorybookがある瞬間から正常に作動しない状態になったので復活させてくださいという依頼が入りました。
この機会に最新バージョンを一からインストールし、Docker環境とApollo Client(GraphQL)を活用して再構築した手順をまとめました。


Storybookとは?

StorybookはUIコンポーネントを独立して開発・テストできるオープンソースのツールです。
コンポーネントを視覚的に表示することで、フロントエンド開発者とデザイナーの協業と効率性を向上させます。

コンポーネントを確認するためにブラウザ上の該当ページへアクセスせずとも、単体で動作をチェックできるのが最大のメリットです。
そして、現在このサービスに使用されているコンポーネントを一目で見ることができ、独立した状態での動作を確認することができます。
個人的には一般的な状態では確認しにくいモーダル、アイコン、状態変化によるデザインチェックの際に有用に使いました。
詳細は公式サイトを参照 👉 storybook

Apollo Clientとは?

Apollo ClientはGraphQLクエリを通じてデータを管理し、フロントエンドアプリケーションと連携するJavaScriptの状態管理ライブラリです。
効率的かつ柔軟なデータロードとキャッシング機能を提供します。
詳細は公式サイトを参照 👉 apollographql

旧バージョンStorybookの削除と新規インストール

既存の旧バージョンStorybookが最新環境との互換性に問題があったため、完全に削除して最新バージョンのStorybookを新しくインストールしました。
新しくインストールしたStorybookとアドオンは以下となります。

"@storybook/addon-essentials": "7.6.20",
"@storybook/addon-interactions": "7.6.20",
"@storybook/addon-links": "7.6.20",
"@storybook/addon-onboarding": "1.0.11",
"@storybook/blocks": "7.6.20",
"@storybook/nextjs": "7.6.20",
"@storybook/react": "7.6.20",
"@storybook/test": "7.6.20",
"storybook": "7.6.20",
"storybook-addon-apollo-client": "5.0.0",

Storybookサーバーの起動と動作確認

インストール後、Storybookを起動して正常にセットアップされていることを確認しました。

npm run storybook

初期ポート番号は6006なのでhttp://localhost:6006にアクセスして動作をチェックしました。

Docker環境でのStorybookポート開放

ターミナル上でのStorybook起動は問題がありませんでしたが、これだけではページが開けませんでした。
原因はDocker環境で起動したためです。
新しいポートを使うためにはDockerで該当ポートを開放する必要があります。
以下のようにポートをマッピングしました。

# docker-compose.yml
mintora:
    container_name: test-web
    build:
      context: .
      dockerfile: docker/web/local.Dockerfile
    ports:
      - "6006:6006" // storybook ポート開放
    volumes:
      - ./web:/usr/src/app:cached
      - node_modules:/usr/src/app/node_modules
    command: npm run dev

ポートを開放後に再起動すると、Storybookが正常に表示されました。
初期環境初期環境

Storybook設定

新規にインストールしたStorybookを現在の環境に合わせるため、configとpreviewファイルを設定しました。

main.tsにはstoriesファイルを作成するパースと使用するアドオンを設定しました。

// storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs'
 
const path = require('path')
 
const config: StorybookConfig = {
  stories: [
    '../**/**/*.stories.@(js|jsx|mjs|ts|tsx)',
  ],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-onboarding',
    '@storybook/addon-interactions',
    'storybook-addon-apollo-client',
  ],
  framework: {
    name: '@storybook/nextjs',
    options: {},
  },
  staticDirs: ['../public'],
  webpackFinal: async (config) => {
    config.resolve = config.resolve || {}
    config.module = config.module || {}
    config.module.rules = config.module.rules || []
 
    config.resolve.alias = {
      ...config.resolve.alias,
      '@': path.resolve(__dirname, '../'),
      '@styles': path.resolve(__dirname, '../styles'),
      '@components': path.resolve(__dirname, '../components'),
    }
    config.devtool = false
    return config
  },
}
export default config

preview.tsにはapollo client使用のため、MockedProviderを設定しておきました。

// storybook/preview.ts
import type { Preview } from '@storybook/react'
import '../styles/globals.scss'
import { MockedProvider } from '@apollo/client/testing'
 
const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
    apolloClient: {
      // apollo/clientとStorybookのバージョンによってAddonバージョンを合わせる必要があるので注意
      // https://storybook.js.org/addons/storybook-addon-apollo-client
      MockedProvider,
    },
  },
}
 
export default preview

stories作成

これで初期環境設定が完了されましたので、storiesファイルを作成しました。

storiesファイルは以下のように構成されます。

// Button/index.stories.tsx
import {
  PrimaryButton,
  ...
} from './index'
import type { Meta, StoryObj } from '@storybook/react'
 
const meta: Meta = {
  title: 'Components/Buttons',
  parameters: {
    // コンポーネント配置: centered, fullscreen, padded
    layout: 'centered',
  },
  // Doc自動作成
  tags: ['autodocs'],
  // コンポーネントの引数指定設定
  argTypes: {
    icon: {
      control: 'text',
      description: 'icon名',
    },
    disabled: {
      control: 'boolean',
      description: 'Disabled',
    },
  },
}
export default meta
 
export const Primary: StoryObj<typeof PrimaryButton> = {
  render: (args) => <PrimaryButton {...args} />,
  args: {
    type: 'button',
    icon: 'offer',
    disabled: false,
    isLoading: false,
    onClick: () => {
      alert('click')
    },
  },
}
...

実際に使うコンポーネント以外に、単純確認のためにもstoriesを作成することができます。
以下はこのサービスで使用するアイコンのリストを確認するため、作成したストーリーです。

// Icon/index.stories.tsx
import { Icon } from './index'
import type { Meta, StoryObj } from '@storybook/react'
 
const meta = {
  title: 'Components/Icon',
  component: Icon,
  parameters: {
    // centered, fullscreen, padded
    layout: 'padded',
  },
  argTypes: {
    icon: {
      control: 'text',
      description: 'icon名',
    },
    size: {
      control: 'number',
      description: 'サイズ(rem)',
    },
  },
  tags: ['autodocs'],
} satisfies Meta<typeof Icon>
 
export default meta
type Story = StoryObj<typeof meta>
 
const glyphNames = [
  'camera',
  'operating',
  'company',
  'document',
  'instagram',
  'logout',
  'question',
  ... 省略
  'operator',
  'searchCheck',
]
 
export const Default: Story = {
  args: {
    icon: '',
    size: 24,
  },
  render: (args) => (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      {glyphNames.map((iconName) => (
        <div key={iconName}>
          <Icon {...args} icon={iconName} />
          <p style={{ margin: '4px' }}>{iconName}</p>
        </div>
      ))}
    </div>
  ),
}

このように作成すれば、アイコンを一目に確認できるので、重複防止と共に新規デザインの際に良いので、新しいプロジェクトを始める時に常に作っておきます。

Apollo Client(GraphQL)とStorybookの統合

GraphQLデータを必要とするコンポーネントが存在したため、StorybookでもApollo Clientを使えるように設定しました。
実際のネットワークリクエストではなく、事前に定義したMockデータを返す仕組みです。
Apollo ClientのuseQueryやuseMutationフックで発生するGraphQLのリクエストを横取りして定義したmockデータを返します。

個別ストーリー

import { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { TestDialog } from './scope-condition-dialog'
import { MockedProvider } from '@apollo/client/testing'
import { TestMockDocument } from '@/graphql-types'
 
// 必要なmockを作成
const MockData = {
  request: {
    query: TestMockDocument,
    variables: {},
  },
  result: {
    data: {
      msMockData: [
        {
          __typename: 'MsMockType',
          id: '1',
          name: 'coffeee',
        },
        { __typename: 'MsMockType', id: '2', name: 'cake' },
        {
          __typename: 'MsMockType',
          id: '3',
          name: 'snack',
        },
        {
          __typename: 'MsMockType',
          id: '4',
          name: 'macaron',
        },
        { __typename: 'MsMockType', id: '5', name: 'chocolate' },
        { __typename: 'MsMockType', id: '6', name: 'ichigo' },
      ],
    },
  },
}
 
const meta: Meta<typeof TestDialog> = {
  title: 'Event/TestDialog',
  component: TestDialog,
  parameters: {
    layout: 'centered',
  },
  decorators: [
    (Story) => (
      // decoratorsにMockedProviderと作成したmockデータを設定
      <MockedProvider mocks={[MockData]} addTypename={false}>
        <Story />
      </MockedProvider>
    ),
  ],
  argTypes: {
    dialogState: { control: 'boolean' },
  },
}
 
export default meta
type Story = StoryObj<typeof TestDialog>
 
export const Default: Story = {
  render: (args) => {
    const form = useForm<any>({
      mode: 'all',
    })
    const { control } = form
 
    const [isActive, setIsActive] = useState(true)
 
    return (
      <FormProvider {...form}>
        <TestDialog
          control={control}
          dialogState={[isActive, setIsActive]}
        />
      </FormProvider>
    )
  },
}

順番は以下のようになります。

  1. mockデータ定義
    const MockDataのようにmockデータを作成します。
    request.queryにはモックキングするGraphQL documentを入れておきます。
    request.variablesは該当クエリに渡される変数です。
    result.dataにほしいmockデータを作成します。

  2. MockedProviderでラップ

  <MockedProvider mocks={[MockData]} addTypename={false}>
    <Story />
  </MockedProvider>

mocks propに定義したmock配列を渡します。
addTypename=はmockデータに直接、typenameを入れたため、Apolloが自動に追加しないようにするオプションです。

  1. コンポーネントの内部でクエリ呼び出し
    ストーリーが始まってコンポーネント内部でクエリーが呼び出されると、ネットワークを呼び出す代わりに、MockedProviderがrequest.queryとrequest.variablesが一致するmockを探してresult.dataをリターンします。

グローバルストーリー

一つのストーリーで使用するのではなく、グローバルで必要なmockデータであればpreviewに設定します。
二つの方法の中で楽なものを使えばいいです。

  1. decorators方式
// storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../styles/globals.scss';
import { MockedProvider } from '@apollo/client/testing';
 
// グローバルで使いたいmockデータ
import { MockData } from '../src/mocks';
 
const preview: Preview = {
  decorators: [
    (Story) => (
      <MockedProvider mocks={[MockData]} addTypename={false}>
        <Story />
      </MockedProvider>
    ),
  ],
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
    controls: { … },
  },
}
 
export default preview;
  1. storybook-addon-apollo-client使用
    mainファイルにアドオン設定
// storybook/main.ts
addons: [
    'storybook-addon-apollo-client',
    ...
  ],
import type { Preview } from '@storybook/react';
import '../styles/globals.scss';
 
import { MockedProvider } from '@apollo/client/testing';
import { withApolloClient } from 'storybook-addon-apollo-client';
 
// グローバルで使いたいmockデータ
import { MockData } from '../src/mocks';
 
export const decorators = [
  // 全てのストーリーをこのデコレーターでラッピングます。
  withApolloClient({
    MockedProvider,
    // storybook-addon-apollo-clientはここに定義したmocks配列を
    // 各ストーリーにapolloClient.parameters.mocksに入れてくれます。
    mocks: [
      MockData,
      // 必要なら追加 mock…
    ],
    addTypename: false, // mockデータに__typenameを直接入れたらfalse
  }),
]
 
const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
    apolloClient: {
      // https://storybook.js.org/addons/storybook-addon-apollo-client
      MockedProvider,
    },
  },
}

ステージングサーバーでの自動Storybook起動設定(Docker)

ローカルでのストーリー作成後作業が終わったら、他の開発者やデザイナーも確認できるようにステージングサーバーにデプロイします。

# syntax=docker/dockerfile:1
 
# npm install
...
 
# build
FROM node:20.17.0-alpine3.20 AS builder
 
ARG stage=""
 
RUN npm run build
 
// storybookビルドを追加
RUN npm run build-storybook
 
# runner
FROM node:20.17.0-alpine3.20 AS runner
 
...
 
CMD ["node", "server.js"]

nginxエラー対応

Docker設定を終えてステージングサーバーのストーリーブックにアクセスしてみたら、ストーリーは開きますが、コンポーネントが無限ローディング状態でした。
デベロッパーツールを開いてコンソールログを確認したところ、以下のようなエラーが検出されました。
Refused to display 'https://stg.******.jp/' in a frame because it set 'X-Frame-Options' to 'deny'.
nginxのadd_header X-Frame-Options DENY; 設定によりiframeが拒否されたのが原因でした。
ストーリーブックのコンポーネントをiframe中でレンダリングするため、iframeを許可する必要があります。

location /storybook/ {
    proxy_pass http://127.0.0.1:3000;
 
    proxy_set_header X-Forwarded-For $http_x_forwarded_for;
    proxy_hide_header X-Powered-By;
    // iframeブロックを防ぐためX-Frame-Optionsを空に設定
    add_header X-Frame-Options "";
}

add_header X-Frame-Options ""を設定します。

ステージングにのみ公開する

iframe問題が解決されたため、ステージング環境では正常に表示されました。
しかし、ストーリーブックは基本的に開発環境で使用されるため、本番サーバーでは起動しないようにする必要があります。
再びdockerfileに戻って、ステージング環境でのみストーリーブックがビルドされるように分岐処理をします。

# npm install
...
 
# build
FROM node:20.17.0-alpine3.20 AS builder
 
ARG stage=""
 
RUN npm run build
 
↓ 変更
# build for staging
FROM builder AS staging-builder
RUN npm run build-storybook
 
# runner for staging
FROM runner AS staging-runner
COPY --from=staging-builder /usr/src/app/storybook-static ./public/storybook-static

このようにすれば、本番サーバーはストーリーブックをビルドしないので、存在したいページになります。

rewrite設定

ストーリーブックは静的ファイルとしてビルドされるため、確認のために/storybook-static/index.htmlのようにurlを入力する必要があります。
これは面倒なので、簡単に/storybookでアクセスできるように、next.configにrewriteを設定しました。

async rewrites() {
    return [
      {
        source: '/storybook',
        destination: '/storybook-static/index.html',
      },
      {
        source: '/storybook/:path*',
        destination: '/storybook-static/:path*',
      },
    ];
  },

おわりに

このようにStorybookを最新バージョンに再設定し、Docker環境とApollo Clientを統合して開発環境を復旧することができました。
長い間放置された開発環境を最新化する過程で、多くの試行錯誤と学習があり、これを通じてツールの重要性と効果的な協業環境構築の必要性を改めて知ることができました。
これからも開発生産性を高めることができる様々なツールと環境設定を着実に探索し、有用な内容を持続的に記録して共有するようにします。