EY-Office ブログ

TanStack DBはブラウザーで動く仮想的なRDBか !?

TanStackTanStack Query(旧 React Query)をはじめ有用なオープンソースライブラリーをリリースしている組織ですが、最近TanStack DBのベータ版がリリースされました。

最初にTanStack DBという名前を見たときには、遂にORM(Object-relational mapping)を出したのかと思いましたが違いました。😅
面白そうなのでいつものReactアプリで試してみたところ、新しいライブラリーだなと感じました。

TanStack DB Bing Image Creatorが生成した画像を使っています

TanStack DBとは、AIまとめ

TanStack DBが何なのかは、このBlogを読むと判りますが、長いですよね。
そこで、AI(Perplexity)にまとめてもらいました。


TanStack DBは、React Queryをはじめとする「TanStack Query」にアドオンされる形で新たに登場した、クライアントサイドのデータベースレイヤーです。主な特徴は「差分データフロー(differential dataflow)」を使い、膨大なデータセットでもミリ秒単位でクエリ結果をインクリメンタルに再計算することで、再レンダリングの発生やスピナーの点滅・待機状態などUXを妨げる問題を抜本的に解決できる点です。

現状の多くのReactアプリは、①ビューごとに専用APIを作る(高速だがAPIが分散・複雑化)か、②大量データを1回で取得して前処理やフィルタをクライアント側で行う(APIはシンプルだがパフォーマンスが鈍化)のどちらかを選んでいるが、TanStack DBはこの第3の道――「正規化コレクションを一度ロードし、クライアント内で高速なインクリメンタル結合やフィルタを実現」――を実現します。

具体的なメリット・特徴:

  • リアクティブなライブクエリ、型安全なクエリ構築、楽観的(optimistic)処理、楽観的なロールバックまで自動化されるため、数千件を超えるタスクでも即座にUIが反映される。
  • 従来必要だった「useMemo」や「手動での楽観的キャッシュ操作」などの冗長なコードが不要になる。
  • 複数コレクション(例:タスク・ユーザー・プロジェクトなど)をロードしておけば、フロントエンドで結合・フィルタ・ソートがミリ秒で完了。ページ遷移時もAPIリクエストなし、瞬時に表示できる。

技術的な意義:

  • TanStack Queryはサーバ状態管理・非同期キャッシュ部分で事実上の標準になっているが、データ構造を“単なるキャッシュエントリー”扱いのため、複数データ間のリアクティブな関係や結合・ライブ更新は苦手でした。
  • TanStack DBはこの関係性・リアクティビティの層を加えることで、「Queryの強力なサーバキャッシュ」+「DBのリレーショナル&インクリメンタルな高速クエリ」という理想的な仕組みを提供します。

活用方法や移行も容易:

  • 既存のqueryClientやuseQueryの呼び出しをそのまま活かしつつ、必要なコレクションだけTanStack DBに移行できる。
  • RESTやGraphQL、最近登場している同期エンジン(Electric SQLなど)とも相性が良く、「大量データを最初に一度だけロード→インクリメンタルなクエリ&結合」を可能にします。
  • これまで一部大企業(Linear, Figma, Slackなど)が独自開発していた超高速レイヤーをパッケージ化し、誰でも利用可能にすることを目指しています。

まとめると、TanStack DBは「フロントエンドのクライアントサイドデータの制約やUX問題を抜本的に解決しつつ、既存のTanStack Queryとの組み合わせで非同期データ管理の最適解」を追求する最新テクノロジーです。今後のリアクティブWebアプリ開発の標準になる可能性があります。


TanStack DBとは、私の理解

TanStack DBはORMではないのですが、ブラウザーの上で動くデータベースのようなものです。ただし、データの実体は

  1. サーバーのAPIからTanStack Queryを使ってデータを取得し、更新等はAPIを使いサーバーのデータを更新する
  2. 以前ブログに書いたElectricのようなデータベースに同期するツールでデータを取得、更新は1.と同じ
  3. ブラウザーのlocalStorageからデータを取得・更新を行う

等が選べ、設定が違うだけで、どの方式にしてもアプリケーションのコードは変更する必要がないのが素晴らしいです!

機能としては、

  • RDBが持つ機能をほぼ持っている
    • SQLのSELECT文と同様に select, where, orderBy, …を持っていて、ORMを通してデータベースを扱うスタイルです
    • 複数のコレクション(≒テーブル)を持て、RDBのようにJoinできる
    • トランザクションをサポート
    • count, sum…等の集約関数や、サブクエリーなどもサポート
  • React用にuseLiveQueryフックがあり、State等を使わずに簡潔にコードが書ける
  • コレクションはZod等を使ったSchemaをサポートしており、データのバリデーションも出来ます

サーバーAPI(TanStack Query)を使ったサンプル

いつものジャンケンをTanStack DB + TanStack Query + APIサーバーで実装してみました。ただし、今回は対戦結果の表示を 全て/引き分け/勝ち/負け のセレクトボックスが追加されていて、表示を切り替える事ができます。

  • createCollectionがコレクション(TanStack DBのデータの単位、RDBのテーブルに相当する)の定義
  • ② ここではqueryCollectionOptionsを使いTanStack Queryを使うコレクションを定義しています
  • ③ queryKeyはTanStack QueryのqueryKeyです
  • ④ queryClientにTanStack Queryのインスタンスを設定しています、Quick StartのサンプルコードにはqueryClientはありませんが必要です
  • ⑤ queryFnはサーバーからのデータ取得関数
  • ⑥ getKeyはデータ要素(レコード)からキーを取り出す関数、ここではidがキーです
  • ⑦ onInsertはデータ挿入時の処理、ここでサーバーにデータを送っています
  • ⑧ TanStack DBはトランザクションをサポートしています、ここでは送信データを取り出して、その中かのidを捨ててサーバーに送っています(idはサーバー側で設定します)。
  • ⑨ サーバーにデータを送るコード
import { createCollection, eq, or, useLiveQuery } from '@tanstack/react-db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
import { QueryClient } from '@tanstack/react-query'

import ApplicationBar from './components/ApplicationBar';
import JyankenBox from './components/JyankenBox';
import ScoreList from './components/ScoreList';
import { judge, randomHand, type Score, type Te } from './libs/jyanken';
import { useState } from 'react';
import ScoreSelector from './components/ScoreSelector';

const API_URL = "http://localhost:3030/scores";
const queryClient = new QueryClient();

const scoreCollection = createCollection(                            // ← ①
  queryCollectionOptions<Score>({                                    // ← ②
    queryKey: ['scores'],                                            // ← ③
    queryClient,                                                     // ← ④
    queryFn: async () => {                                           // ← ⑤
      const response = await fetch(API_URL);
      return response.json();
    },
    getKey: (item: Score) => item.id,                               // ← ⑥
    onInsert: async ({ transaction }) => {                          // ← ⑦
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      const {id, ...newItem} = transaction.mutations[0].modified;   // ← ⑧

      await fetch(API_URL, {                                        // ← ⑨
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(newItem),
      });
    },
  })
);

function App() {
  const [selector, setSelector] = useState<number>(-1);           // ← ⑩

  const { data: scores } = useLiveQuery((q) => q                  // ← ⑪
    .from({ score: scoreCollection })                             // ← ⑫
    .fn.select((row) => ({                                        // ← ⑬
      ...row.score,
      matchDate: new Date(row.score.matchDate)
    }))                                                           // ↓ ⑭
    .where(({score}) => or(selector < 0, eq(score.judgment, selector)))
    .orderBy(({ score }) => score.matchDate, 'desc'),             // ← ⑮
    [selector]);                                                  // ← ⑯

  const pon = async (humanHand: Te) => {                          // ← ⑰
    const computerHand = randomHand();
    const score:Score = {
      id: crypto.randomUUID(),
      human: humanHand,
      computer: computerHand,
      judgment: judge(humanHand, computerHand),
      matchDate: new Date(),
    };
    scoreCollection.insert(score)
  };

  return (
    <>
    <ApplicationBar />
      <div className="mx-2 md:mx-8 md:w-1/2">
        <h1 className="my-6 text-center text-xl font-bold">
          対戦結果
        </h1>
        <JyankenBox pon={pon} />                                       // ← ⑱
        <ScoreSelector selector={selector} setSelector={setSelector}/> // ← ⑲
        <ScoreList scores={scores} />                                  // ← ⑳
      </div>
    </>
  );
}

Appはジャンケンアプリのメインコンポーネントです。

  • ⑩ 対戦結果の表示選択のステート、全て:-1、引き分け:0、勝ち:1、負け:2
  • ⑪ Tanstack DBの強力な機能useLiveQuery、SQLのselect文に相当し、またTanstack Query同様にステート管理やライフサイクル管理を行ってくれます
  • ⑫ fromにテーブルに相当するコレクションを指定 → From Clause
  • ⑬ selectはSQLのselectリストに相当します → Select Projections
    • selectにはSQLのselectリストに相当する式しか書けませんがfn.selectはJavaScriptの処理が書けます
    • ここではmatchDateを文字列からDate型に変換しています(JSONにはDate型が無いので、サーバーからは文字列で戻ってきます)
  • ⑭ whereで検索条件を指定 → Where Clauses
    • 対戦結果の表示選択のステートselectorの値が負ならtrue(全ての場合)、またはselectorの値がjudgmentと等しければtrue
  • ⑯ useEffect同様に第2引数で依存値を指定
    • selectorの値が変わった時にもuseLiveQueryが再実行されます
  • ⑰ ジャンケンボタンが押されたさいの処理
    • コンピューターの手を乱数で生成し、人の手、勝敗、対戦日時をコレクションに追加
    • 結果として、サーバーにこの対戦結果が送信されます
  • ⑱ JyankenBoxはジャンケンのボタンが並ぶコンポーネント、コードは他のReact記事を参照してください
  • ⑲ ScoreSelectorは全て/引き分け/勝ち/負け のセレクトボックスです。コードはとくに説明する部分はないので省略します
  • ⑳ ScoreListは対戦結果の表示コンポーネント、コードは他のReact記事を参照してください

まとめると、ジャンケンを行った後や、対戦結果の表示選択が変化した時に、useLiveQueryが実行され対戦結果が再表示されます。

LoaclSorageを使ったサンプル

次に、 LoaclSorageにデータを格納するコードに変更してみました。ブラウザーだけで動作するジャンケンアプリになっています。

  • ① LoaclSorageを使うにはlocalStorageCollectionOptionsを使います
  • ② LoaclSorageのキーを指定します。このコレクションはキーscoresの値に格納されます
  • ③ getKeyはqueryCollectionOptionsと同じです

createCollectionの定義が違うだけでAppの変更はありません!

import { createCollection, eq, or, useLiveQuery, localStorageCollectionOptions } from '@tanstack/react-db'

import ApplicationBar from './components/ApplicationBar';
import JyankenBox from './components/JyankenBox';
import ScoreList from './components/ScoreList';
import { judge, randomHand, type Score, type Te } from './libs/jyanken';
import { useState } from 'react';
import ScoreSelector from './components/ScoreSelector';


const scoreCollection = createCollection(
  localStorageCollectionOptions<Score>({                         // ← ①
    id: 'id',
    storageKey: 'scores',                                        // ← ②
    getKey: (item: Score) => item.id,                            // ← ③
  })
);

function App() {
  const [selector, setSelector] = useState<number>(-1);

  const { data: scores } = useLiveQuery((q) => q
    .from({ score: scoreCollection })
    .fn.select((row) => ({
      ...row.score,
      matchDate: new Date(row.score.matchDate)
    }))
    .where(({score}) => or(selector < 0, eq(score.judgment, selector)))
    .orderBy(({ score }) => score.matchDate, 'desc'),
    [selector]);

  const pon = async (humanHand: Te) => {
    const computerHand = randomHand();
    const score:Score = {
      id: crypto.randomUUID(),
      human: humanHand,
      computer: computerHand,
      judgment: judge(humanHand, computerHand),
      matchDate: new Date(),
    };
    scoreCollection.insert(score)
  };

  return (
    <>
    <ApplicationBar />
      <div className="mx-2 md:mx-8 md:w-1/2">
        <h1 className="my-6 text-center text-xl font-bold">
          対戦結果
        </h1>
        <JyankenBox pon={pon} />
        <ScoreSelector selector={selector} setSelector={setSelector}/>
        <ScoreList scores={scores} />
      </div>
    </>
  );
}

まとめ

TanStack DBは少し判りにくいライブラリーだと思います。まだネット上の記事が少なく、これを使ったサンプルコードも少なく、今回のブログのコードも作るのに苦労しました。とくにQuick Startのサンプルコードの間違には苦労しまいましたが、AIが教えてくれました。😄

現在のReactのようなクライアントと(API)サーバーのアーキテクチャには、AIまとめにもあるように以下があります。

  • ① クライアントの画面で表示するデータを、サーバー側で複数のテーブルをJoinしたり、加工したデータを作り、クライアントに戻す
  • ② クライアントで必要になりそうなデータを(少し多めに)所得し、クライアント側でJavaScriptの機能を使い表示データを組み立てる

①のやり方は性能面では有利ですが、サーバー・クライアント両方の開発が必要になりたいへん。②は機能豊富なクライアントではクライアント側の開発たいへんになります、さらにムダな通信が増えてしまう可能性があります。

TanStack DBは、②のクライアント処理をクライアント側にデータベースのようなソフトを導入する事で、従来のRDB(SQL)を使ったプログラミングのスタイルでクライアント側のソフトを開発する事ができるのが大きなメリットだと思います。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ