【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向けのリアクティブクライアントストアです。高速でモダンなアプリ構築の課題を解決し、以下のことを実現します。
- データを正規化されたコレクションにロードすることで、エンドポイントの無秩序な広がりやネットワークのウォーターフォールを回避します。
- ミリ秒未満のライブクエリとリアルタイムの反応性でクライアントのパフォーマンスを最適化します。
- 即時の楽観的書き込みにより、ネットワークをインタラクションパスから外す
ドキュメントでは上記のような説明がなされていますが、一言で言うと、クライアント側で動作するデータベースです。
TanStack DBが実現すること
前述の通り、TanStack DBを使えばローカルでのクエリが実行可能となるため、以下のようなことが簡単に実現できます。
- リアルタイムの反応性の獲得
- 楽観的更新
- コンポーネントに応じたデータfetch
つまり、ドキュメントで言及されているような悪手を打たなくて済むようになります。
- View 固有のAPIによるバックエンドの複雑化・エンドポイントの拡散・ウォーターフォールにつながるリスクの上昇
ToDoアプリで言えば、「完了済みタスク一覧画面」「プロジェクト別タスク一覧画面」「優先度の高いタスク一覧画面」のそれぞれにAPIを用意する必要がなくなります。
- すべてのロードしてフィルタリングする
こちらは、バックエンドはシンプルですが、クライアントのパフォーマンスは低下します。
しかし、TanStack DBを使用することで、これらの問題を解決し、ローカルファーストなアプリケーションを簡単に構築することができます。
TanStack DBの仕組み
先程、TanStack DBは「バックエンドをシンプルに保ちつつ、クライアント側では高速なデータ操作をする」ということを言及しました。
これは以下の3つの仕組みが備わっているためできることです。
1. コレクションでデータを管理
TanStack DBでは一度取得した、データをコレクションに保持します。
これにより、クライアントからの問い合わせを高速化します。
また、コレクションは重複排除と整合性を自動で管理します。
前提としてコレクションは型付きのオブジェクトの集合です。
これは同じタイプのデータをIDベースで管理する正規化されたデータセットであることを意味しています。
簡単に言うと、バックエンドと同じような形式で整理されているということです。
そのため、データが書き換わった場合にもコレクション内部でデータは一元管理されるため、UIの再レンダリングも一貫したものとなります。
2. クエリ駆動型同期
TanStack DBはv0.5以降、query-driven-sync(クエリ駆動型同期)をサポートしています。
そのため、クエリに基づいて必要な部分だけを更新することができます。
TanStack DBは、差分データフロー(変更された部分のみを再計算する技術)を活用した、API向けのクライアントファーストストアです。ToDoを完了すると、最新のラップトップであれば、メモリに10万行以上あっても、DBは1ミリ秒未満でクエリ結果を更新します。
また、この同期機能には3つのモード(Eager・on-demand・progressive)があるので、ユースケースに合わせてクエリを実装することも可能です。
これにより、データ再読み込み・ネットワーク呼び出し・再レンダリングの最適化を自動化することができます。
3. 楽観的更新が前提(ミリ秒未満のライブクエリ)
TanStack DBは楽観的更新が前提になっています。
これはLive Queriesという機能によるもので、TanStack DBはクエリ結果をリアクティブに扱います。
クエリ駆動型同期に通じる部分がありますが、これは内部的には、differential dataflow(差分フロー)という「変更された部分のみを再計算する技術」によるものです。
メモリに10万行以上あっても、DBは1ミリ秒未満でクエリ結果を更新します。
だからこそ、ミリ秒未満で即座に結果を返すことができるのです。
ここまででTanStack DBが何なのかが理解できたのでは、と思います。
次の項から、実際にToDoアプリを作っていきましょう。
TanStack DBについて、より詳しく知りたい方は以下の2記事を参照すると良さそうです。
こちらでは、TanStack Queryとの比較もわかりやすく記載されているので、非常におすすめです。
ToDoアプリ開発
今回、作成したのはCRUDができる簡易的なToDoアプリです。
以下の順で解説していきます
- 技術スタック
- 環境構築
- Drizzle ORMの実装
- ElysiaによるAPI実装
- 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で最初の環境構築を行います。
上記が完了したら、必要なパッケージを導入します。
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を利用するため、こちらの公式ドキュメントに従い実装していきます。
※ ドキュメントではTurso CLIを使用していますが、Turso ダッシュボード上でも準備は可能です。
ドキュメントに従い、Turso DBのURLとtokenを取得まで行った後に、Drizzleの各種やSchema定義を実装します。
まず、Drizzle ORMとDB接続設定を実装します。
Drizzle ORMとDB接続設定
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のみ定義していきます。
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
以下のように実装します。
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をサポートしているなど、今勢いのあるフレームワークです。
それでは早速、APIの実装をしていきます。
なお、前提としてフォルダ構成などはベストプラクティスに則るようにしています。
TanStack Startと統合
まずは、以下のドキュメントを参照し、TanStack Startと統合していきます。
以下のように実装します。
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との統合
こちらも公式ドキュメントに従って実装していきます。
ここでは、Elysia用にテーブルSchemaをTypeBoxのモデルに変換してバリデーションができるようにします。
また、Elysiaのベストプラクティスに従い、モデルをグループ化するようにします。
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つです。
-
namespaceの使用 -
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スキーマの再利用としてcreateSelectSchemaとcreateInsertSchemaを使用して、Drizzleのスキーマ定義をElysiaのバリデーションスキーマに変換しています。
そして、エラーレスポンスもModelに含めることで、APIのレスポンス型を一元管理できるようにしています。
これでDrizzle ORMとの統合は完了です。
次に、エラーハンドリングのためCustom Errorを定義していきます。
Custom Errorの定義
こちらもElysiaの公式ドキュメントを参照し、定義していきます。
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としてビジネスロジックを定義します。
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 classとstaticを使用してクラスのインスタンス割り当てを避ける
ドキュメントでは上記が推奨されているので、Elysiaのベストプラクティスに従い、abstract class と static メソッドを使用してビジネスロジックを定義するようにしています。
次に、Controller層の実装をしていきます。
Controller層の実装
Controller層は以下のように定義します。
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を使用することでハンドリングすることができます。
最後に、ここまでで実装した内容をElysiaのメインAPIファイルに統合して、APIの作成は完了です
Elysia メインAPIファイルにPluginを統合
これまでの実装をElysia APIに以下のように統合します。
Controller層・Service層・Model層と分離したため、メインのAPIファイルはシンプルになります。
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に渡すだけで統合が可能となります。
以上で、ElysiaによるAPIの実装が完了です。
TanStack DBの実装
TanStack DBの実装は3つの手順で構成されます。
- Collectionの定義
- Live Queriesを使用してCollectionからデータをクエリする
- transaction mutatorsによる実装を行う(楽観的更新の実装)
まずは、Collectionの定義から行います。
Collectionの定義
今回はTanStack Queryを使用するため、以下のドキュメントのサンプルコードを参考に実装を進めます。
まず、TanStack DBのCollectionで使用するスキーマを定義します。
TanStack DBではValibotを使用するため、drizzle-valibotを使ってDrizzleスキーマからValibotスキーマを生成します。
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の定義を行います。
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()
},
}),
)
queryFn・onInsert・onUpdate・onDeleteでは、Elysiaで定義したAPIをRPCで呼び出します。
また、Mutation(queryFn以外)の処理では、transactionを受け取ることができ、ここに実行中のMutation(変更)の情報が格納されています。
具体的には以下のような型として情報が提供されます。
{
mutations: [
{
original: T, // 変更前のアイテム(更新・削除時)
modified: T, // 変更後のアイテム(挿入・更新時)
changes: Partial<T>, // 変更されたフィールドのみ
key: TKey // アイテムのキー
},
// ... 複数の変更がある場合
]
}
これでCollectionの定義が完了しました。
続いて、Live Queriesの実装を行います。
Live Queriesの実装
今回、クエリパラメータを用いて検索処理も実装したいので、まずはクエリパラメータのSchemaから定義します。
クエリパラメータのSchema定義
以下のようにTodoの「テキスト」「ステータス」「作成日付かテキストのSort種別」「Sort順」の情報を持つSchemaを定義します。
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の実装
以下のように作成します。
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をサポートしているものになります。
また、依存配列にはクエリパラメータの値を渡すことで、クエリパラメータが変更時にクエリの再実行をトリガーすることができます。
さらに、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)) )
UI統合
実際に、先程作成したhooksをコンポーネント側で呼び出すと以下のようになります。
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による実装(楽観的更新の実装)
それでは、Insert・Update・Deleteの各処理について実装を進めていきましょう。
Insert
まずはInsert処理ですが、全体は以下のようになっております。
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と統合しています。
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を表示するというものです。
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と同じく以下のuseFormのonSubmitで、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は独自実装なんだな〜」くらいの認識で問題ないです。
では、そもそもImmerとは何なのかですが、Immerは、イミュータブルな更新を簡潔に書くためのライブラリです。
通常はスプレッド構文などで新しいオブジェクトを作りますが、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の実装は完了です。
細かな部分は解説やコードを割愛しているので、ソースコードの全容は以下のリポジトリから参照していただけると幸いです。
注意点
TanStack DBについては、現状、以下のIssue・PRがでている状態で、TanStack DBをSSRやRSCサポートされているフレームワークで使用するとMissing getServerSnapshotというエラーがでます。
そのため、現状では対症療法として、TanStack DBのクエリが実行されるコンポーネントについては確実にクライアント側で実行されるようにしておく必要があります。
※ SSR・RSCサポートされていないフレームワークやそもそもエラーなどの問題がない場合は対応不要です
Missing getServerSnapshot エラーの対応
以下のようにuseEffectでコンポーネントがマウントされるまでfallbackを返却するようにし、クライアント側で確実にTanStack DBのクエリが行われるようにします。
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の実行がされる構成としています。
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に反映される
-
楽観的更新の簡潔な実装:
insert、update、deleteといったシンプルなAPIで、複雑な楽観的更新を自動化 - クライアント側での高速なクエリ: フィルタリングやソートをネットワークリクエストなしで実現
- バックエンドのシンプル化: View固有のAPIエンドポイントを用意する必要がなくなり、API設計がシンプルに
特に、TanStack Queryと比較した際のコードの簡潔さや、複雑な検索条件やソートを考慮した楽観的更新の実装の容易さは、実際にコードを書いてみると体感できると思います。
※ ただし、楽観的更新がない場合は、TanStack Queryでも十分と私は考えています。
本記事が、TanStack DBを使ったローカルファーストアプリケーション開発の一助となれば幸いです。
実際にコードを動かしてみて、TanStack DBのパフォーマンスや開発体験を体感していただければと思います!
最後まで読んでいただきありがとうございました!
参考文献
Discussion