wheatandcatの開発ブログ

技術系記事を投稿してます

expo-routerを使ったアプリでstorybook v8を導入

概要

expo-router導入時に一旦Storybookは考慮せずに実装していた。
今回、storybook v6からv8へのバージョンアップと、expo-router対応の再導入を行ったため記事にまとめる。

PR

github.com

github.com

使用バージョン

  • Expo SDK: 52
  • expo-router: 4.0.19

実装手順

初期セットアップ

まずはStorybook用ファイルを作成するため、以下を実行する。

npx storybook@latest init

実行後、以下のようなファイルが生成される。

.storybook/
├── index.ts
├── main.ts
├── preview.tsx
├── stories
│   └── Button
│       ├── Button.stories.tsx
│       └── Button.tsx
└── storybook.requires.ts

エントリーポイントの切り替え対応

expo-routerはファイルベースルーティングのため、package.jsonmainフィールドを以下のように変更する。

{
  "main": "index.ts", // ← "expo-router/entry"から修正
}

次に、ルートにindex.tsを新規作成し、以下のように実装する。

if (process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === "true") {
  require("./.storybook");
} else {
  require("expo-router/entry");
}

.env にデフォルト設定を追加する。

EXPO_PUBLIC_STORYBOOK_ENABLED=false

起動コマンドの追加

"scripts": {
  "storybook:ios": "EXPO_PUBLIC_STORYBOOK_ENABLED=true npx expo run:ios",
  "storybook:android": "EXPO_PUBLIC_STORYBOOK_ENABLED=true npx expo run:android"
}

Storybook 設定ファイルの修正

.storybook/main.ts

import type { StorybookConfig } from "@storybook/react-native";

const main: StorybookConfig = {
  stories: ["../(components|features)/**/*.stories.?(ts|tsx|js|jsx)"],
  addons: ["@storybook/addon-ondevice-controls", "@storybook/addon-ondevice-actions"],
  reactNative: { playFn: false },
};

export default main;

.storybook/preview.tsx

import { MockedProvider } from "@apollo/client/testing";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import type { Preview } from "@storybook/react";
import React from "react";
import { RecoilRoot } from "recoil";
import Notification from "../containers/Notification";

const preview: Preview = {
  parameters: {
    controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } },
  },
  decorators: [
    (Story) => (
      <RecoilRoot>
        <ActionSheetProvider>
          <MockedProvider>
            <Notification>
              <Story />
            </Notification>
          </MockedProvider>
        </ActionSheetProvider>
      </RecoilRoot>
    ),
  ],
};

export default preview;

.storybook/storybook.requires.ts

import { start, updateView } from "@storybook/react-native";
import "@storybook/addon-ondevice-controls/register";
import "@storybook/addon-ondevice-actions/register";

const normalizedStories = [
  {
    titlePrefix: "",
    directory: ".",
    files: "(components|features)/**/*.stories.?(ts|tsx|js|jsx)",
    importPathMatcher: /^\.[\\/](?:(components|features)(?:\/(?!\.)(?:(?:(?!(?:^|\/)\.).)*?)\/|\/|$)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/,
    req: require.context("..", true, /^\.[\\/](?:(components|features)(?:\/(?!\.)(?:(?:(?!(?:^|\/)\.).)*?)\/|\/|$)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/),
  },
];

declare global {
  var view: ReturnType<typeof start>;
  var STORIES: typeof normalizedStories;
}

const annotations = [
  require("./preview"),
  require("@storybook/react-native/preview"),
  require("@storybook/addon-ondevice-actions/preview"),
];

global.STORIES = normalizedStories;
module?.hot?.accept?.();

const options = { playFn: false };

if (!global.view) {
  global.view = start({ annotations, storyEntries: normalizedStories, options });
} else {
  updateView(global.view, annotations, normalizedStories, options);
}

export const view = global.view;

.storybook/index.ts

import AsyncStorage from "@react-native-async-storage/async-storage";
import { registerRootComponent } from "expo";
import { view } from "./storybook.requires";

const StorybookUIRoot = view.getStorybookUI({
  storage: {
    getItem: AsyncStorage.getItem,
    setItem: AsyncStorage.setItem,
  },
  shouldPersistSelection: true,
  enableWebsockets: false,
});

registerRootComponent(StorybookUIRoot);
export default StorybookUIRoot;

動作確認

Storybook 起動コマンドを実行すると、以下のように動作確認できる。

youtu.be

まとめ

  • expo-router 対応アプリに Storybook v8 を導入する方法を紹介
  • エントリーポイントを環境変数で切り替えることで、通常アプリと Storybook を両立可能
  • 現状のディレクトリ構成に合わせた Storybook 設定も紹介
  • プロジェクトに合わせて設定や適用範囲は柔軟に調整することを推奨