🏖️

【TanStack DB ☓ Elysia】クライアントで完結するローカルファーストアプリ開発 —— 再レンダリング最適化を自動化しよう

に公開

はじめに

今回ですが、TanStackのフレームワークであるTanStack Start上で、 TanStack DBによるクエリとElysiaによるAPIを使ったローカルファーストアプリ開発について記述してきます。

この記事でわかること

この記事では以下について解説しています。

  • TanStack DBの概要
  • TanStack DBによるCRUDの実装方法
  • ElysiaによるAPI側のCRUDの実装方法
  • ElysiaとTanStack DBの連携

まずはTanStack DBの概要から解説していきます。

TanStack DBとは

ToDoアプリ開発に入る前に、まずは、TanStack DBとはなにかを解説していきます。

TanStack DBは、API向けのリアクティブクライアントストアです。高速でモダンなアプリ構築の課題を解決し、以下のことを実現します。

  • データを正規化されたコレクションにロードすることで、エンドポイントの無秩序な広がりやネットワークのウォーターフォールを回避します。
  • ミリ秒未満のライブクエリとリアルタイムの反応性でクライアントのパフォーマンスを最適化します。
  • 即時の楽観的書き込みにより、ネットワークをインタラクションパスから外す

ドキュメントでは上記のような説明がなされていますが、一言で言うと、クライアント側で動作するデータベースです。

https://tanstack.com/db/latest/docs/overview

TanStack DBが実現すること

前述の通り、TanStack DBを使えばローカルでのクエリが実行可能となるため、以下のようなことが簡単に実現できます。

  • リアルタイムの反応性の獲得
  • 楽観的更新
  • コンポーネントに応じたデータfetch

つまり、ドキュメントで言及されているような悪手を打たなくて済むようになります。

  1. View 固有のAPIによるバックエンドの複雑化・エンドポイントの拡散・ウォーターフォールにつながるリスクの上昇

ToDoアプリで言えば、「完了済みタスク一覧画面」「プロジェクト別タスク一覧画面」「優先度の高いタスク一覧画面」のそれぞれにAPIを用意する必要がなくなります。

  1. すべてのロードしてフィルタリングする

こちらは、バックエンドはシンプルですが、クライアントのパフォーマンスは低下します。

しかし、TanStack DBを使用することで、これらの問題を解決し、ローカルファーストなアプリケーションを簡単に構築することができます。

TanStack DBの仕組み

先程、TanStack DBは「バックエンドをシンプルに保ちつつ、クライアント側では高速なデータ操作をする」ということを言及しました。

これは以下の3つの仕組みが備わっているためできることです。

https://tanstack.com/blog/tanstack-db-0.5-query-driven-sync

1. コレクションでデータを管理

TanStack DBでは一度取得した、データをコレクションに保持します。
これにより、クライアントからの問い合わせを高速化します。

また、コレクションは重複排除と整合性を自動で管理します。
前提としてコレクションは型付きのオブジェクトの集合です。
これは同じタイプのデータをIDベースで管理する正規化されたデータセットであることを意味しています。

簡単に言うと、バックエンドと同じような形式で整理されているということです。

そのため、データが書き換わった場合にもコレクション内部でデータは一元管理されるため、UIの再レンダリングも一貫したものとなります。

2. クエリ駆動型同期

TanStack DBはv0.5以降、query-driven-sync(クエリ駆動型同期)をサポートしています。
そのため、クエリに基づいて必要な部分だけを更新することができます。

TanStack DBは、差分データフロー(変更された部分のみを再計算する技術)を活用した、API向けのクライアントファーストストアです。ToDoを完了すると、最新のラップトップであれば、メモリに10万行以上あっても、DBは1ミリ秒未満でクエリ結果を更新します。

また、この同期機能には3つのモード(Eageron-demandprogressive)があるので、ユースケースに合わせてクエリを実装することも可能です。

これにより、データ再読み込み・ネットワーク呼び出し・再レンダリングの最適化を自動化することができます。

3. 楽観的更新が前提(ミリ秒未満のライブクエリ)

TanStack DBは楽観的更新が前提になっています。
これはLive Queriesという機能によるもので、TanStack DBはクエリ結果をリアクティブに扱います。

クエリ駆動型同期に通じる部分がありますが、これは内部的には、differential dataflow(差分フロー)という「変更された部分のみを再計算する技術」によるものです。

メモリに10万行以上あっても、DBは1ミリ秒未満でクエリ結果を更新します。

だからこそ、ミリ秒未満で即座に結果を返すことができるのです。

ここまででTanStack DBが何なのかが理解できたのでは、と思います。
次の項から、実際にToDoアプリを作っていきましょう。

TanStack DBについて、より詳しく知りたい方は以下の2記事を参照すると良さそうです。

https://tanstack.com/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query

こちらでは、TanStack Queryとの比較もわかりやすく記載されているので、非常におすすめです。

https://zenn.dev/r1013t/articles/396a10c6025205

ToDoアプリ開発

今回、作成したのはCRUDができる簡易的なToDoアプリです。

https://github.com/sc30gsw/tanstack-start-todo-app

https://tanstack-start-todo-app.vercel.app

以下の順で解説していきます

  1. 技術スタック
  2. 環境構築
  3. Drizzle ORMの実装
  4. ElysiaによるAPI実装
  5. TanStack DB実装 + UI統合

技術スタック

技術スタックは以下のとおりです。

  • フレームワーク: TanStack Start
  • クライアントDB: TanStack DB
  • Cloud DB: Turso
  • ORM: Drizzle
  • API: Elysia
  • Formライブラリ: TanStack Form
  • Schemaライブラリ: Valibot
  • Styling: Tailwind CSS

環境構築

それでは早速、環境構築からはじめます。
TanStack Startで最初の環境構築を行います。

https://tanstack.com/start/latest

上記が完了したら、必要なパッケージを導入します。

bun add @tanstack/react-db @tanstack/query-db-collection @tanstack/react-form @libsql/client dotenv drizzle-orm drizzle-typebox drizzle-valibot valibot elysia @elysiajs/eden @elysiajs/openapi
bun add -D drizzle-kit

続いて、DrizzleによるORMの実装を行います。

ORMの実装

今回はTursoを利用するため、こちらの公式ドキュメントに従い実装していきます。

https://orm.drizzle.team/docs/tutorials/drizzle-with-turso

※ ドキュメントではTurso CLIを使用していますが、Turso ダッシュボード上でも準備は可能です。

https://turso.tech/

ドキュメントに従い、Turso DBのURLとtokenを取得まで行った後に、Drizzleの各種やSchema定義を実装します。

まず、Drizzle ORMとDB接続設定を実装します。

Drizzle ORMとDB接続設定

src/db/index.ts
import { config } from "dotenv"
import { drizzle, LibSQLDatabase } from "drizzle-orm/libsql"
import * as schema from "~/db/schema"

export let db: LibSQLDatabase<typeof schema> | null = null

if (typeof window === "undefined") {
  config({ path: ".env" })

  db = drizzle({
    connection: {
      url: process.env.TURSO_DATABASE_URL!,
      authToken: process.env.TURSO_AUTH_TOKEN!,
    },
    schema,
  })
}

typeof window === "undefined"による条件分岐でサーバーサイドでのみ実施されるようにしているのは、dotenv.config()が内部で使用しているprocess.cwd()がクライアント側でなくNode.js環境でのみ利用できるためです。

そのため、if文がないとクライアント側ではエラーとなります。

次にSchema定義を行います。

Schema定義

今回はtodosのみ定義していきます。

src/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"

export const todos = sqliteTable("todos", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  text: text("text").notNull(),
  completed: integer("completed", { mode: "boolean" }).default(false),
  created_at: integer("created_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date()),
  updated_at: integer("updated_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date())
    .$onUpdate(() => new Date()),
})

export const table = {
  todos,
} as const

export type Table = typeof table

tableをexportしている理由は、後ほど、Elysia実装時にバリデーション用のSchemaを定義するためです。

次にDrizzle Configを記述します。

Drizzle ConfigのSetup

以下のように実装します。

drizzle.config.ts
import { config } from "dotenv"
import { defineConfig } from "drizzle-kit"
import { resolve } from "path"

config({ path: resolve(process.cwd(), ".env") })

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./migrations",
  dialect: "turso",
  dbCredentials: {
    url: process.env.TURSO_DATABASE_URL!,
    authToken: process.env.TURSO_AUTH_TOKEN!,
  },
})

私の環境だとresolveで絶対パスでないと、環境変数の読み込みがされなかったので、このようにしています。

最後にマイグレーションを実行して、TursoにSchemaの反映をしていきます。

マイグレーション実行

以下のコマンドを実行し、マイグレーションを実行していきます。

bunx drizzle-kit generate
bunx drizzle-kit migrate

実行後、エラーがなかったら、成功です。
Turso ダッシュボードを確認するとtodosテーブルが作成されていると思います。

次にElysiaによるAPIの実装を行っていきます。

ElysiaによるAPI実装

ElysiaJSは、Bun上で動作する高性能なTypeScript Webフレームワークです。
大きな特徴はBun ネイティブであることと、デフォルトでOpenAPIに準拠しているため、OpenAPIドキュメントが簡単に生成できるということです。

その他、型安全、パフォーマンスに優れている、Multi runtimeをサポートしているなど、今勢いのあるフレームワークです。

https://elysiajs.com/

https://x.com/saltyAom/status/1991564346535948630

それでは早速、APIの実装をしていきます。
なお、前提としてフォルダ構成などはベストプラクティスに則るようにしています。

https://elysiajs.com/essential/best-practice

TanStack Startと統合

まずは、以下のドキュメントを参照し、TanStack Startと統合していきます。

https://elysiajs.com/integrations/tanstack-start.html#integration-with-tanstack-start

以下のように実装します。

src/routes/api/$.ts
import { Elysia } from "elysia"
import { treaty } from "@elysiajs/eden"
import { openapi } from "@elysiajs/openapi"
import { createFileRoute } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"

const app = new Elysia({ prefix: "/api" })
  .use(
    openapi({
      path: "/swagger",
      documentation: {
        info: {
          title: "Todo API",
          version: "1.0.0",
          description: "API for managing todos",
        },
        tags: [{ name: "Todos", description: "Todo management endpoints" }],
      },
    }),
  )

const handle = ({ request }: { request: Request }) => {
  return app.fetch(request)
}

export const Route = createFileRoute("/api/$")({
  server: {
    handlers: {
      GET: handle,
      POST: handle,
      PATCH: handle,
      DELETE: handle,
    },
  },
})

export const getTreaty = createIsomorphicFn()
  .server(() => treaty(app).api)
  .client(() => treaty<typeof app>(import.meta.env.VITE_APP_URL || "http://localhost:3000").api)

今回はCRUDについてはAPIで実装するため、server.handlersにはCRUDのリクエストを定義しておきます。

次に、Drizzle ORMとの統合です。

Drizzle ORMとの統合

こちらも公式ドキュメントに従って実装していきます。

https://elysiajs.com/integrations/drizzle

ここでは、Elysia用にテーブルSchemaをTypeBoxのモデルに変換してバリデーションができるようにします。
また、Elysiaのベストプラクティスに従い、モデルをグループ化するようにします。

https://elysiajs.com/essential/best-practice.html#model

src/features/todos/server/model.ts
import { t } from "elysia"
import { createSelectSchema, createInsertSchema } from "drizzle-typebox"
import { todos } from "~/db/schema"

const todoSelectSchema = createSelectSchema(todos)
const todoInsertSchema = createInsertSchema(todos)

export namespace TodoModel {
  export const createBody = t.Pick(todoInsertSchema, ["text"])
  export type createBody = typeof createBody.static

  export const updateBody = t.Partial(t.Omit(todoInsertSchema, ["id", "created_at", "updated_at"]))
  export type updateBody = typeof updateBody.static

  export const todoParams = t.Object({
    id: t.String(),
  })
  export type todoParams = typeof todoParams.static

  export const todo = todoSelectSchema
  export type todo = typeof todo.static

  export const todoList = t.Array(todoSelectSchema)
  export type todoList = typeof todoList.static

  export const deleteResponse = t.Object({
    success: t.Boolean(),
  })
  export type deleteResponse = typeof deleteResponse.static

  export const databaseError = t.Object({
    error: t.String(),
    code: t.Literal("DATABASE_ERROR"),
  })
  export type databaseError = typeof databaseError.static

  export const todoNotFoundError = t.Object({
    error: t.String(),
    code: t.Literal("TODO_NOT_FOUND"),
  })
  export type todoNotFoundError = typeof todoNotFoundError.static
}

ここで重要なのは、以下の2つです。

  1. namespaceの使用
  2. typeof model.staticの使用
// Model define the data structure and validation for the request and response
import { t } from 'elysia'

export namespace AuthModel {
	// Define a DTO for Elysia validation
	export const signInBody = t.Object({
		username: t.String(),
		password: t.String(),
	})

	// Define it as TypeScript type
	export type signInBody = typeof signInBody.static

	// Repeat for other models
	export const signInResponse = t.Object({
		username: t.String(),
		token: t.String(),
	})

	export type signInResponse = typeof signInResponse.static

	export const signInInvalid = t.Literal('Invalid username or password')
	export type signInInvalid = typeof signInInvalid.static
}

上記ドキュメントのサンプルでも示されているように、関連するモデルをグループ化し、TodoModel.createBodyのように名前空間付きでアクセスできるようにしています。

型を別途定義するのではなく、typeof.staticプロパティを使用してモデルから型を取得する

また、ドキュメントでは上記が推奨されています。
これにより、型定義の重複を避け、**SSoT(Single Source of Truth)**を保つことが可能となります。

また、今回の実装では、Drizzleスキーマの再利用としてcreateSelectSchemacreateInsertSchemaを使用して、Drizzleのスキーマ定義をElysiaのバリデーションスキーマに変換しています。

そして、エラーレスポンスもModelに含めることで、APIのレスポンス型を一元管理できるようにしています。

これでDrizzle ORMとの統合は完了です。

次に、エラーハンドリングのためCustom Errorを定義していきます。

Custom Errorの定義

こちらもElysiaの公式ドキュメントを参照し、定義していきます。

https://elysiajs.com/patterns/error-handling#custom-error

src/features/todos/server/errors.ts
export class DatabaseError extends Error {
  status = 500

  constructor(message: string = "Database error occurred") {
    super(message)
    this.name = "DatabaseError"
  }
}

export class TodoNotFoundError extends Error {
  status = 404

  constructor(id?: string) {
    super(id ? `Todo with id ${id} not found` : "Todo not found")
    this.name = "TodoNotFoundError"
  }
}

このように定義しておくと、プラグイン内や他のファイルでも、これらのエラーの型を使用することができるようになります。

次にロジック実装を行うため、ビジネスロジックである、Service Classを定義します。

Service(ビジネスロジック)の定義

以下のようにclassとしてビジネスロジックを定義します。

https://elysiajs.com/essential/best-practice.html#service

src/features/todos/server/service.ts
import { db } from "~/db"
import { todos } from "~/db/schema"
import { eq } from "drizzle-orm"
import { DatabaseError, TodoNotFoundError } from "~/features/todos/server/errors"

export abstract class TodoService {
  static async getAll() {
    if (!db) {
      throw new DatabaseError("Database not initialized")
    }

    try {
      const result = await db.query.todos.findMany()

      return result
    } catch (error) {
      throw new DatabaseError(
        `Failed to fetch todos: ${error instanceof Error ? error.message : "Unknown error"}`,
      )
    }
  }

  static async create(text: (typeof todos.$inferInsert)["text"]) {
    if (!db) {
      throw new DatabaseError("Database not initialized")
    }

    try {
      const result = await db
        .insert(todos)
        .values({
          text,
        })
        .returning()

      if (!result[0]) {
        throw new DatabaseError("Failed to create todo")
      }

      return result[0]
    } catch (error) {
      if (error instanceof DatabaseError || error instanceof TodoNotFoundError) {
        throw error
      }

      throw new DatabaseError(
        `Failed to create todo: ${error instanceof Error ? error.message : "Unknown error"}`,
      )
    }
  }

  static async update(
    id: (typeof todos.$inferSelect)["id"],
    data: Partial<Pick<typeof todos.$inferInsert, "text" | "completed">>,
  ) {
    if (!db) {
      throw new DatabaseError("Database not initialized")
    }

    try {
      const result = await db
        .update(todos)
        .set({
          text: data.text,
          completed: data.completed ?? false,
        })
        .where(eq(todos.id, id))
        .returning()

      const todo = result[0]

      if (!todo) {
        throw new TodoNotFoundError(id)
      }

      return {
        ...todo,
        completed: todo.completed ?? false,
      }
    } catch (error) {
      if (error instanceof DatabaseError || error instanceof TodoNotFoundError) {
        throw error
      }

      throw new DatabaseError(
        `Failed to update todo: ${error instanceof Error ? error.message : "Unknown error"}`,
      )
    }
  }

  static async delete(id: (typeof todos.$inferSelect)["id"]) {
    if (!db) {
      throw new DatabaseError("Database not initialized")
    }

    try {
      const existing = await db.query.todos.findFirst({
        where: eq(todos.id, id),
      })

      if (!existing) {
        throw new TodoNotFoundError(id)
      }

      await db.delete(todos).where(eq(todos.id, id))

      return { success: true }
    } catch (error) {
      if (error instanceof DatabaseError || error instanceof TodoNotFoundError) {
        throw error
      }

      throw new DatabaseError(
        `Failed to delete todo: ${error instanceof Error ? error.message : "Unknown error"}`,
      )
    }
  }
}

記述内容は見ての通り、Drizzle ORMによるCRUD処理がメインとなっており、適宜エラーハンドリングをしているという内容になってます。

プロパティを保存する必要がない場合は、abstract classstatic を使用してクラスのインスタンス割り当てを避ける

ドキュメントでは上記が推奨されているので、Elysiaのベストプラクティスに従い、abstract classstatic メソッドを使用してビジネスロジックを定義するようにしています。

次に、Controller層の実装をしていきます。

Controller層の実装

Controller層は以下のように定義します。

https://elysiajs.com/essential/best-practice.html#controller

src/features/todos/server/index.ts
import { Elysia } from "elysia"
import { TodoService } from "~/features/todos/server/service"
import { TodoModel } from "~/features/todos/server/model"
import { DatabaseError, TodoNotFoundError } from "~/features/todos/server/errors"

export const todoPlugin = new Elysia({ prefix: "/todos", name: "todo" })
  .error({
    DatabaseError,
    TodoNotFoundError,
  })
  .onError(({ code, error, set }) => {
    switch (code) {
      case "DatabaseError":
        set.status = error.status

        return {
          error: error.message,
          code: "DATABASE_ERROR",
        }

      case "TodoNotFoundError":
        set.status = error.status

        return {
          error: error.message,
          code: "TODO_NOT_FOUND",
        }

      default:
        throw error
    }
  })
  .get(
    "/",
    async () => {
      return await TodoService.getAll()
    },
    {
      response: {
        200: TodoModel.todoList,
      },
      detail: {
        summary: "Get all todos",
        tags: ["Todos"],
      },
    },
  )
  .post(
    "/",
    async ({ body }) => {
      return await TodoService.create(body.text)
    },
    {
      body: TodoModel.createBody,
      response: {
        201: TodoModel.todo,
      },
      detail: {
        summary: "Create a new todo",
        tags: ["Todos"],
      },
    },
  )
  .patch(
    "/:id",
    async ({ params, body }) => {
      return await TodoService.update(params.id, {
        text: body.text,
        completed: body.completed,
      })
    },
    {
      params: TodoModel.todoParams,
      body: TodoModel.updateBody,
      response: {
        200: TodoModel.todo,
      },
      detail: {
        summary: "Update a todo",
        tags: ["Todos"],
      },
    },
  )
  .delete(
    "/:id",
    async ({ params }) => {
      return await TodoService.delete(params.id)
    },
    {
      params: TodoModel.todoParams,
      response: {
        200: TodoModel.deleteResponse,
      },
      detail: {
        summary: "Delete a todo",
        tags: ["Todos"],
      },
    },
  )

Elysiaのベストプラクティスに従い、Elysiaインスタンスをコントローラーとして扱います(1 Elysia instance = 1 controller)。

ルートを直接Elysiaインスタンス上に定義することで、ElysiaがContext型を自動推論し、型の整合性とランタイムコードとの一貫性を保証します。

エラーハンドリングはonErrorを使用することでハンドリングすることができます。

https://elysiajs.com/essential/life-cycle#on-error-error-handling

最後に、ここまでで実装した内容をElysiaのメインAPIファイルに統合して、APIの作成は完了です

Elysia メインAPIファイルにPluginを統合

これまでの実装をElysia APIに以下のように統合します。

Controller層・Service層・Model層と分離したため、メインのAPIファイルはシンプルになります。

src/routes/api/$.ts
import { Elysia } from "elysia"
import { treaty } from "@elysiajs/eden"
import { openapi } from "@elysiajs/openapi"
import { createFileRoute } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
+ import { todoPlugin } from "~/features/todos/server"

const app = new Elysia({ prefix: "/api" })
+  .onError(({ code, error, set }) => {
+    switch (code) {
+      case "VALIDATION":
+        set.status = 400
+
+        return {
+          error: error.message,
+          code: "VALIDATION_ERROR",
+        }
+
+      default:
+        set.status = 500
+
+        return {
+          error: "Internal server error",
+          code: "INTERNAL_ERROR",
+        }
+    }
+  })
  .use(
    openapi({
      path: "/swagger",
      documentation: {
        info: {
          title: "Todo API",
          version: "1.0.0",
          description: "API for managing todos",
        },
        tags: [{ name: "Todos", description: "Todo management endpoints" }],
      },
    }),
  )
+  .use(todoPlugin)

const handle = ({ request }: { request: Request }) => {
  return app.fetch(request)
}

export const Route = createFileRoute("/api/$")({
  server: {
    handlers: {
      GET: handle,
      POST: handle,
      PATCH: handle,
      DELETE: handle,
    },
  },
})

export const getTreaty = createIsomorphicFn()
  .server(() => treaty(app).api)
  .client(() => treaty<typeof app>(import.meta.env.VITE_APP_URL || "http://localhost:3000").api)

これで統合が完了しました。

ErrorHandling部分ではController層と同じくonErrorによるハンドリングを実施し、Controller層で定義したPluginをuseに渡すだけで統合が可能となります。

https://elysiajs.com/essential/plugin.html

以上で、ElysiaによるAPIの実装が完了です。

TanStack DBの実装

TanStack DBの実装は3つの手順で構成されます。

  1. Collectionの定義
  2. Live Queriesを使用してCollectionからデータをクエリする
  3. transaction mutatorsによる実装を行う(楽観的更新の実装)

https://tanstack.com/db/latest/docs/overview?utm_source=chatgpt.com#how-it-works

まずは、Collectionの定義から行います。

Collectionの定義

今回はTanStack Queryを使用するため、以下のドキュメントのサンプルコードを参考に実装を進めます。

https://tanstack.com/db/latest/docs/overview#1-tanstack-query

まず、TanStack DBのCollectionで使用するスキーマを定義します。
TanStack DBではValibotを使用するため、drizzle-valibotを使ってDrizzleスキーマからValibotスキーマを生成します。

src/features/todos/schemas/todo-schema.ts
import { createSelectSchema } from "drizzle-valibot"
import type { InferOutput } from "valibot"
import { todos } from "~/db/schema"

export const todoSchema = createSelectSchema(todos)

export type Todo = InferOutput<typeof todoSchema>

次に、Collectionの定義を行います。

src/features/todos/collections.ts
import { createCollection } from "@tanstack/react-db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
import { getTreaty } from "~/routes/api/$"
import { QueryClient } from "@tanstack/react-query"
import { todoSchema } from "~/features/todos/schemas/todo-schema"

const queryClient = new QueryClient()

const api = getTreaty()

export const todoCollection = createCollection(
  queryCollectionOptions({
    queryClient,
    queryKey: ["todos"],
    queryFn: async () => {
      const response = await api.todos.get()

      if (response.status !== 200) {
        throw new Error(response.error?.value?.message || "Failed to fetch todos")
      }

      if (!response.data) {
        return []
      }

      const mappedData = response.data.map((todo) => {
        return {
          ...todo,
          completed: todo.completed ?? false,
        }
      })

      return mappedData
    },
    getKey: (item) => {
      if (!item.id) {
        throw new Error("Todo item must have an id")
      }

      return item.id
    },
    schema: todoSchema,
    onInsert: async ({ transaction }) => {
      const { changes: newTodo } = transaction.mutations[0]

      if (!newTodo.text) {
        throw new Error("Todo text is required")
      }

      await api.todos.post({ text: newTodo.text })
    },
    onUpdate: async ({ transaction }) => {
      const { original, modified } = transaction.mutations[0]

      if (!original.id) {
        throw new Error("Todo item must have an id")
      }

      await api.todos({ id: original.id }).patch({
        text: modified.text,
        completed: modified.completed,
      })
    },
    onDelete: async ({ transaction }) => {
      const original = transaction.mutations[0].original

      if (!original.id) {
        throw new Error("Todo item must have an id")
      }

      await api.todos({ id: original.id }).delete()
    },
  }),
)

queryFnonInsertonUpdateonDeleteでは、Elysiaで定義したAPIをRPCで呼び出します。

また、Mutation(queryFn以外)の処理では、transactionを受け取ることができ、ここに実行中のMutation(変更)の情報が格納されています。

https://tanstack.com/db/latest/docs/reference/interfaces/CollectionConfig

具体的には以下のような型として情報が提供されます。

{
  mutations: [
    {
      original: T,      // 変更前のアイテム(更新・削除時)
      modified: T,      // 変更後のアイテム(挿入・更新時)
      changes: Partial<T>, // 変更されたフィールドのみ
      key: TKey         // アイテムのキー
    },
    // ... 複数の変更がある場合
  ]
}

これでCollectionの定義が完了しました。

続いて、Live Queriesの実装を行います。

Live Queriesの実装

今回、クエリパラメータを用いて検索処理も実装したいので、まずはクエリパラメータのSchemaから定義します。

クエリパラメータのSchema定義

以下のようにTodoの「テキスト」「ステータス」「作成日付かテキストのSort種別」「Sort順」の情報を持つSchemaを定義します。

src/features/todos/schemas/search-schema.ts
import { object, string, boolean, optional, pipe, transform, picklist } from "valibot"
import type { InferInput, InferOutput } from "valibot"

export const searchSchema = object({
  q: pipe(
    optional(string()),
    transform((val) => val ?? ""),
  ),
  completed: optional(boolean()),
  sortBy: optional(picklist(["createdAt", "text"])),
  sortOrder: optional(picklist(["asc", "desc"])),
})

export type SearchParams = InferOutput<typeof searchSchema>
export type SearchParamsInput = InferInput<typeof searchSchema>

Live Queryの実装

以下のように作成します。

src/features/todos/hooks/use-todos-query.ts
import { useLiveSuspenseQuery } from "@tanstack/react-db"
import { todoCollection } from "~/features/todos/collections"
import { eq, like } from "@tanstack/db"
import type { SearchParams } from "~/features/todos/schemas/search-schema"

export function useTodosQuery({
  q: searchQuery,
  completed,
  sortBy = "createdAt",
  sortOrder = "desc",
}: SearchParams = {}) {
  return useLiveSuspenseQuery(
    (q) => {
      let query = q.from({ todo: todoCollection })

      if (typeof completed === "boolean") {
        query = query.where(({ todo }) => eq(todo.completed, completed))
      }

      if (searchQuery) {
        query = query.where(({ todo }) => {
          return like(todo.text, `%${searchQuery}%`)
        })
      }

      switch (sortBy) {
        case "createdAt":
          query = query.orderBy(({ todo }) => todo.created_at, sortOrder)
          break

        case "text":
          query = query.orderBy(({ todo }) => todo.text, sortOrder)
          break
      }

      return query
    },
    [completed, searchQuery, sortBy, sortOrder],
  )
}

私はuseLiveSuspenseQueryで実装しました。
※ ドキュメントではuseLiveQueryで実装されている例が多いです

このuseLiveSuspenseQueryは、Live Queriesを実装でき、かつReactのSuspenseをサポートしているものになります。

https://tanstack.com/db/latest/docs/framework/react/reference/functions/useLiveSuspenseQuery#examples-3

また、依存配列にはクエリパラメータの値を渡すことで、クエリパラメータが変更時にクエリの再実行をトリガーすることができます。

さらに、TanStack DBのLive Queriesの関数は、内部でqというQueryBuilderのインスタンスを受け取ることができるので、ここにfromでクエリを実行したいcollectionを指定することでフィルターやソートなど簡単に実装することができます

これをTanStack Queryで実装しようとすると以下のデメリットが起きます。

  • 依存配列が変わるたびにネットワークリクエストが発生する
  • バックエンド(API)側で複雑なクエリパラメータの処理が必要となる
  • コードが冗長になりやすい
const queryKey = ['todos', { completed, searchQuery, sortBy, sortOrder }]

const query = useQuery({
  queryKey,
  queryFn: async () => {
    const params = new URLSearchParams()
    
    if (typeof completed === "boolean") {
      params.append('completed', String(completed))
    }
    
    if (searchQuery) {
      params.append('search', searchQuery)
    }
    
    if (sortBy) {
      params.append('sortBy', sortBy)
      params.append('sortOrder', sortOrder)
    }
    
    const response = await fetch(`/api/todos?${params.toString()}`)
    
    if (!response.ok) {
      throw new Error('Failed to fetch todos')
    }
    
    return response.json()
  },
  staleTime: 0,
})

さらにすごいのは、リレーションまで扱うことができる点です。

useLiveQuery(q =>
 q
   .from({ todo: todoCollection })
   .join({ project: projectCollection }, ({ todo, project }) >=>
     eq(todo.projectId, project.id)
   )
   .where(({ project }) => eq(project.favorite, true))
)

https://zenn.dev/r1013t/articles/396a10c6025205#4.リレーションを扱う

UI統合

実際に、先程作成したhooksをコンポーネント側で呼び出すと以下のようになります。

src/features/todos/components/todo-list-content.tsx
import { getRouteApi } from "@tanstack/react-router"
import { TodoItem } from "~/features/todos/components/todo-item"
import { useTodosQuery } from "~/features/todos/hooks/use-todos-query"

export function TodoListContent() {
  const routeApi = getRouteApi("/")
  const search = routeApi.useSearch()
  const q = search.q ?? ""
  const completed = search.completed
  const sortBy = search.sortBy
  const sortOrder = search.sortOrder

  const { data } = useTodosQuery({
    q,
    completed,
    sortBy,
    sortOrder,
  })

  if (!data || data.length === 0) {
    return (
      <p className="text-center text-gray-500">
        {q ? "No todos found matching your search." : "No todos yet. Add one above!"}
      </p>
    )
  }

  return (
    <div className="space-y-2">
      {data.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </div>
  )
}

実装すると体感できますが、TanStack DBの良さがかなり実感できたのではと思います。

次にTanStack DBによるMutationの実装を行います。

transaction mutatorsによる実装(楽観的更新の実装)

それでは、InsertUpdateDeleteの各処理について実装を進めていきましょう。

Insert

まずはInsert処理ですが、全体は以下のようになっております。

src/features/todos/components/todo-form.tsx
import { useForm } from "@tanstack/react-form"
import { todoCollection } from "~/features/todos/collections"
import { todoFormSchema } from "~/features/todos/schemas/todo-form-schema"
import { cn } from "~/utils/cn"

export function TodoForm() {
  const form = useForm({
    defaultValues: {
      text: "",
    },
    validators: {
      onChange: todoFormSchema,
    },
    onSubmit: async ({ value }) => {
      todoCollection.insert({
        id: crypto.randomUUID(),
        text: value.text.trim(),
        completed: false,
        created_at: new Date(),
        updated_at: new Date(),
      })

      form.reset()
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        e.stopPropagation()
        form.handleSubmit()
      }}
      className="mb-6"
    >
      <form.Field name="text">
        {(field) => (
          <div className="flex flex-col gap-2">
            <div className="flex gap-2">
              <input
                type="text"
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => field.handleChange(e.target.value)}
                placeholder="Add a new todo..."
                className={cn(
                  "flex-1 rounded-lg border px-4 py-2 focus:outline-none focus:ring-2",
                  field.state.meta.errors.length > 0
                    ? "border-red-500 focus:border-red-500 focus:ring-red-500"
                    : "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
                )}
              />
              <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
                {([canSubmit, isSubmitting]) => (
                  <button
                    type="submit"
                    disabled={!canSubmit || isSubmitting}
                    className="rounded-lg bg-blue-600 px-6 py-2 font-semibold text-white transition-colors hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
                  >
                    {isSubmitting ? "..." : "Add"}
                  </button>
                )}
              </form.Subscribe>
            </div>
            {field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
              <p className="text-sm text-red-600">
                {field.state.meta.errors.map((err) => err?.message ?? "Invalid input").join(", ")}
              </p>
            )}
          </div>
        )}
      </form.Field>
    </form>
  )
}

FormのSchemaは以下のようになっており、TanStack Formを使ってUIと統合しています。

src/features/todos/schemas/todo-form-schema.ts
import { object, string, minLength, pipe } from "valibot"

export const todoFormSchema = object({
  text: pipe(string(), minLength(1, "Todo text is required")),
})

重要なのは、useFormのonSubmitの部分です。

const form = useForm({
  defaultValues: {
    text: "",
  },
  validators: {
    onChange: todoFormSchema,
  },
  onSubmit: async ({ value }) => {
    todoCollection.insert({
      id: crypto.randomUUID(),
      text: value.text.trim(),
      completed: false,
      created_at: new Date(),
      updated_at: new Date(),
    })

    form.reset()
  },
})

ここでtodoCollection.insertを呼び出すことで、作成処理を行うことができます。

TanStack QueryだとuseMutationを使用して、以下のようになるため、かなり面倒です。
TanStack DBはこのような面倒な部分を綺麗に抽象化し、自動化してくれます。

const mutation = useMutation({
  mutationFn: async (newTodo) => {
    const response = await fetch('/api/todos', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(newTodo),
    })
    
    if (!response.ok) {
      throw new Error('Failed to add todo')
    }
    
    return response.json()
  },
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

楽観的更新にすると、onMutateなどが追加されるため、さらに冗長になってしまいます。
さらに、複数の検索条件やソートまで考慮することとなる場合、より複雑な処理を加える必要があります。
(すべてのキャッシュキーのパターンを列挙し、各キャッシュを更新したのち、ソート順を考慮して正しいソートにデータを編集などなど、考えるだけでも大変そうです)

const mutation = useMutation({
  mutationFn: async (newTodo) => {
    const response = await fetch('/api/todos', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(newTodo),
    })
    
    if (!response.ok) {
      throw new Error('Failed to add todo')
    }
    
    return response.json()
  },
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    
    const previousTodos = queryClient.getQueryData(['todos'])
    
    queryClient.setQueryData(['todos'], (old) => {
      return [...(old || []), newTodo]
    })
    
    return { previousTodos }
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

Update・Delete

こちらもまず全体から解説します。

Update・Deleteについては、同一ファイルで実装したため、以下のような構成となっております。

全体としてはシンプルで、EditModeなら編集用Formを表示し、EditModeでない場合は、デフォルトのTodoItemViewを表示するというものです。

src/features/todos/components/todo-item.tsx
import { memo, useCallback, useState } from "react"
import { useForm } from "@tanstack/react-form"
import type { Todo } from "~/features/todos/schemas/todo-schema"
import { todoCollection } from "~/features/todos/collections"
import { todoFormSchema } from "~/features/todos/schemas/todo-form-schema"
import { cn } from "~/utils/cn"

type TodoItemViewProps = {
  todo: Todo
  onEdit: () => void
}

const TodoItemView = memo(function TodoItemView({ todo, onEdit }: TodoItemViewProps) {
  return (
    <div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
      <input
        type="checkbox"
        checked={todo.completed ?? false}
        onChange={() =>
          todoCollection.update(todo.id, (draft) => {
            draft.completed = !(todo.completed ?? false)
          })
        }
        className="h-5 w-5 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
      />
      <span
        className={cn("flex-1", todo.completed ? "text-gray-500 line-through" : "text-gray-900")}
      >
        {todo.text}
      </span>
      <button
        onClick={onEdit}
        className="rounded px-3 py-1 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50"
      >
        Edit
      </button>
      <button
        onClick={() => todoCollection.delete(todo.id)}
        className="rounded px-3 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-50"
      >
        Delete
      </button>
    </div>
  )
})

type TodoItemEditProps = {
  todo: Todo
  onCancel: () => void
}

const TodoItemEdit = memo(function TodoItemEdit({ todo, onCancel }: TodoItemEditProps) {
  const form = useForm({
    defaultValues: {
      text: todo.text,
    },
    validators: {
      onChange: todoFormSchema,
    },
    onSubmit: async ({ value }) => {
      todoCollection.update(todo.id, (draft) => {
        draft.text = value.text.trim()
      })
      onCancel()
    },
  })

  return (
    <div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
      <input
        type="checkbox"
        checked={todo.completed ?? false}
        onChange={() =>
          todoCollection.update(todo.id, (draft) => {
            draft.completed = !(todo.completed ?? false)
          })
        }
        className="h-5 w-5 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
      />
      <form
        onSubmit={(e) => {
          e.preventDefault()
          e.stopPropagation()
          form.handleSubmit()
        }}
        className="flex-1"
      >
        <form.Field name="text">
          {(field) => (
            <div className="flex flex-col gap-2">
              <div className="flex gap-2">
                <input
                  type="text"
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                  className={cn(
                    "flex-1 rounded-lg border px-3 py-2 focus:outline-none focus:ring-2",
                    field.state.meta.errors.length > 0
                      ? "border-red-500 focus:border-red-500 focus:ring-red-500"
                      : "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
                  )}
                />
                <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
                  {([canSubmit, isSubmitting]) => (
                    <>
                      <button
                        type="submit"
                        disabled={!canSubmit || isSubmitting}
                        className="rounded px-3 py-1 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed"
                      >
                        {isSubmitting ? "..." : "Save"}
                      </button>
                      <button
                        type="button"
                        onClick={() => {
                          onCancel()
                          form.reset()
                        }}
                        className="rounded px-3 py-1 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-50"
                      >
                        Cancel
                      </button>
                    </>
                  )}
                </form.Subscribe>
              </div>
              {field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
                <p className="text-sm text-red-600">
                  {field.state.meta.errors.map((err) => err?.message ?? "Invalid input").join(", ")}
                </p>
              )}
            </div>
          )}
        </form.Field>
      </form>
      <button
        onClick={() => todoCollection.delete(todo.id)}
        className="rounded px-3 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-50"
      >
        Delete
      </button>
    </div>
  )
})

export const TodoItem = memo(function TodoItem({ todo }: Record<"todo", Todo>) {
  const [isEditing, setIsEditing] = useState(false)

  const handleEdit = useCallback(() => {
    setIsEditing(true)
  }, [])

  const handleCancel = useCallback(() => {
    setIsEditing(false)
  }, [])

  return isEditing ? (
    <TodoItemEdit todo={todo} onCancel={handleCancel} />
  ) : (
    <TodoItemView todo={todo} onEdit={handleEdit} />
  )
})

Update 処理詳細

こちらは重要な部分が2つあります。
1つ目が、Insertと同じく以下のuseFormonSubmitで、2つ目がcheckbox部分です。

まずは、useForm部分から見ていきましょう。
ここでもInsertと同様にCollectionに定義してtodoCollection.updateを呼び出します。

そして、draftに対して、更新後の値を代入することで更新処理が行えます。

const form = useForm({
  defaultValues: {
    text: todo.text,
  },
  validators: {
    onChange: todoFormSchema,
  },
  onSubmit: async ({ value }) => {
    todoCollection.update(todo.id, (draft) => {
      draft.text = value.text.trim()
    })
    onCancel()
  },
})

checkbox部分でも同様に、todoCollection.updateとし、draftに対して、更新後の値を代入します。

<input
  type="checkbox"
  checked={todo.completed ?? false}
  onChange={() =>
    todoCollection.update(todo.id, (draft) => {
      draft.completed = !(todo.completed ?? false)
    })
  }
  className="h-5 w-5 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>

これで更新処理の実装は完了です。

更新処理についても、TanStack Queryでの記述を見てみましょう。
こちらも、Insertと同様のことが言えると思います。

const updateMutation = useMutation({
  mutationFn: async ({ id, updates }) => {
    const response = await fetch(`/api/todos/${id}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(updates),
    })
    
    if (!response.ok) {
      throw new Error('Failed to update todo')
    }
    
    return response.json()
  },
  onMutate: async ({ id, updates }) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    
    const previousTodos = queryClient.getQueryData(['todos'])
    
    queryClient.setQueryData(['todos'], (old) => {
      return old?.map(todo => 
        todo.id === id ? { ...todo, ...updates } : todo
      ) || []
    })
    
    return { previousTodos }
  },
  onError: (err, variables, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

Draftオブジェクトの仕組み

ここで、更新処理ででてきたdraftオブジェクトについてどういうものなのか疑問に思った方に向けて解説していきます。

TanStack DBではupdate処理のみ、draftを引数で受け取ることができます。

このdraftは、TanStack DBが提供するImmerと同様のパターンを使用した変更可能なプロキシオブジェクトです。

ただし、TanStack DBは、Immerと同様の仕組みを提供してくれるものの、内部実装は独自のプロキシベースのシステムを使用しているという点は押さえておく必要があります。

とはいえ、仕組みは同じなので、「TanStack DBは独自実装なんだな〜」くらいの認識で問題ないです。

https://tanstack.com/db/latest/docs/guides/mutations

では、そもそもImmerとは何なのかですが、Immerは、イミュータブルな更新を簡潔に書くためのライブラリです。
通常はスプレッド構文などで新しいオブジェクトを作りますが、Immerを使うと直接変更するように書けます

https://immerjs.github.io/immer/

つまりdraftを一言で言うと、元のオブジェクトの変更可能なコピーといえます。

これがわかると仕組みも見えてきますが、簡単に言えば、「このコピーに対して直接変更を加えることで、新しいイミュータブルなオブジェクトを生成する」という仕組みを提供してくれているのです。

そのため、直接変更が可能で、draft.completed = trueのように直接代入できます。

まとめると、draft作業用のコピーのようなものです。
このコピーに変更を加えると、TanStack DBが自動的に新しいイミュータブルなオブジェクトを生成してくれて更新処理を実現できるということですね。

上記のため、元のオブジェクトは変更されず、安全に状態を更新することができます。

それでは話を戻して、CRUDの最後のDelete処理について見ていきましょう。

Delete 処理詳細

削除処理もこれまでのMutationと同様にCollectionからdeleteを呼び出すだけで実現できます。

<button
  onClick={() => todoCollection.delete(todo.id)}
  className="rounded px-3 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-50"
>
    Delete
</button>

こちらもTanStack Queryだと以下のようになり、かなり冗長になってしまいます。

const deleteMutation = useMutation({
  mutationFn: async (todoId) => {
    const response = await fetch(`/api/todos/${todoId}`, {
      method: 'DELETE',
    })
    
    if (!response.ok) {
      throw new Error('Failed to delete todo')
    }
    
    return response.json()
  },
  onMutate: async (todoId) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    
    const previousTodos = queryClient.getQueryData(['todos'])
    
    queryClient.setQueryData(['todos'], (old) => {
      return old?.filter(todo => todo.id !== todoId) || []
    })
    
    return { previousTodos }
  },
  onError: (err, todoId, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

ここまでで、TanStack DBの思想やUX・DXが素晴らしいことが伝わったのではないかと思います。

以上で、TanStack DBを用いたCRUDの実装は完了です。
細かな部分は解説やコードを割愛しているので、ソースコードの全容は以下のリポジトリから参照していただけると幸いです。

https://github.com/sc30gsw/tanstack-start-todo-app

注意点

TanStack DBについては、現状、以下のIssue・PRがでている状態で、TanStack DBをSSRやRSCサポートされているフレームワークで使用するとMissing getServerSnapshotというエラーがでます。

https://github.com/TanStack/db/issues/1016

https://github.com/TanStack/db/pull/709

そのため、現状では対症療法として、TanStack DBのクエリが実行されるコンポーネントについては確実にクライアント側で実行されるようにしておく必要があります。
※ SSR・RSCサポートされていないフレームワークやそもそもエラーなどの問題がない場合は対応不要です

Missing getServerSnapshot エラーの対応

以下のようにuseEffectでコンポーネントがマウントされるまでfallbackを返却するようにし、クライアント側で確実にTanStack DBのクエリが行われるようにします。

src/components/client-only.tsx
import { useState, useEffect, type ReactNode } from "react"

type ClientOnlyProps = {
  children: ReactNode
  fallback?: ReactNode
}

export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
  const [hasMounted, setHasMounted] = useState(false)

  useEffect(() => {
    setHasMounted(true)
  }, [])

  if (!hasMounted) {
    return <>{fallback}</>
  }

  return <>{children}</>
}

ClientOnlyの呼び出し側では以下のようにします。
ここでは、TodoListContentがTanStack DBのLive Queriesの実行がされる構成としています。

src/features/todos/components/todo-list-with-search.tsx
import { Suspense } from "react"
import { TodoSearch } from "~/features/todos/components/todo-search"
import { TodoListContent } from "~/features/todos/components/todo-list-content"
import { ClientOnly } from "~/components/client-only"

function TodoListLoading() {
  return (
    <div className="grid min-h-screen place-items-center">
      <p className="text-xl">Loading...</p>
    </div>
  )
}

export function TodoListWithSearch() {
  return (
    <>
      <TodoSearch />
      <ClientOnly fallback={<TodoListLoading />}>
        <Suspense fallback={<TodoListLoading />}>
          <TodoListContent />
        </Suspense>
      </ClientOnly>
    </>
  )
}

おわりに

この記事では、TanStack Start上でTanStack DBとElysiaを使ったローカルファーストなToDoアプリの開発について解説してきました。

TanStack DBを使うことで、以下のようなメリットを享受できることが実感できたのではないでしょうか。

  • リアルタイムの反応性: Live Queriesにより、データの変更が即座にUIに反映される
  • 楽観的更新の簡潔な実装: insertupdatedeleteといったシンプルなAPIで、複雑な楽観的更新を自動化
  • クライアント側での高速なクエリ: フィルタリングやソートをネットワークリクエストなしで実現
  • バックエンドのシンプル化: View固有のAPIエンドポイントを用意する必要がなくなり、API設計がシンプルに

特に、TanStack Queryと比較した際のコードの簡潔さや、複雑な検索条件やソートを考慮した楽観的更新の実装の容易さは、実際にコードを書いてみると体感できると思います。
※ ただし、楽観的更新がない場合は、TanStack Queryでも十分と私は考えています。

本記事が、TanStack DBを使ったローカルファーストアプリケーション開発の一助となれば幸いです。
実際にコードを動かしてみて、TanStack DBのパフォーマンスや開発体験を体感していただければと思います!

最後まで読んでいただきありがとうございました!

参考文献

https://tanstack.com/db/latest

https://tanstack.com/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query

https://immerjs.github.io/immer/

https://zenn.dev/r1013t/articles/396a10c6025205

https://github.com/TanStack/db/issues/1016

https://github.com/TanStack/db/pull/709

https://tanstack.com/start/latest

https://turso.tech/

https://orm.drizzle.team/

https://elysiajs.com/

https://x.com/saltyAom/status/1991564346535948630

https://github.com/sc30gsw/tanstack-start-todo-app

Discussion