React + indexedDBでデータが永続化されたアプリケーションを作ろう

indexedDBとは、ブラウザを利用したクライアントサイドデータベースで、今では非推奨となったLocal StorageやwebSQLの代替としても推奨されています。

本記事では、React(+TS)で作成したミニTodoアプリにindexedDBを組み合わせてTodoリストをデータ永続化し、MicroSoft To Doのようにオフラインアプリケーションとして機能させてみます。

アプリケーションポータル Github

Todo管理アプリケーションのデスクトップビュー。
モバイルビュー。
この記事でわかること
  • indexedDBの応用例をサンプルを通して体感できる
  • useReducer(), useContext()で状態管理されたReactアプリケーションのクライアントDBへの同期の勘所

なお、本記事は以下のディレクトリ構成のアプリケーション(詳細はGithubを参照)を前提としており、本記事ではデータの管理、共有を担うstoreディレクトリのコードについて解説していきます。

micro-todo-app/
├── components/
│ └── ...
├── hooks/
│ └── ...
└── store/
├── todos-context.tsx //アプリケーション全体に共有されるデータの管理
├── initializer.ts
└── syncData.ts // indexedDBとの同期処理

本記事では、React + TypeScript、JavaScript ES6の基本文法レベルの知識を前提としています。

アプリケーション仕様

クライアントデータベース(indexedDB)と同期するにあたり、アプリケーションの仕様として最低限必要なものを書いておきます。

データ永続化対象のTodoデータ仕様

まず、データ永続化の対象となるTodoは、型Todoのオブジェクトで保存されており、Reactアプリケーション全体で管理されてるTodoリストのデータはTodoの配列Todo[]で定義されています。

{"filename":"todos-context.tsx","code":"export type Todo = {\n  id: string;\n  title: string;\n  description: string;\n  date: DateRange | undefined;\n  progress: 'New' | 'Working' | 'Done' | 'Pending';\n};\nexport type TodosState = {\n  Todos: Todo[];\n};","language":"typescript","id":2}

TodosStateuseReducer() フックでstateとして管理され、useContext()フックを通してアプリケーション全体にstateとその更新処理が共有されています。

Store中のState更新を行うActionの種類

本記事で紹介しているサンプルTodoアプリでは、以下の3種類のActionを設定しています。

  • ADD_TODOアクション:Todoを1つ追加するアクションです。Todo型のオブジェクトをTodosState型のStateに追加します。
  • REMOVE_TODOアクション:削除が選択されたTodoを削除するアクションです。
  • EDIT_TODOアクション:Todoの進行状態を表すprogressプロパティが変更された時、変更をstateに反映するアクションです。

今回はサンプルとして、EDIT_TODOアクションについてはprogressプロパティの変更のみ実装していますが、他のプロパティの変更を実装した場合にも、stateへの反映やクライアントDB(indexedDB)との同期処理は全く同じです。

React、TypeScriptの話になりますので詳細は記載しませんが、同じコードパターンでアクションの追加・拡張はできますので試してみてください。

クライアントDBとのデータ同期の勘所

アプリケーションの仕様を押さえると、アプリケーションのデータを、React・クライアントDBの間でどのようにやり取りするべきか、以下のように考えられます。

  • アプリケーションが初期化されるとき、クライアントDBにデータがあるか確認する。クライアント側にデータがあれば、クライアント側のデータでTodoリストのstateを初期化する。
  • ADD_TODOアクションが発火したら、クライアントDBに同じデータを追加する。
  • REMOVE_TODOアクションが発火したら、クライアントDBから同じデータを削除する。
  • EDIT_TODOアクションが発火したら、クライアントDBから更新対象のデータを検索し、同じ更新を反映する。

言語化すると以外にもシンプルですね。

Reactアプリケーションにおいて、useReducer(), useContext() フックで状態管理を行う場合、アプリ全体に共有されるStateと、その更新処理(Action)はuseReducer()で初期化されます。

{"filename":"todos-context.tsx","code":"\/\/...\nconst [todosState, dispatch] = useReducer(todosReducer, initialState);\n\/\/...","language":"typescript","id":2}

すると、実装の方針としては以下のようにすれば良さそうです。

  • アプリケーションの初期化において、useReducer()に渡すinitialStateの中身を、クライアントDBから取るかどうかを分岐する処理を書き、initialState取得時に呼び出す。
  • ADD_TODOアクション発火時に実行する処理に、dispatch({ type: ..., action: ...})だけでなく、クライアントDBにデータを追加する関数の呼び出しを追加する。
  • ADD_TODOについて行った変更と同様に、REMOVE_TODOアクション発火時に実行する処理に、クライアントDBのデータ削除処理を追加する。
  • ADD_TODOについて行った変更と同様に、EDIT_TODOアクションが発火時に実行する処理に、クライアントDBに更新を反映する処理を追加する。

実装の方針を示す疑似コードはこのようになります。

{"filename":"todos-context.tsx","code":"\/\/ ctx\u306f\u3001useContext()\u3092\u901a\u3057\u3066Provider\u3067\u30a2\u30d7\u30ea\u5168\u4f53\u306b\u5171\u6709\u3055\u308c\u308b\u30b3\u30f3\u30c6\u30af\u30b9\u30c8\nconst ctx: TodosContextValue = {\n  Todos: todosState.Todos,\n  addTodo(newTodoData) {\n    dispatch({ type: 'ADD_TODO', ... });\n    syncWithClientDB(...);\n  },\n  removeTodo(todoId) {\n    dispatch({ type: 'REMOVE_TODO', ...});\n    syncWithClientDB(...);\n  },\n  editTodo(updates) {\n    dispatch({ type: 'EDIT_TODO', ... });\n    syncWithClientDB(...);\n  },\n};","language":"typescript","id":2}

データ同期を実装しよう

全体の方針が掴めましたので、詳細を実装していきます。今回は、以下のようにファイルを作成しました。

.
├── ├ micro-todo-app/
│ └── ├ store/
│ └── ├ todos-context.tsx // コンテクスト本体を記述するファイル
├── + ├ initializer.ts // アプリの初期Stateを返すイニシャライザーを定義
├── + ├ demoData.ts // 初期データがなかった際に初期Stateに設定するダミーデータ
└── + ├ syncData.ts // Stateの変更をクライアントDBに同期する処理を記述

なお、以下の実装例の紹介では、indexedDBの基本操作方法について(紙面の都合上)説明しません。不明点については、以下の記事を参照しながら読んでいただけると幸いです。

indexedDBの使い方 | アプリケーションデータをクライアント側に保持しよう

初期Stateの取得 | initializer.tsの実装

initializer.tsを編集して、アプリの初期Stateを取得します。

以下の分岐を考えるのでした。

アプリケーションの初期化において、useReducer()に渡すinitialStateの中身を、クライアントDBから取るかどうかを分岐する処理を書き、initialState取得時に呼び出す。

全体を示す疑似コードは以下のようになりますね。

{"filename":"initializer.tsx","code":"function initializer() {\n  let initialState;\n  let existPrevData = true;\n  const db = DBOpen(\n    if (not exist db){\n       existPrevData = false;\n       CrateDB();\n    }\n  );\n  \n  if (existPrevData){\n    initialState = PrevData;\n    return initialState;\n  } else {\n    TodosFromIDBStore = db.Fetch();\n    initialState = TodosFromIDBStore;\n    return initialState;\n  }\n}","language":"typescript","id":2}

全体の実装方針に基づいて、実際の実装は以下のようになりました。

{"filename":"initializer.ts","code":"\/\/import ... \u7701\u7565\n\nexport default async function initializer(): Promise {\n  let initialState: TodosState;\n  let existPrevData: Boolean = true;\n  const db = await openDB('TodoAppData', 1, {\n    upgrade(db) {\n      if (!db.objectStoreNames.contains('todos')) {\n        existPrevData = false;\n        const todoStore = db.createObjectStore('todos', { keyPath: 'id' });\n        todoStore.createIndex('idIndex', 'id', { unique: true });\n      }\n    },\n  });\n  if (!existPrevData) {\n    const tx = await db.transaction('todos', 'readwrite');\n    await Promise.all([\n      demoData.Todos.map((todo) => tx.store.add(todo)),\n      tx.done\n    ]);\n    \n    initialState = demoData;\n    return initialState;\n  }else{\n    let TodosFromIDBStore: TodosState = {\n      Todos: [],\n    };\n    const tx = await db.transaction('todos', 'readonly');\n    let cursor = await tx.store.openCursor();\n    while (cursor) {\n      TodosFromIDBStore.Todos.push(cursor.value);\n      cursor = await cursor.continue();\n    }\n    \n    initialState = TodosFromIDBStore;\n    return initialState;\n  }\n}","language":"typescript","id":2}

Stateの変更とクライアントDBへの同期 | syncData.tsの実装

syncData.tsを編集して、アプリのStateに反応してクライアントDBのデータが同期するようにします。

ここで、ReactアプリケーションにおけるState更新の特徴を考えると、Actionの種類ごとに分岐する構造でクライアントDBとの同期処理を書けば、1つの関数で済むのでは無いかと思われます。

疑似コードは以下のように書けるかと思います。

{"filename":"syncData.ts","code":"function syncWithClientDB(Action) {\n  const db = DBOpen();\n  if (Action.type = 'ADD_TODO'){\n    db.add(Action.payload );\n    return;\n  }\n  if (Action.type = 'REMOVE_TODO'){\n    db.remove(Action.payload.id );\n    return;\n  }\n  if (Action.type = 'EDIT_TODO'){\n    db.edit(Action.payload.updates );\n    return;\n  }\n}","language":"typescript","id":2}

疑似コードで立てた実装方針に基づいて、実際の実装は以下のようになりました。

{"filename":"syncData.ts","code":"export default async function syncWithClientDB(\n  state: TodosState,\n  action: Action\n) {\n  const db = await openDB('TodoAppData', 1);\n  switch (action.type) {\n    case 'ADD_TODO':\n      const DatatoSaveIDB = {\n        ...action.payload,\n        progress: 'New',\n      };\n      await db.add('todos', DatatoSaveIDB);\n      return;\n    case 'REMOVE_TODO':\n      await db.delete('todos', action.id);\n      return;\n    case 'EDIT_TODO':\n      const updatedTodo = await state.Todos.map((todo) => {\n        if (todo.id === action.updates.id) {\n          return {\n            ...todo,\n            ...action.updates,\n          };\n       }\n     });\n     \n     if (updatedTodo.length === 0 || updatedTodo.length > 1) {\n       throw Error('client db is not sync with state correctly');\n     } else {\n       await db.put('todos', updatedTodo[0]);\n     }\n     return;\n  default:\n    return;\n  }\n}","language":"typescript","id":2}

オフラインアプリケーションの機能を確認しよう

実装ができたら、アプリでオフラインアプリケーションの機能を確認してみましょう。

初期Stateの取得をテストしよう

アプリを開いたら、ブラウザの開発者モードから、アプリケーション→ストレージの順に選択して、indexedDBストレージの中にtodosオブジェクトストア(テーブルのようなもの)があるのを確認してください。

initializer.tsの実装にあるように、ユーザーがアプリケーション上で何も操作を行なっていない段階では、demoData.tsファイルに用意されたダミーデータが読み込まれています。

todosストアが作成されていることを確認しよう

クライアントDBとの同期を確認しよう

次に、ユーザーの操作によってアプリで管理されたStateが変更されたとき、正しくクライアントDBと同期しているかを確認してみましょう。

Todoアプリにtodoを追加してみましょう。「+ Add Todo」ボタンから開くウィンドウで、todoの内容を入力し、Saveボタンを押すと新規追加が完了します。

todoアプリの見た目で追加されたことが分かりますが、追加されたtodoがクライアントDB(indexedDB)上に保存されているか確認しましょう。

先ほどと同じブラウザの開発者モード→アプリケーション→ストレージを見ると、入力内容が反映されています。

ブラウザの開発者モードを開いたままだと、更新が表示に反映されていない場合がありますので、その場合は更新ボタンを押してください。

Screenshot

Todoリストがデータ永続化されていることを確認しよう

最後に、反映されているデータが永続化されていることを確認しましょう。ページリフレッシュを行なっても、同じデータ(Todo)が反映されていれば成功です。

Screenshot

まとめ

いかがだったでしょうか。駆け足の説明で申し訳ありません(時間があったら増補します)が、まるでサーバーにデータが保持されているかのような挙動を、オフラインでも再現することができました。

オフラインでも機能するTodo管理アプリMicrosoft To Doは、かなり利用者も多い有名アプリですが、原理的には本記事と同じようなデータ永続化を行なっています。

現実世界での、クライアント側DBでのデータ永続化によるオフラインアプリケーション対応には、以下のようなものがあるようです。

こんな時にオフラインアプリケーション
  • Todoリストなど、オフラインで動作することが望ましいアプリ
  • 屋外ライブ会場など、通信が不安定な環境下で一時的にクライアント側でデータ管理したい場合
  • チャットルームの書きかけテキストなど、サーバーとのやり取りをするコストを払えないがデータは保持したい場合

また、クライアントDBとアプリ間でのデータのやり取りは、アプリの相手側がサーバーになっても根本的には同じ流れと言えるでしょう。

フロントエンド側だけで手軽にデータフェッチの処理を実験できますから、サーバーサイドの学習前にチュートリアルとして利用していただくのも良いかと思います。

以上で本記事の説明を終わります。お疲れ様でした。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA


error: Content is protected !!