はじめに
React Router v7 は、単なるルーティングライブラリではありません。
v7 では Remix のデータAPIやフルスタック機能が React Router 本体へ取り込まれ、Framework Mode が強化されました。
ただ最初に触ると、
loaderとactionの違いFormを使う理由- どこでDBアクセスするのか
あたりが分かりづらいです。
なので今回は、「React Router v7 + Prisma + SQLite でTodoアプリを作りながら使い方を学ぶ」記事を書きます。
完成する機能:
- Todo 一覧表示
- Todo 追加
- Todo 削除
- Prisma による DB 操作
loader/actionの理解- DB 操作を
app/models/todo.server.tsに分離する設計
までやります。
使用技術
今回使う構成はこちら。
- React Router v7
- Vite
- TypeScript
- Prisma
- SQLite
- TailwindCSS
個人開発でかなり使いやすい構成です。
手順
プロジェクト作成
まずは React Router v7 プロジェクトをtodo-app-rrという名前で作成。
npx create-react-router@latest todo-app-rr移動:
cd todo-app-rr
npm install起動:
npm run devhttp://localhost:5173/を開くとReact Routerの初期画面が開かれます。
Prisma を導入
次に ORM を追加。今回はSQLite で使用します。
SQLite はセットアップが非常に楽なので、学習用途や個人開発にかなり向いています。
npm install prisma @types/node @types/better-sqlite3 -D
npm install @prisma/client @prisma/adapter-better-sqlite3 dotenv
npx prisma init --datasource-provider sqliteコマンドを実行すると
- prisma/schema.prisma
- .env
- prisma.config.ts
が生成されます。
prisma.config.ts は Prisma の設定ファイルで今回は特にいじりません。
Prisma Schema を作成
schema.prismaはデータベースのスキーマを定義するファイルです。
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "sqlite"
}
model Todo {
id Int @id @default(autoincrement())
text String
createdAt DateTime @default(now())
}マイグレーション
npx prisma migrate dev --name init
npx prisma generateこれで SQLite DB が作られ、Prisma Client が生成されます。
Prisma Client のインスタンス化
import "dotenv/config";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../../generated/prisma/client";
const adapter = new PrismaBetterSqlite3({
url: process.env.DATABASE_URL!,
});
const globalForPrisma = global as unknown as {
prisma: PrismaClient;
};
const prisma =
globalForPrisma.prisma ||
new PrismaClient({
adapter,
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;
ルーティング設定について
React Router v7 の Framework Mode では、app/routes.ts でルーティングを定義します。
create-react-router で作成した直後の状態では、routes/home.tsx が index route (/) に設定されています。
import { type RouteConfig, index } from "@react-router/dev/routes";
export default [index("routes/home.tsx")] satisfies RouteConfig;そのため、app/routes/home.tsx を編集すると / にアクセスした際の画面として表示されます。
新しいページを追加したい場合は、app/routes.ts にルートを追加していきます。
DB 操作を models に分離する
ここが今回の記事で特に大事なポイントです。
最初は home.tsx に全部書いてしまいがちですが、実際のプロジェクトでは DB 操作の関数を app/models/todo.server.ts に切り出すのがベストプラクティスです。
なぜ分離するのか?
loader や action は「どのデータを取ってくるか・何を実行するか」のオーケストレーションに集中させるべきです。DB アクセスの詳細(クエリや引数)をルートファイルに直接書いてしまうと、ルートが複数になったときに同じクエリが散らかり、変更にも弱くなります。
app/models/ にまとめると:
- 再利用しやすい(複数のルートから呼べる)
- テストが書きやすい
- ルートファイルが読みやすくなる
.server.ts という拡張子は「このファイルはサーバー専用」というシグナルで、クライアントバンドルに混入しないよう React Router / Vite が処理してくれます。
app/models/todo.server.ts を作成
import prisma from "~/lib/prisma.server";
export async function getTodos() {
return prisma.todo.findMany({
orderBy: { createdAt: "desc" },
});
}
export async function createTodo(text: string) {
return prisma.todo.create({
data: { text },
});
}
export async function deleteTodo(id: number) {
return prisma.todo.delete({
where: { id },
});
}Prisma を直接触るのはこのファイルだけ。ルートファイルはこれらの関数を呼ぶだけになります。
routes/home.tsx を作成
welcomeディレクトリ を丸ごと削除してから、app/routes/home.tsx を以下のように作成します。
import { Form } from "react-router";
import type { Route } from "./+types/home";
import { getTodos, createTodo, deleteTodo } from "../models/todo.server";
export async function loader() {
return getTodos();
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
if (request.method === "DELETE") {
const id = Number(formData.get("id"));
await deleteTodo(id);
return null;
}
const text = formData.get("text");
if (!text || typeof text !== "string") return null;
return createTodo(text);
}
export default function Home({ loaderData }: Route.ComponentProps) {
return (
<main className="max-w-lg mx-auto mt-16 px-4">
<h1 className="text-2xl font-semibold mb-6">Todos</h1>
<ul className="flex flex-col gap-2 mb-4">
{loaderData.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between gap-3 px-4 py-3 border border-gray-200 rounded-lg"
>
<span className="text-sm text-gray-800">{todo.text}</span>
<Form method="delete">
<input type="hidden" name="id" value={todo.id} />
<button
type="submit"
aria-label="削除"
className="p-1 text-gray-300 hover:text-red-400 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</Form>
</li>
))}
</ul>
<Form method="post" className="flex gap-2 mt-4">
<input
type="text"
name="text"
className="flex-1 h-9 px-3 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-300 placeholder:text-gray-400"
placeholder="Todoを入力"
/>
<button
type="submit"
className="h-9 px-4 text-sm font-medium bg-black text-white rounded-lg hover:opacity-80 transition-opacity"
>
追加
</button>
</Form>
</main>
);
}loader と action がすっきりしているのが分かると思います。DB アクセスの詳細は todo.server.ts 側が全部持っています。
仕組みのまとめ
<Form> を使うと、送信時に同じルートの action が自動で呼ばれます。
Form を submit
↓
action 実行(DB への書き込み・削除)
↓
loader 再実行(最新の Todo 一覧を取得)
↓
UI 更新これが v7 の気持ちいいところです。useEffect や useState、fetch を大量に書かなくても、loader / action / Form だけで綺麗に整理できます。
ディレクトリ構成
最終的なディレクトリ構成はこうなります。
app/
├── lib/
│ └── prisma.server.ts # Prisma Client のインスタンス
├── models/
│ └── todo.server.ts # DB 操作関数(getTodos / createTodo / deleteTodo)
└── routes/
└── home.tsx # loader / action / UIlib/ は汎用的なユーティリティ、models/ はエンティティごとの DB 操作、routes/ はルートのオーケストレーションと UI、という役割分担になります。
React Router v7 の思想
v7 は「URL 中心にデータと UI を管理する」思想がかなり強いです。また今回のように models/ を分離することで、React Router の関心(ルーティング・データフロー)と Prisma の関心(DB アクセス)をきれいに切り分けることができます。
おわりに
Todo アプリ程度でも、
loader/action/Form- Prisma による DB 操作
app/models/へのロジック分離
を組み合わせることで、React Router v7 の思想と実務的な設計パターンが一気に理解できます。
なお、今回は削除の分岐を request.method で行いましたが、intent という hidden フィールドで分岐するパターンもよく使われます。プロジェクトの規模や好みで選ぶとよいでしょう。
気軽にフルスタックに構築できて、ちょっとしたものを作るのに React Router v7 はかなりちょうどいい選択肢だと思います。