StorybookでIndexedDBを使えるようにする

:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#一緒に二次元業界を盛り上げていきませんか)まで。
:::

## はじめに

プロダクトでIndexedDBを使用しており、Storybook上でもIndexedDBの初期値を設定したい場面がありました。
しかし、サードパーティーのアドオンg存在しないので自作してみます。
また、今回はIndexedDBのラッパーライブラリのDexie.jsを使用します。

実際の実装はこちらの[サンプルリポジトリ](https://github.com/suke/storybook-with-indexedDB)をご参照ください。

## 環境

- React@18.3.1
- dexie@4.0.4
- dexie-react-hook@1.1.7
- storybook@8.5.2

## DBを用意する

まず、アプリケーションで使用するデータベースのセットアップを行います。
最初にデータベースのテーブルの型を定義します。

```typescript
export type User = {
  id: number;
  name: string;
};

export type Tables = {
  users: User[];
};
```

次にDexie.jsで使用するスキーマを定義します。

```typescript
import { Tables } from "../types/tables";

export const schema = {
  users: "++id, name",
} as const satisfies Record<keyof Tables, string>;
```

最後に、データベースのクラスとインスタンスを作成する関数を実装します。
今回はReactを想定していますが、サンプルではContextなどを用いたインスタンス管理は行いません。

```typescript
import Dexie, { Table } from "dexie";
import { User } from "../types/user";
import { schema } from "./schema";

export class MyDB extends Dexie {
  users!: Table<User>;

  constructor() {
    super(DB_NAME);
    this.migrate();
  }

  async clear() {
    await this.users.clear();
  }

  private migrate() {
    this.version(1).stores(schema);
  }
}

export let db: MyDB;

export function initializeDB(dbName: string) {
  db = new MyDB(dbName);
};

export function getDB() {
  if (!db) {
    throw new Error("Database not initialized");
  }

  return db;
}
```

## IndexedDBのStorybookアドオンを作成する

StorybookでIndexedDBを使用するためにloaderを実装します。
本来、loaderはアセットの読み込み、APIの遅延読み込みなどを目的としていますが、[msw-storybook-addon](https://github.com/mswjs/msw-storybook-addon/blob/v2.0.4/packages/msw-addon/src/loader.ts)の実装を参考にloaderを用いて実装します。

https://storybook.js.org/docs/writing-stories/loaders

### アドオンの実装

まず、IndexedDBを初期化する関数を作成します。
Storybook終了時に可能な限りindexedDBのデータを破棄したいため、beforeunloadイベントでデータを破棄します。

```typescript
import { initializeDB, getDB } from "../../src/persistence/db";
import { Tables } from "../../src/types/tables";

export const initializeIndexedDB = () => {
  try {
    initializeDB("db-for-storybook");
    const db = getDB();

    window.addEventListener("beforeunload", (e) => {
      db.delete();
    });
  } catch {
    console.error("Could not create database");
  }
};
```

次にloaderを実装します。
実装としてはparametersから渡されるシードデータをIndexedDBに保存するだけです。

また、Storybook上でツールバーを操作した際にも毎回ローダーが呼ばれます。
Dexie.jsのuseLiveQueryのようなリアクティブなフックを呼び出している場合、loaderによってデータが初期化され一瞬undefinedになるため、意図しないエラーになることがあります。

その対策として、前回と現在のparametersの値を比較し、変更がなければ処理をスキップしています。

```typescript
export type IndexedDBParameters = {
  idb?: Tables;
};

type Context = {
  parameters: IndexedDBParameters;
};

let currentIdbSettings: Partial<Tables> = {};

export const indexedDBLoader = async ({ parameters }: Context) => {
  const { idb = {} } = parameters;
  const db = getDB();

  const previousIdbSettings = currentIdbSettings;
  currentIdbSettings = idb;

  if (currentIdbSettings === previousIdbSettings) {
    return {};
  }

  await Promise.all(db.tables.map((table) => table.clear()));
  await Promise.all(
    Object.entries(idb).map(([tableName, values]) =>
      db.table(tableName).bulkPut(values)
    )
  );

  return {};
};
```

最後に `preview.ts`でIndexedDBの初期化と作成したloaderを読み込めb完了です。

```typescript
import type { Preview } from "@storybook/react";
import { initializeIndexedDB, indexedDBLoader } from "./addon-indexed-db";

initializeIndexedDB('app-for-storybook');

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
  loaders: [indexedDBLoader],
};

export default preview;
```

各Storyの`parameters.idb`に使用したいデータを渡すことで、IndexedDBにデータを追加することができます。

```typescript
import type { Meta, StoryObj } from "@storybook/react";

const UserList: FC = () => {}

const meta = {
  component: UserList,
} satisfies Meta<typeof UserList>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  parameters: {
    idb: {
      users: [
        { id: 1, name: "foo" },
        { id: 2, name: "bar" },
        { id: 3, name: "hoge" },
      ],
    },
  },
};

export const Empty: Story = {};
```

また、`parameters.idb`の型推論が効かないので、`storybook.d.ts`を用意してParametersの型を上書きします。

```typescript
import { Tables } from "../types/tables";

declare module "@storybook/types" {
  interface Parameters {
    idb?: Partial<Tables>;
  }
}
```



## まとめ
今回はStorybookでIndexedDBを使用するアドオンを作成してみました。
loaderの使い方を理解しておくと、自作する必要が出た際に便利なので、ぜひ活用してみてください。

## 参考
- https://storybook.js.org/docs/writing-stories/loaders
- https://github.com/mswjs/msw-storybook-addon/blob/v2.0.4/packages/msw-addon/src/loader.ts

# 一緒に二次元業界を盛り上げていきませんか？
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。