TanStack Start と Drizzle ORM で構築する Full-Stack TypeScript
概要
Web アプリにおいて、フロントエンドとバックエンドを TypeScript で統一することができる技術スタックは、一般的に Full-Stack TypeScript と呼ばれています。
直近でも、T3 Turbo や Hono の記事が注目を集めていました。
最近 β 版になったばかりの TanStack Start と 、TypeScript で型安全にデータベースを操作できる Drizzle ORM を組み合わせると、Full-Stack TypeScript アプリケーションを簡単に実現することができます。
自分はこの技術スタックを TanStack Drizzle と呼んでいます!
誰でも気軽に使えるように、プロジェクトの雛形に使えるリポジトリを作成しました。
今回の記事では、このリポジトリを元に解説していきます。
セットアップ手順
先ほど紹介したリポジトリ、lef237/tanstack-drizzleを clone します。
gh repo clone lef237/tanstack-drizzle
あとは移動して npm install
をしたら下準備は完了です。
cd tanstack-drizzle
npm install
今回は PostgreSQL を選択します。
事前にデータベースを用意する必要があります。こちらのドキュメント通りに進めれば、Docker で簡単に用意することができます(Docker Desktop が必要です)。
データベースを作成したら、次はプロジェクトのルートに.env
ファイルを置きます。
DATABASE_URL=postgres://postgres:mypassword@localhost:5432/postgres
こんな感じで設定できていれば OK!
次に、Database がきちんと動くかどうかをテストします。
npx drizzle-kit push
問題なく DB と接続できれば、[✓] Changes applied
という表示が出るはずです。
Drizzle 経由で DB を操作するテストコードも動かしてみましょう。
npx tsx app/utils/test.ts
次の結果が console.log として出力されます。
inserted [ { id: 1, title: 'First Todo', completed: false } ]
results [ { id: 1, title: 'First Todo', completed: false } ]
updated [ { id: 1, title: 'First Todo', completed: true } ]
deleted [ { id: 1, title: 'First Todo', completed: true } ]
ここまで来れば、準備は完了です。🤩
次のコードを実行して、TanStack Start を起動しましょう!
npm run dev
http://localhost:3000/
を開くと、次の画面が表示されると思います。
上のヘッダーにある「Todos」をクリックしてみてください。
ここで、TanStack Start の Server Functions, Drizzle ORM による DB 操作を使った CRUD 操作を試すことができます。
一覧・追加画面
編集・削除画面
ここからは実際のコードを説明していきます。
Drizzle ORM
Drizzle ORM の魅力は色々あります。型安全性はもちろん、Edge 環境でも動くことや、DSL を必要としないこと(TypeScript で書けること)。個人的に TS の中ではイチオシの ORM です。
Drizzle の魅力については上記の Fireship の動画で解説できたものとして、実際にコードを解説します。
db/schema.ts
以下のコードでスキーマを定義しています。
import { pgTable, serial, text, boolean } from "drizzle-orm/pg-core";
export const todosTable = pgTable("todos", {
id: serial("id").primaryKey(),
title: text("title").notNull(),
completed: boolean("completed").notNull().default(false),
});
今回は id
を SERIAL 型の主キーに設定し、Todoの title
には NOT NULL制約をつけています。completed
は完了・未完了のために定義しています。export
することで、型を共有しています。
TypeScript で書くことができ、コード補完も効きやすいです。
db/drizzleConnect.ts
DB を Drizzle に繋げているコードです。
import { drizzle } from "drizzle-orm/node-postgres";
export const db = drizzle(process.env.DATABASE_URL!);
今回は PostgreSQL を選択したため、node-postgres
になっています。
もし、MySQL や SQLite など、他の DB を使う場合は、公式のドキュメントを参考に適宜書き換えてください。
drizzle.config.ts
設定ファイルも TypeScript で書きます。
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./app/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
先ほどnpx drizzle-kit push
というコマンドでマイグレーションをおこないました。この drizzle-kit を使うために必要なファイルとなります。
dialect
には現在postgresql
が指定されています。もし MySQL を使う場合はmysql
, SQLite の場合はsqlite
になります。
app/utils/test.ts
先ほど動作確認のために、このコマンド(npx tsx app/utils/test.ts
)を実行しました。
テストコードの中身を簡単に紹介して、Drizzle の動きを説明します。
以下ではINSERT
, SELECT
, UPDATE
, DELETE
の一連の処理をおこなっています。
import "dotenv/config";
import { eq } from "drizzle-orm";
import { todosTable } from "~/db/schema";
import { db } from "~/db/drizzleConnect";
async function main() {
// INSERT
const inserted = await db
.insert(todosTable)
.values({ title: "First Todo" })
.returning();
console.log("inserted", inserted);
// SELECT
const results = await db.select().from(todosTable);
console.log("results", results);
// UPDATE
const updated = await db
.update(todosTable)
.set({ completed: true })
.where(eq(todosTable.id, inserted[0].id))
.returning();
console.log("updated", updated);
// DELETE
const deleted = await db
.delete(todosTable)
.where(eq(todosTable.id, inserted[0].id))
.returning();
console.log("deleted", deleted);
}
main();
SQL が得意な方ならスラスラと読める作りになっています。
PostgreSQL のRETURNING
句に対応する.returning()
を使うこともできます。
eq()
はWHERE
句で等価比較=
を行う関数です。もちろん、<>
やEXISTS
, BETWEEN
, LIKE
なども関数として定義されています。
公式ドキュメントの以下のページに詳しく書かれています。
IDE でこのファイルを開き、変数に対してカーソルを当てると、型が表示されるのを確認できます。
次に、Server Functions を使って、これらの型情報をバックエンドとフロントエンドで共有できるようにしていきましょう。
TanStack Start とは
Server Functions の話をする前に、まずは TanStack Start について簡単に説明します。
上記のページのキャッチコピーにはこのように書かれています。
Full-stack React framework powered by TanStack Router
「TanStack Router の機能に、SSR や API Routes などサーバーの機能を追加したもの!」と捉えると分かりやすいです。
そして、この TanStack Start の一番の目玉が Server Functions (RPCs) となっています。
Server Functions の何が嬉しいか?
Server Functions を利用すると、クライアント側からでもサーバー側からでも型安全に関数を呼び出すことができます。
この関数はサーバー側で実行されます。つまり、これらの関数で DB 操作をすれば、API Routes を利用せずにフロントエンドとバックエンドを疎通できます。
ここまでの説明だと React Server Functions のことだと思いそうですが、公式ドキュメントをよく読むと違いました。TanStack Start の Server Functions は特定のフレームワークに縛られないつくりになっているそうです。つまり、(将来的には)React 以外のフレームワークにも適用できるはずです。
更に、標準的な HTTP リクエストをベースに実装しているため、"serial-execution bottlenecks"(シリアル実行のボトルネック)に悩まされることなく、好きなだけ呼び出せるそうです。
サーバー側のコード
まずは、Server Functions を定義する、サーバー側のコードを見てみましょう。
このコードは基本的にどこに置いてもよく、公式のサンプルコードではutils/
に置かれています。
Server Functions のみを定義したファイルを置く場所を作るため、functions/
ディレクトリを作成します。
app/functions/todoServer.ts
Server Functions が置かれている核となるファイルです。
テーブル名に対応して、todoServer.ts
と名付けています。
実際にコードを見てみましょう。
import { createServerFn } from "@tanstack/start";
import { todosTable } from "~/db/schema";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "~/db/drizzleConnect";
export const getTodos = createServerFn({ method: "GET" }).handler(async () => {
return db.select().from(todosTable);
});
export const createTodo = createServerFn({ method: "POST" })
.validator(z.object({ title: z.string().min(1) }))
.handler(async ({ data }) => {
const [inserted] = await db
.insert(todosTable)
.values({ title: data.title })
.returning();
return inserted;
});
export const getTodoById = createServerFn({ method: "GET" })
.validator(z.object({ id: z.number() }))
.handler(async ({ data }) => {
const [todo] = await db
.select()
.from(todosTable)
.where(eq(todosTable.id, data.id));
if (!todo) {
throw new Error(`Todo not found: id=${data.id}`);
}
return todo;
});
export const updateTodo = createServerFn({ method: "POST" })
.validator(
z.object({
id: z.number(),
title: z.string().optional(),
completed: z.boolean().optional(),
})
)
.handler(async ({ data }) => {
const [updated] = await db
.update(todosTable)
.set({
title: data.title,
completed: data.completed,
})
.where(eq(todosTable.id, data.id))
.returning();
if (!updated) {
throw new Error(`Todo not found: id=${data.id}`);
}
return updated;
});
export const deleteTodo = createServerFn({ method: "POST" })
.validator(z.object({ id: z.number() }))
.handler(async ({ data }) => {
const [deleted] = await db
.delete(todosTable)
.where(eq(todosTable.id, data.id))
.returning();
if (!deleted) {
throw new Error(`Todo not found: id=${data.id}`);
}
return { success: true };
});
5つの関数が定義されていることが分かると思います。それぞれの対応関係は次のようになっています。
- getTodos
- Todo 一覧を取得する
- createTodo
- 新しい Todo を追加する
- getTodoById
- Id を元に対象の Todo を取得する
- updateTodo
- Todo を更新する
- deleteTodo
- Todo を削除する
すべてを紹介すると大変なので、getTodos
とcreateTodo
に絞って解説します。
getTodos
export const getTodos = createServerFn({ method: "GET" }).handler(async () => {
return db.select().from(todosTable);
});
まずはcreateServerFn
です。ここに HTTP リクエストメソッドを書きます。今回は Todo を取得するので GET を指定します。
そして、次に.handler
があります。ここで、非同期関数として実際に処理(ハンドル)するコードを書きます。
今回は Drizzle を使って、DB からtodosTable
の全データを取得しています。
おまけ:この時点での getTodos の型
getTodos の型はこのようになっています。関数の戻り値の型が埋め込まれているのが分かります。
const getTodos: OptionalFetcher<
undefined,
undefined,
{
id: number;
title: string;
completed: boolean;
}[]
>;
createTodo
次に、新しい Todo を作成する関数です。
export const createTodo = createServerFn({ method: "POST" })
.validator(z.object({ title: z.string().min(1) }))
.handler(async ({ data }) => {
const [inserted] = await db
.insert(todosTable)
.values({ title: data.title })
.returning();
return inserted;
});
先程のgetTodos
と異なり、POST を使っています。
補足:Method の型
※現在は GET と POST のみに対応しているようです。
export type Method = 'GET' | 'POST';
次に登場するのが.validator
です。ここで受け取ったデータのバリデーションをすることができます。
The return type of the validator function will be the input to the server function's handler.
上記のコードでは Zod を使ってバリデーションしています。
z.object({ title: z.string().min(1) });
このコードの場合、データには「1 文字以上の文字列」が必要となります。
(Zod 以外のバリデーターを使うことも可能です!)
そして、バリデーションしたコードを使って、DB へと処理をおこないます。
.handler(async ({ data }) => {
const [inserted] = await db
.insert(todosTable)
.values({ title: data.title })
.returning();
return inserted;
});
data
の title
を DB へと INSERT しています。
また、Server Functions は関数なので、値を返すことができます。この関数ではinserted
を呼び出し側に返しています。
もし、結果を返さなくて良いときは、もっとシンプルに次のように書けます!
.handler(async ({ data }) => {
await db.insert(todosTable).values({ title: data.title });
});
クライアント側のコード
次に Server Functions を呼び出す側の、クライアントサイドのコードを見てましょう。
一覧表示
app/routes/todos/index.tsx
というファイルがあります。これは、Todo 一覧を表示するページです。
TanStack Start はDirectory Routesに対応しています(Flat Routesにすることも可能です)。
そのため、app/routes/
以下に階層構造を作れば、それが URL に対応します。app/routes/todos/index.tsx
の場合は/todos
にアクセスすると読み込めます。
それでは具体的なコードを見ていきましょう。
import { createFileRoute } from "@tanstack/react-router";
import { getTodos, createTodo } from "~/functions/todoServer";
import { useRouter } from "@tanstack/react-router";
export const Route = createFileRoute("/todos/")({
loader: async () => {
const todos = await getTodos();
return { todos };
},
component: TodosIndex,
});
function TodosIndex() {
const { todos } = Route.useLoaderData();
const router = useRouter();
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
const title = formData.get("title");
if (typeof title !== "string" || !title.trim()) {
alert("Title is required!");
return;
}
try {
await createTodo({
data: { title: title },
});
form.reset();
router.invalidate();
} catch (err) {
alert(String(err));
}
}
return (
<div style={{ padding: "1rem" }}>
<h1>Todo List</h1>
<form onSubmit={handleCreate}>
<input type="text" name="title" placeholder="New Todo" />
<button type="submit">Create</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<a href={`/todos/${todo.id}`}>
{todo.completed ? <s>{todo.title}</s> : todo.title}
</a>
</li>
))}
</ul>
</div>
);
}
まず、createFileRoute()
を使って、/todos
へとアクセスされたときの挙動を制御します。
ポイントはリンク先の次の部分です。
- Critical data fetching is coordinated from a Route's loader
つまり、createFileRoute()
の中でloader
として関数を読み込み、そこから取得したデータを元に画面へと表示します。
ここで、Server Functions のgetTodos
を読み込みます。
loader: async () => {
const todos = await getTodos();
return { todos };
},
todos
の型を見てみましょう。
Drizzle で定義した型が無事に取得されています!
createFileRoute()
の続きを読むと、component に、TodosIndex
を渡しています。
component: TodosIndex,
これはすぐ直下にある TodosIndex
コンポーネントを示しています。
function TodosIndex() {
const { todos } = Route.useLoaderData();
// 略
そして Route.useLoaderData()
を使って todos
を取得し、それを画面上に表示しています。
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<a href={`/todos/${todo.id}`}>
{todo.completed ? <s>{todo.title}</s> : todo.title}
</a>
</li>
))}
</ul>
表示部分のロジックとしては、完了しているかどうかで、タイトルに抹線を引くかどうかを変えています。
Todo の作成
次に新しい Todo を作成する、Create ボタンの動きを追いかけましょう。
まず onSubmit
で handleCreate
を呼び出しています。
<form onSubmit={handleCreate}>
<input type="text" name="title" placeholder="New Todo" />
<button type="submit">Create</button>
</form>
handleCreate
を見てみましょう。
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
const title = formData.get("title");
if (typeof title !== "string" || !title.trim()) {
alert("Title is required!");
return;
}
try {
await createTodo({
data: { title: title },
});
form.reset();
router.invalidate();
} catch (err) {
alert(String(err));
}
}
画面が勝手にリロードしないように preventDefault
をして、form
要素を取得、FormData
オブジェクトを作成します(FormData
オブジェクトによって、値を取り出しやすくなります)。
その次の if 分では form
の title
が String 型であり、文字が入力されていることを念の為確認しています( Narrowing をしています)。
そして、try~catch 文の中でcreateTodo()
を呼び出しています。
ここで data
として { title: title }
を渡します。ここで、もし title
が文字列以外になっていると、createServerFn
で定義した.validator
に違反することになるため、型エラーが表示されます。
例えば、先程の if 文がないと次のような型エラーが表示されます。
型安全にデータを渡せることを確認できました!
あとは、createTodo()
を実行して、新しいレコードを DB 上に追加します。
最後にrouter.invalidate()
をしています。これが地味に大事で、TanStack Router の invalidate()
メソッドを呼び出し、このルートに紐づく loader
を再実行しています。
これによって、再度 getTodos()
などのデータ取得が走り、最新の Todo リストへと画面が更新されます。
(もしこの invalidate()
を使っていないと、画面を再読み込みする必要があり、不便になってしまいます。)
まとめ
公式ドキュメントやソースコードを読みつつ、調査しながらまとめました。🔍️
TanStack Start と Drizzle ORM の組み合わせの魅力について解説できたと思います。
夜なべして書いたので(?)、もしかしたら記事の内容に誤りがあるかもしれません。お気軽にコメント等でお知らせください!
リポジトリも公開しているので、お気軽にご活用ください 💖
Discussion