Dexie useLiveQuery入門
目次
ブラウザにデータを保存する方法にはいくつか種類があります。Web Storageやクッキー、そして今回触れるIndexedDBです。クッキーなどは保存できるサイズも小さくテキストしか扱えませんが、IndexDBはblobや構造化されたデータを扱うことができます。名前からわかるように、フロント側に用意された簡易なデータベースです。キーや条件による検索など、データに対して多様なクエリを実行することもできます。JSONベースのNoSQL型データベースを操作するAPIと考えればイメージが掴めるかと思います。IndexedDBは、Google KeepやDropboxなどで実装されているのが確認できます。
IndexedDBはドメインをまたぐことができないので、各々のWebサイト固有のデータになります。データベースはデータを格納するためにオブジェクトストアという仕組みを持ちます。オブジェクトストアに格納される各レコードはキーと値を持っており、実際に操作するのはこのオブジェクトストアへの操作が主となります。また、データベースはバージョン情報を持っており、データを更新するたびにバージョンを更新していく必要があります。
Dexie
IndexedDBは仕様が思ったよりも難しく操作も煩雑になります。そこでよく利用されているラッパーにDexieがあります。最近、React用のフックが追加されたことを知ったので、これを使ってみることにします。フォームの入力値を保存して画面に表示する簡易なWebアプリ作成してみます。ここで実際の動作及び最終コードを確認できます。
⚠ useLiveQuery
フックは現状、dexie@3.1.0-alpha以上でないと利用できません。正式リリース後に仕様が変更になっている可能性もあります。また、ブラウザの設定によっては解説と挙動が異なることがあるかもしれません。このページで書かれているコードは、2021年3月時点で最新版の3.1.0-alpha.8の仕様に沿っています。
セットアップ
まず、必要なパッケージをインストールします。
yarn add dexie@3.1.0-alpha.8 dexie-react-hooks
今回の主役でデータベースを担当するmodles/db.js
を作成します。
import Dexie from "dexie";
import { populate } from "./populate";
export const db = new Dexie("myDB");
db.version(1).stores({ items: "++id,name" });
db.on("populate", populate);
new Dexie("データベース名")
でデータベースを作成します。db.version(バージョン).stores({ スキーマ })
でオブジェクトストア(IDBObjectStore)の作成とスキーマの定義をしています。ここでは主キーidとプロパティnameを持つitemsというオブジェクトストアを作成しています。主キーは省略することもでき、その場合は, name
のように定義します。idに++
というインクリメントがありますが、これはスキーマ構文といい++
はオートインクリメントする主キーを意味します。他にもユニークキーを示す&
などがあります。
IndexedDBのスキーマの定義はSQLとは異なり、すべてのプロパティ(SQLではカラム名)を定義する必要がなく、インデックスを作成したいプロパティのみを指定します。インデックスとは、オブジェクトストアに格納されているオブジェクトへの追加のパスのようなものです。このため、実際のレコードの挿入時はスキーマの定義時にない値も追加できます。blobのように大きなデータの場合はインデックスを作成する必要はなく、仮にスキーマで定義してしまうとパフォーマンスが低下します。
最後に、db.on("populate", トリガー関数)
で初期化時のダミーデータを読み込みます。"populate"は、データベースがクライアントに存在せずストアを作成する必要があるときに一度だけ呼ばれます。このon
メソッドの第一引数のイベントタイプには、他にもready
やerror
などが存在します。
初期化時のデータ
次に上記で定義しているpopulate関数を作成するため、modles/populate.js
を作成します。
import { db } from "./db";
export async function populate() {
await db.items.bulkAdd([{ name: "dummy-A" }, { name: "dummy-B" }]);
}
データベースへの追加はdb.ストア名.bulkAdd(オブジェクト)
で行います。単一データの場合は、add()
というメソッドも存在します。これらのメソッドはPromiseを返します。SessionStorageやクッキーは同期型でメインスレッドをブロックしますが、IndexedDBは非同期に実行するため、メインスレッドをブロックしません。
IndexedDBのすべての操作はトランザクション内で実行する必要があるのですが、Dexieは自動でトランザクションを開始してコミットしてくれます。今回は割合していますが、明示的にトランザクションを開始することもできます。
フォーム
src/Form.js
でフォームを作成します。
import React from "react";
import { db } from "../models/db.js";
import { populate } from "../models/populate.js";
function Form() {
const [name, setName] = React.useState("");
const addItemDb = async e => {
e.preventDefault();
await db.items.add({
name
});
setName("");
};
return (
<div>
<form onSubmit={e => addItemDb(e)}>
<fieldset>
<input
type="text"
placeholder="input name"
value={name}
onChange={e => setName(e.target.value)}
/>
<button type="submit" disabled={!name.length > 0}>
Add
</button>
</fieldset>
</form>
</div>
);
}
export default Form;
重要なのはボタンクリック時に呼ばれるハンドラ関数内のdb.items.add()
だけです。このメソッドは先程書いたように単一データをストアに追加するメソッドです。ここではフォームの入力データを格納しています。
リスト
次にデータベース内のデータを表示するsrc/List.js
を作成します。
import React from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../models/db.js";
const List = () => {
// db.itemsが変更されたときにコンポーネントは再レンダリングされます
const items = useLiveQuery(() => db.items.toArray(), []);
// 読み込み中
if (!items) return null;
const removeItemDb = async id => {
await db.items.delete(id);
};
const data = items.map(({ id, name }) => (
<li key={id} onClick={() => removeItemDb(id)} aria-hidden>
{id}: {name}
</li>
));
return (
<div className="box">
<h2>IndexedDB</h2>
{data.length > 0 && <ul>{data}</ul>}
</div>
);
};
export default List;
ここでようやくuseLiveQuery
フックが登場します。このフックは優秀で、データベースが変更されたときにコンポーネントを再レンダリングしてくれます。第一引数は読み出すストアデータを指定し、第二引数に監視するパラメータを指定します。これはuseEffect
の第二引数と似ています。ここではitemsストアの全データが変数itemsに格納されます。あとはこのデータをmap
を使いレンダリングする要素を作成しているだけです。各レコードはidとnameを持つので、単にそれらを出力しています。
また、要素をクリックするとそのデータを削除できるように、削除用のハンドラ関数としてremoveItemDb
を定義しています。idを受け取り、db.items.delete(id)
で指定したIDのレコードを削除しています。idは主キーなので一意のレコードが削除されます。
まとめる
最後にApp.js
を次のようにします。
import React from "react";
import Form from "./Form";
import List from "./List";
import "./styles.css";
export default function App() {
return (
<div className="container">
<Form />
<List />
</div>
);
}
上記で読み込んでいるスタイルは次のようにしています。
.container {
max-width: 860px;
margin: 1em auto;
font-family: sans-serif;
}
.box {
background-color: rgb(105, 202, 170);
margin-top: 1em;
padding: 2em;
}
li {
padding: 0.5em;
}
ul li:hover {
background-color: #fff;
}
label,
input,
button {
font-size: inherit;
padding: 0.2em;
margin: 0.1em 0.2em;
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box;
box-sizing: content-box;
}
アプリを起動すると、ダミーデータがid: name
の形式で2つ表示されるはずです。フォームに何か入力してAddボタンを押すとその内容がデータベースに追加され、その内容が下部に表示されます。同様にリストのデータをクリックするとデータベースからそのデータが削除され、画面からも削除されます。データはクライアントに保管されるため、ページをリロードしたりブラウザを再起動してページに再アクセスしても前回の状態が保持されています。
ここで重要なのは、データベースにデータを追加/削除しただけで画面にその内容が反映される点です。表示部分を担当しているList.js
を再確認してもらえればわかりますが、状態変数を変更したわけでもないのに、コンポーネントが再レンダリングされています。これを担ってくれているのがuseLiveQuery
です。
リセット
オブジェクトストアのデータをリセットする機能を追加しましょう。ここで少し興味深い動作が確認できます。Form.js
のForm関数内に以下のハンドラ関数を追加します。db.items.clear()
はitemsオブジェクトストアを削除するという関数です。その後のpopulate関数は、初期化時に読み込んだダミーデータを再び呼び出しています。
const resetDb = async e => {
alert("オブジェクトストア[items]をリセットしました");
await db.items.clear();
await populate();
};
そして次のようなボタンを追加します。
<button type="button" onClick={() => resetDb()}>
Reset
</button>
実行後、Resetボタンを押してみます。一度ストアが削除され、再び初期化のダミーデータが表示されます。しかし、画面に表示されているidは1から始まっていません。少し不思議な感じがしますが、こういうものみたいです。RDBMSで歯抜けの主キーは埋めないのと似ているかもしれません。
ダミーデータにid値を持たせておけばリセット後もidを固定することはできます。ただし、後から追加されるデータは影響を受けるため、あくまでダミーのみ固定したい場合に限ります。主キーがidでなければ問題ありません。
export async function populate() {
await db.items.bulkAdd([
{ name: "dummy-A", id: 1 },
{ name: "dummy-B", id: 2 }
]);
}
少し異なりますが、データベースを削除すればすべてが初期化されます。
db.delete("myDB");
おわりに
Dexieを使うとIndexedDBの操作が簡単になります。特にuseLiveQuery
フックはデータベース内の変更を捉え、コンポーネントを自動で再レンダリングしてくれるので便利です。
追伸、サンプルにカラーパレットのようなものを作成していたのですが、解説が長くなるのでお蔵入りしました。供養として途中まで作成したものを置いておきます。