🎳

IndexedDBを利用したオンライン/オフライン時におけるデータ管理

2023/12/04に公開

業務で indexedDB というブラウザ内でデータ保存することができるデータベースを使ったので、そのアウトプットとして記事にまとめました。少しでも参考になれば幸いです。

🤖 IndexedDBとは?

https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API

IndexedDBは、ブラウザ内で使用できる高度なデータベースシステムです。

クライアント側で動作し、大量のデータを効率的に扱うことが可能なNoSQLデータベースです。

IndexedDBは構造化されたデータをキーとともに保存し、オフライン状態でもデータの読み書きが可能です。

また、非同期APIを利用しているため、データの取得や更新を行う際に、ブラウザの主要な動作を妨げることなく操作を行えます。

そのため、UX向上、パフォーマンス向上にもつながります。

さらに、IndexedDBは「トランザクション」という機能を備えており、これにより複数のデータ操作を同時に行っても、データが正確かつ安全に扱うことが可能です。

まとめると

IndexedDB
データの生存期間 手動で消去するまで
データ保存可能容量 ブラウザによる制限なし(ディスク容量に基づく)
データアクセス可能領域 クライアントのみ
データ形式 任意のデータ形式(構造化されたデータも含む)
通信 送信しない
主な利用用途 大量かつ複雑なデータの長期保存、オフライン対応アプリケーション

以下の記事を引用させていただきました。
https://zenn.dev/tm35/articles/584ece2d771a4b

👨‍🏫 ユースケース

主なユースケースとしては以下の6点が挙げられます。

  1. オフラインデータストレージ: Webアプリケーションがオフラインでも機能するように、ユーザーデータやアプリケーションデータをブラウザに保存します。これにより、ネットワーク接続がない場合でも、アプリケーションは引き続きユーザーにサービスを提供できます。
  2. 大量のデータ処理: IndexedDBは大量のデータを効率的に扱うことができるため、大規模なデータセットを扱うアプリケーションに適しています。例えば、分析ツールやデータ集計ツールなどで利用されます。
  3. ユーザー設定とプリファレンスの保存: ユーザーのカスタム設定やプリファレンスを保存し、次回の訪問時にこれらの設定を復元するために使用されます。
  4. キャッシングと一時データストレージ: Webアプリケーションがサーバーから受け取ったデータをキャッシュするために利用され、より迅速なデータアクセスとパフォーマンスの向上を実現します。
  5. プログレッシブウェブアプリ(PWA): PWAはオフライン機能やデータの永続性に重点を置いており、IndexedDBはこれらのアプリケーションでデータを管理するための主要なツールです。
  6. ゲームの状態管理: オンラインゲームやブラウザゲームにおいて、プレイヤーの進行状況や設定を保存するために使用されます。

(ChatGPTくんに聞いた回答)

今回実装する内容は、実際に業務で扱ったオフライン時におけるデータ管理についてまとめていきたいと思います。

🛠️ 初期設定

indexedDB.ts
/**
 * @description indexedDBの初期化処理
 * @module utils/indexedDB
 * @return {Promise<IDBDatabase>}
 * @reference https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API/Using_IndexedDB
 */

export const INDEXED_DB_NAME = "test-idb";
export const USER_DATA_STORE = "userDataStore";

let idb: IDBDatabase;

export const openIndexedDB = async (): Promise<IDBDatabase> => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(INDEXED_DB_NAME, 2);
    request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
      /**
       * @memo :
       * オブジェクトストアの作成および削除や、インデックスの構築および削除が可能領域
       */

      idb = (event.target as IDBOpenDBRequest).result;
      if (!idb.objectStoreNames.contains(USER_DATA_STORE)) {
        // * オブジェクトストアの作成
        idb.createObjectStore(USER_DATA_STORE, { keyPath: "id" });
      }
    };

    request.onsuccess = (event: Event) => {
      console.log("Database opened successfully");
      idb = (event.target as IDBOpenDBRequest).result;
      resolve(idb);
    };

    request.onerror = error => {
      console.error("Database error:", error);
      reject(error);
    };
  });
};

今回の最終ゴールは、添付画像のようにデータベースとオブジェクトストアが格納されていれば成功です!🙆‍♂️

📝 機能要件

1.indexedDBにusersデータを追加

2.indexedDBにuserデータが存在する場合はindexedDBからデータを取得して、存在しない場合はswrを使ってRoute Hnadlerでデータを取得する

3.再検証処理を行う

🚀 実装手順

以下は簡易的なサンプルコードです。
実際にDBと紐付けたり、API処理を実装することで応用的に使うことができます。

データの取得処理

import useSWRImmutable from "swr/immutable";

const fetcher = async (userId: string): Promise<UserData | any> => {
  try {
    const idb = await openIndexedDB();
    const transaction = idb.transaction([USER_DATA_STORE], "readonly");
    const store = transaction.objectStore(USER_DATA_STORE);
    const request = store.get(userId);

    return new Promise((resolve, reject) => {
      request.onsuccess = async () => {
        if (request.result) {
          resolve(request.result);
        } else {
          try {
            const res = await fetch(`/api/user/${userId}`);
            if (!res.ok) {
              reject(new Error(res.statusText));
            } else {
              const data = await res.json();
              resolve(data);
            }
          } catch (error) {
            reject(error);
          }
        }
      };
      request.onerror = () => reject(request.error);
    });
  } catch (error) {
    throw error;
  }
};

// 省略...

const { data, mutate, error } = useSWRImmutable(`/api/user/${userId}`, () => fetcher(userId));

swrのfetcher関数部分を上記のように実装することで、初回ロードでAPIデータを取得した後、2回目以降はindexedDBからデータを取得するができ、オフライン時でもuserデータの取得が実現できます。

データの追加処理

useEffect(() => {
    // * 初回レンダリング時に実行
    const initialRendering = async () => {
      const idb = await openIndexedDB();
      if (idb) {
        const transaction = idb.transaction([USER_DATA_STORE], "readwrite");
        const store = transaction.objectStore(USER_DATA_STORE);
        const request = store.get(userId);

        request.onsuccess = async () => {
          if (!request.result) {
            const userData: UserData = {
              userId,
              email: "test@example.com",
              firstName: "John",
              lastName: "Doe",
              age: 30,
            };
            store.add({ id: userId, userData });
          }
        };

        request.onerror = event => {
          console.error("Error fetching user data:", event);
        };
      }
    };
    initialRendering();
  }, []);

初回時のみuseEffectを使ってAPIで取得したデータをindexedDBに格納します。

こうすることで永続的にuserデータをAPIを呼び出さずに取得することができます。

データの更新処理

const update = async () => {
    /**
     * @description IndexedDB に保存されているユーザーデータを更新する
     */
    try {
      const idb = await openIndexedDB();
      if (idb) {
        const transaction = idb.transaction([USER_DATA_STORE], "readwrite");
        const store = transaction.objectStore(USER_DATA_STORE);
        const request = store.get(userId);

        request.onsuccess = async () => {
          const data = request.result;
          if (data) {
            data.userData.age = 24;
            store.put(data);

            // * 再検証処理
            await mutate();
          }
        };

        request.onerror = event => {
          console.error("Error updating user data:", event);
        };
      }
    } catch (error) {
      console.log(error);
    }
  };

putを使用してindexedDB内の更新処理が可能です。また、mutate()を併せて書くことで再検証処理もしています。

全体コード

type UserData = {
  userId: string;
  email: string;
  firstName: string;
  lastName: string;
  age: number;
};

const fetcher = async (userId: string): Promise<UserData | any> => {
  try {
    const idb = await openIndexedDB();
    const transaction = idb.transaction([USER_DATA_STORE], "readonly");
    const store = transaction.objectStore(USER_DATA_STORE);
    const request = store.get(userId);

    return new Promise((resolve, reject) => {
      request.onsuccess = async () => {
        if (request.result) {
          resolve(request.result);
        } else {
          try {
            const res = await fetch(`/api/user/${userId}`);
            if (!res.ok) {
              reject(new Error(res.statusText));
            } else {
              const data = await res.json();
              resolve(data);
            }
          } catch (error) {
            reject(error);
          }
        }
      };
      request.onerror = () => reject(request.error);
    });
  } catch (error) {
    throw error;
  }
};

export default function TestPage({}) {
  const userId = "b1fb6cb1-d59b-032c-da88-7ae2dda0eeb8";
  const { data, mutate, error } = useSWRImmutable(`/api/user/${userId}`, () => fetcher(userId));
  const userData = data?.userData;

  useEffect(() => {
    // * 初回レンダリング時に実行
    const initialRendering = async () => {
      const idb = await openIndexedDB();
      if (idb) {
        const transaction = idb.transaction([USER_DATA_STORE], "readwrite");
        const store = transaction.objectStore(USER_DATA_STORE);
        const request = store.get(userId);

        request.onsuccess = async () => {
          if (!request.result) {
            const userData: UserData = {
              userId,
              email: "test@example.com",
              firstName: "John",
              lastName: "Doe",
              age: 30,
            };
            store.add({ id: userId, userData });
          }
        };

        request.onerror = event => {
          console.error("Error fetching user data:", event);
        };
      }
    };
    initialRendering();
  }, []);

  const update = async () => {
    /**
     * @description IndexedDB に保存されているユーザーデータを更新する
     */
    try {
      const idb = await openIndexedDB();
      if (idb) {
        const transaction = idb.transaction([USER_DATA_STORE], "readwrite");
        const store = transaction.objectStore(USER_DATA_STORE);
        const request = store.get(userId);

        request.onsuccess = async () => {
          const data = request.result;
          if (data) {
            data.userData.age = 24;
            store.put(data);

            // * 再検証処理
            await mutate();
          }
        };

        request.onerror = event => {
          console.error("Error updating user data:", event);
        };
      }
    } catch (error) {
      console.log(error);
    }
  };

  // * エラー時に実行
  if (error) return <div>Error</div>;

  return (
    <div>
      <h1>Test Page</h1>
      <Button onClick={update}>更新</Button>
    </div>
  );
}

👀 おまけ

弊社では、スマホやPC1つで完結する網羅的な教材と、無制限で解ける本番と同形式の模試で、短期間での資格取得を目指すことができる簿記のアプリ 『Funda簿記』 を運営しています。

少しでも興味のある方がいれば、リンクよりアクセスしていただくか、メールにてお願いします☺️

https://boki.funda.jp/

Discussion