はじめに
「グローバルな状態管理はZustandやReduxで、永続化はlocalStorageを自前でラップして、複数タブ間の同期はBroadcastChannelを手書きして……」。ローカルファーストなアプリを作ろうとすると、こうして必要なライブラリがどんどん積み重なっていきます。しかもテーブル状のデータを扱いたくなった瞬間、状態管理ライブラリだけでは力不足になり、結局ミニDBのようなものを自作し始めた、という経験がある方も多いのではないでしょうか。
TinyBaseは、この「状態管理・永続化・同期・簡易DB」をひとつの小さなパッケージにまとめてくれるライブラリです。今回はその特徴と使い方を、実際に動かせるコード例とともに紹介します。
TinyBaseとは
TinyBaseは、ローカルファーストなJavaScript/TypeScriptアプリのための、リアクティブなインメモリデータストアです。公式サイトのキャッチコピーは「Local first. Sync on demand. Fast always.」で、オフラインでも即座に動作し、必要なときだけ同期するという設計思想を持っています。単なるKey-Valueストアにとどまらず、テーブル・行・セルという表形式のデータモデルや、SQLに近い感覚でクエリを書けるTinyQLも備えており、まさに「ブラウザに持ち込む小さなデータベース」と呼べる存在です。
主な特徴
- 圧倒的な軽量さ - 最小構成でgzip後7.2kB、フル機能でも15.6kB。依存関係はゼロです
- リアクティブ設計 - テーブル・行・セル単位でリスナーを登録でき、変更があった部分だけを効率よく再描画できます
- React/Solid/Svelteとの連携 -
useCellやuseTableのようなフックが用意されており、UIとデータを簡単に結び付けられます - TinyQLによるクエリ機能 - テーブル同士のJOINやフィルタ、集計をSQLライクな記法で表現できます
- CRDT対応の同期と永続化 -
MergeableStoreを使えばWebSocketやBroadcastChannel経由でデバイス間を同期でき、IndexedDBやSQLite、PostgreSQLなどへの永続化にも対応しています
インストール
npm install tinybase
yarn add tinybase
pnpm add tinybase
Reactなどのフレームワーク向けバインディングも同じパッケージに含まれており、tinybase/ui-reactのようにサブパスからインポートするだけで使えます。追加のパッケージインストールは不要です。
TinyBaseの基本的な使い方
TinyBaseの基本はcreateStoreでストアを作り、setTableやsetCellでデータを操作するだけです。ここでは、ペットショップの在庫を管理しながら、変更をリアルタイムに検知する様子を見てみましょう。要点だけを抜き出すと、次のようになります。
import { createStore } from 'tinybase'
const store = createStore().setTable('pets', {
fido: { species: 'dog', sold: false },
felix: { species: 'cat', sold: false },
})
// テーブルの変更を検知するリスナーを登録
store.addTableListener('pets', () => {
console.log(store.getTable('pets'))
})
// 特定のセルを更新すると、リスナーが自動で反応する
store.setCell('pets', 'fido', 'sold', true)
実際にボタン操作込みで動かせるサンプルが以下です。
ボタンを押すとfidoのsoldセルだけが更新され、リスナーが即座に反応して画面が最新の状態に書き換わります。ReduxのようなReducerやActionを用意しなくても、「どこを見て、変わったら何をするか」を宣言するだけでリアクティブなデータ操作が実現できるのがTinyBaseの気持ちよさです。addTableListenerをaddCellListener('pets', 'fido', 'sold', ...)に書き換えると、テーブル全体ではなく特定のセルの変更だけを検知するようになり、無関係な行が更新されても再描画が走らなくなります。
実践的なユースケース
TinyBaseのReactフックでコンポーネントに直結する
Reactアプリでは、tinybase/ui-reactのuseCellやuseTableフックを使うことで、コンポーネント側は「どのデータを表示したいか」を書くだけで済みます。ストアの変更を検知して再レンダーする処理はすべてTinyBaseが面倒を見てくれます。中心となる部分だけを抜き出すと次のとおりです。
import { createStore } from 'tinybase'
import { useCell, useTable } from 'tinybase/ui-react'
const store = createStore().setTable('pets', {
fido: { species: 'dog', price: 5 },
})
function PetRow({ id }) {
// useCellで購読したセルが変わったときだけ再レンダーされる
const price = useCell('pets', id, 'price', store)
return <li>{id}: ¥{price}</li>
}
function App() {
const pets = useTable('pets', store)
return (
<ul>
{Object.keys(pets).map((id) => (
<PetRow key={id} id={id} />
))}
</ul>
)
}
実際に値上げボタンを押して再レンダーの範囲を確かめられるサンプルが以下です。
「値上げ」ボタンを押すと、そのセルを購読している行だけが再レンダーされます。ストアはコンポーネントの外に存在するため、状態管理ライブラリとデータストアを別々に用意する必要がなくなります。useCellをuseRowに差し替えると、セル単位ではなく行全体の変更を購読できるようになり、複数のセルをまとめて扱いたい場面で便利です。
TinyBaseのTinyQLで複数テーブルを結合・集計する
テーブルが増えてくると、「飼い主の州ごとに、もっとも平均価格が高いペットの種類は?」のような集計が欲しくなります。TinyBaseに組み込まれたクエリエンジンTinyQLを使えば、createQueriesとsetQueryDefinitionだけで、SQLのJOINやGROUP BYに近い感覚のクエリをリアクティブに定義できます。要点を抜き出すと次のようになります。
import { createStore, createQueries } from 'tinybase'
const store = createStore()
.setTable('pets', {
fido: { species: 'dog', ownerId: '1', price: 5 },
})
.setTable('owners', { 1: { name: 'Alice', state: 'CA' } })
const queries = createQueries(store)
queries.setQueryDefinition('prices', 'pets', ({ select, join, group }) => {
select('species')
select('owners', 'state')
select('price')
join('owners', 'ownerId') // ownerIdでownersテーブルと結合
group('price', 'avg').as('avgPrice') // 価格の平均を集計
})
queries.getResultTable('prices') // 集計済みの結果テーブルを取得
実際に複数テーブルをJOINしてリアクティブに集計する様子を確かめられるサンプルが以下です。
petsテーブルとownersテーブルをownerIdで結合し、州・種類ごとの平均価格を降順で取得しています。元データが変われば結果も自動で再計算されるため、フィルタや集計のたびに手動で再クエリを書く必要がありません。group('price', 'avg')をgroup('price', 'max')に書き換えると、平均価格ではなく各セグメントの最高価格でソートした結果に切り替わります。
TinyBaseのCheckpointsでUndo/Redo機能を作る
フォームやドローツールなど、ユーザーの操作を後から取り消したい場面は多くあります。TinyBaseのCheckpointsを使えば、createCheckpointsでストアの状態にスナップショットを打ち、goBackwardで前後に移動するだけでUndo/Redoが実装できます。中心部分だけを取り出すと次のようになります。
import { createStore, createCheckpoints } from 'tinybase'
const store = createStore().setRow('pets', 'felix', { sold: false })
const checkpoints = createCheckpoints(store)
checkpoints.addCheckpoint('初期状態')
store.setCell('pets', 'felix', 'sold', true)
checkpoints.addCheckpoint('売却済み')
// 1つ前のチェックポイントまで巻き戻す
checkpoints.goBackward()
実際にボタンでUndo操作を試せるサンプルが以下です。
「売却する」を押すたびにチェックポイントが積まれ、「元に戻す」を押すとgoBackward()で1つ前の状態に巻き戻ります。Undoスタックを自前で実装する手間がまるごと不要になるのは、地味ながら大きなメリットです。goBackward()をgoForward()に差し替えるとRedo方向に進めるようになり、checkpoints.getCheckpointIds()を使えば積まれた履歴の一覧を取得できます。
まとめ
TinyBaseは、状態管理・表形式データの操作・クエリ・永続化・デバイス間同期という、ローカルファーストなアプリに必要な要素を、依存関係ゼロの小さなパッケージで一気に引き受けてくれるライブラリです。今回紹介したReactフックによるUI連携、TinyQLによる集計、Checkpointsを使ったUndo機能はそのごく一部にすぎず、IndexedDBやSQLiteへの永続化、CRDTによるリアルタイム同期など、さらに踏み込んだ機能も揃っています。「状態管理はこれ、永続化はこれ」とライブラリを積み上げる前に、一度TinyBase単体でどこまでできるか試してみる価値は十分にあるはずです。