はじめに
オフラインでもサクサク動いて、ネットが繋がった瞬間に自動でサーバーと同期される。そんなアプリを作りたいと思ったことはないでしょうか。
実際に作ろうとすると、これがなかなか大変です。ローカルのデータストアを用意して、サーバーとの差分を検知して、コンフリクトが起きたら解決して……と考えることが山積みになります。素直にfetchでAPIを叩くだけの設計では、電波の悪い場所や機内モードになった瞬間にアプリが固まってしまいます。
RxDBは、この「ローカルファースト」なアプリ設計を丸ごと引き受けてくれるNoSQLデータベースです。GitHubで23,000以上のスターを獲得しており、直近の更新も前日という活発さ。ライセンスはApache License 2.0なので商用利用も安心して検討できます。
とはいえ、読むより触った方が早いと思います。ブラウザだけで動くRxDBのTodoアプリをこの記事内にそのまま置いてあるので、先に挙動を見たい方はこちらからどうぞ。
RxDBとは
RxDBは、ブラウザやNode.js、React Nativeなど、あらゆるJavaScriptランタイム上で直接動作するNoSQLデータベースです。アプリ内にデータベースそのものを持つことで、ネットワークが切れていてもゼロレイテンシーでクエリを実行できます。
名前の「Rx」が示す通り、RxJSのObservableをベースにした「Reactive」なクエリが最大の特徴。データが変化すると、それを購読しているUIが自動的に更新されます。
主な特徴
- ローカルファースト設計 - データはまずローカルに保存され、オフラインでも即座に読み書きできる
- Observable Realtime Queries - クエリ結果をSubscribeするだけで、データ変更がリアルタイムにUIへ反映される
- 柔軟なバックエンド連携 - CouchDB、GraphQL、Firebase、Supabase、MongoDB、独自のHTTPサーバーなど、既存のバックエンドと同期できる
- JSON Schemaによるスキーマ定義 - 型やインデックスを宣言的に記述でき、TypeScriptとの相性も良い
- マルチプラットフォーム対応 - React、Vue、Angular、Svelte、Electron、Ionic、React Native、Capacitorなど幅広いフレームワークで利用可能
インストール
npmまたはyarnでインストールします。
npm install rxdb rxjs
yarn add rxdb rxjs
RxDBはRxJSに依存しているため、あわせてインストールしておきましょう。
RxDBのサンプルを動かす
RxDBの基本フローは、createRxDatabase()でデータベースを作り、addCollections()でJSON Schemaを使ってコレクションを定義し、insert()でドキュメントを追加し、find().$.subscribe()でその変化をリアルタイムに受け取る、という流れです。この記事内のサンプルでは、外部通信やIndexedDBへの永続化を避けるため、ストレージにインメモリで完結するgetRxStorageMemory(rxdb/plugins/storage-memory)を使っています。ブラウザのタブを閉じればデータは消えますが、動作確認用途としてはこれで十分です。
要点だけ抜き出すと、こういう流れになります。
import { createRxDatabase } from 'rxdb'
import { getRxStorageMemory } from 'rxdb/plugins/storage-memory'
const db = await createRxDatabase({
name: 'todo_db',
storage: getRxStorageMemory(),
})
await db.addCollections({
todos: { schema: todoSchema },
})
await db.todos.insert({ id: 't1', title: '牛乳を買う', done: false })
// Observableなクエリ。データが変わるたびに自動で新しい結果が流れてくる
db.todos.find({ selector: { done: false } }).$.subscribe((docs) => {
console.log('未完了:', docs.length)
})
実際に動かせるものが下です。入力欄にタスク名を入れて「追加」を押すとdb.todos.insert()が呼ばれ、チェックボックスを操作するとincrementalPatch()でdoneフィールドが更新されます。どちらの操作もfind().$.subscribe()が購読しているため、画面を再描画するコードを自分で書かなくても、一覧と未完了件数が自動的に更新されます。
タスクを追加してチェックを入れたり外したりすると、find().$.subscribe()のコールバックが即座に呼ばれ、一覧と件数表示が同時に更新されるのが確認できるはずです。上のコードでselector: {}の部分をselector: { done: false }に変えると、購読対象が「未完了のタスクだけ」に絞り込まれ、完了済みのタスクは一覧から自動的に消えるようになります。
基本的な使い方
まずはデータベースを作成し、コレクション(テーブルに近い概念)を追加してみます。ストレージにはブラウザで手軽に使えるstorage-dexie(IndexedDBベース)を利用します。
import { createRxDatabase, addRxPlugin } from 'rxdb';
import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie';
// データベースの作成
const db = await createRxDatabase({
name: 'todo_database',
storage: getRxStorageDexie(),
});
// スキーマの定義(JSON Schema形式)
const todoSchema = {
version: 0,
primaryKey: 'id',
type: 'object',
properties: {
id: { type: 'string', maxLength: 100 },
title: { type: 'string' },
done: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' },
},
required: ['id', 'title', 'done'],
};
// コレクションの追加
await db.addCollections({
todos: { schema: todoSchema },
});
データの追加とリアルタイムクエリはこのように書けます。
// ドキュメントの追加
await db.todos.insert({
id: 'todo-1',
title: 'RxDBの記事を書く',
done: false,
createdAt: new Date().toISOString(),
});
// Observableなクエリ(データ変更時に自動で流れてくる)
const subscription = db.todos
.find({ selector: { done: false } })
.$.subscribe((todos) => {
console.log('未完了のタスク数:', todos.length);
});
// 購読解除
subscription.unsubscribe();
insertやupdateでデータを変更すると、購読しているfind().$が自動で新しい結果を流してくれます。ReactやVueのstateと組み合わせれば、DOM更新のためのコードを自前で書く必要がなくなります。
実践的なユースケース
条件を絞り込んでクエリする(Mango Query)
RxDBのクエリは、MongoDBに似た「Mango Query」という記法で書きます。$gteや$ltといった演算子でセレクタを組み立てたり、sortやlimitで並び替え・件数制限をかけたりできるのが特徴です。以下は、タスクに優先度(priority)というフィールドを持たせ、指定したしきい値以上のタスクだけを優先度の高い順に絞り込む例です。
// priority が指定値以上のタスクだけを、優先度の高い順に取得する
const query = db.todos.find({
selector: { priority: { $gte: threshold } },
sort: [{ priority: 'desc' }],
})
query.$.subscribe((docs) => {
console.log(`priority >= ${threshold}:`, docs.length, '件')
})
スライダーを動かすたびにpriority: { $gte: threshold }というセレクタを持つ新しいクエリを組み立て直し、sort: [{ priority: 'desc' }]で優先度の高い順に並び替えています。しきい値を5まで上げると、優先度5の「コードレビュー」しか残らないことが確認できるはずです。$gteを$ltに変えれば「しきい値未満」の絞り込みに反転できます。
ドキュメントを更新する(patchとincrementalPatch)
find()やinsert()で取得したドキュメントはRxDocumentというオブジェクトで、.patch()または.incrementalPatch()を呼ぶことで特定のフィールドだけを部分更新できます。incrementalPatch()は更新の直前に最新のドキュメント状態を自動取得してから変更を適用するため、他の変更と競合しにくい安全な書き方です。以下はボタンを押すたびにカウンター用のドキュメントを1ずつ更新する例です。
// 特定フィールドだけを部分更新する。取得済みのdocが古くても安全に反映される
await counterDoc.incrementalPatch({
count: counterDoc.count + 1,
})
// 購読していれば、patch/incrementalPatchの結果もfind().$へ自動で流れてくる
+1/-1ボタンを押すたびにincrementalPatch()が呼ばれてドキュメントのcountフィールドだけが更新され、findOne('main').$.subscribe()が購読しているUIへ即座に反映されます。incrementalPatchをpatchに置き換えても結果は同じですが、複数箇所から同時に同じドキュメントを更新するような場面ではincrementalPatchの方が競合が起きにくく安全です。
Reactコンポーネントとの連携
React Hooksと組み合わせると、リアルタイムなUIを簡潔に実装できます。
import { useEffect, useState } from 'react';
import type { RxDatabase } from 'rxdb';
function TodoList({ db }: { db: RxDatabase }) {
const [todos, setTodos] = useState<any[]>([]);
useEffect(() => {
const subscription = db.todos
.find({ selector: { done: false } })
.$.subscribe((docs) => setTodos(docs));
return () => subscription.unsubscribe();
}, [db]);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
コンポーネントのアンマウント時にunsubscribe()を呼ぶのを忘れないようにしましょう。メモリリークの原因になります。
バックエンドとのレプリケーション
RxDBの真骨頂は、既存のバックエンドとの同期機能です。以下は独自のHTTPサーバーと同期する例です。
import { replicateRxCollection } from 'rxdb/plugins/replication';
const replicationState = replicateRxCollection({
collection: db.todos,
replicationIdentifier: 'my-todo-replication',
live: true,
pull: {
handler: async (checkpoint) => {
const response = await fetch('/api/todos/pull', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
return {
documents: data.documents,
checkpoint: data.checkpoint,
};
},
},
push: {
handler: async (changeRows) => {
const response = await fetch('/api/todos/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changeRows }),
});
return response.json();
},
},
});
pullでサーバーから差分を取得し、pushでローカルの変更をサーバーへ送信します。live: trueにしておけば、オンライン中は継続的に同期が走り続けます。オフラインになっても書き込みはローカルに蓄積され、再接続時に自動で同期されるのが大きな利点です。
まとめ
RxDBを使うと、オフライン対応・リアルタイム同期といった、本来なら実装コストの高い機能を、宣言的なスキーマとObservableなクエリだけで実現できます。今回紹介したのは基本的な使い方の一部で、ほかにもコンフリクト解決の仕組みや、CouchDB・GraphQL・Firebaseなど各種バックエンドへのレプリケーションプラグインが用意されています。
PWAやモバイルアプリなど「ネットワークが不安定な環境でも快適に使えるアプリ」を作りたい方は、まず公式のQuickstartから試してみることをおすすめします。