やまどり
React Router v7 を Todoアプリ開発で学ぶ|loader / action / Form の基本

React Router v7 を Todoアプリ開発で学ぶ|loader / action / Form の基本

React Router v7 の基本を Todoアプリ開発で解説。Prisma と SQLite を使いながら loader・action・Form の使い方を理解します。

はじめに

React Router v7 は、単なるルーティングライブラリではありません。

v7 では Remix のデータAPIやフルスタック機能が React Router 本体へ取り込まれ、Framework Mode が強化されました。

ただ最初に触ると、

  • loaderaction の違い
  • 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という名前で作成。

bash
npx create-react-router@latest todo-app-rr

移動:

bash
cd todo-app-rr
npm install

起動:

bash
npm run dev

http://localhost:5173/を開くとReact Routerの初期画面が開かれます。

Prisma を導入

次に ORM を追加。今回はSQLite で使用します。
SQLite はセットアップが非常に楽なので、学習用途や個人開発にかなり向いています。

bash
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はデータベースのスキーマを定義するファイルです。

prisma/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())
}

マイグレーション

bash
npx prisma migrate dev --name init
npx prisma generate

これで SQLite DB が作られ、Prisma Client が生成されます。

Prisma Client のインスタンス化

app/lib/prisma.server.ts
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 (/) に設定されています。

app/routes.ts
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 に切り出すのがベストプラクティスです。

なぜ分離するのか?

loaderaction は「どのデータを取ってくるか・何を実行するか」のオーケストレーションに集中させるべきです。DB アクセスの詳細(クエリや引数)をルートファイルに直接書いてしまうと、ルートが複数になったときに同じクエリが散らかり、変更にも弱くなります。

app/models/ にまとめると:

  • 再利用しやすい(複数のルートから呼べる)
  • テストが書きやすい
  • ルートファイルが読みやすくなる

.server.ts という拡張子は「このファイルはサーバー専用」というシグナルで、クライアントバンドルに混入しないよう React Router / Vite が処理してくれます。

app/models/todo.server.ts を作成

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 を以下のように作成します。

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>
  );
}

loaderaction がすっきりしているのが分かると思います。DB アクセスの詳細は todo.server.ts 側が全部持っています。

仕組みのまとめ

<Form> を使うと、送信時に同じルートの action が自動で呼ばれます。

Form を submit
  ↓
action 実行(DB への書き込み・削除)
  ↓
loader 再実行(最新の Todo 一覧を取得)
  ↓
UI 更新

これが v7 の気持ちいいところです。useEffectuseStatefetch を大量に書かなくても、loader / action / Form だけで綺麗に整理できます。

ディレクトリ構成

最終的なディレクトリ構成はこうなります。

app/
├── lib/
│   └── prisma.server.ts   # Prisma Client のインスタンス
├── models/
│   └── todo.server.ts     # DB 操作関数(getTodos / createTodo / deleteTodo)
└── routes/
    └── home.tsx           # loader / action / UI

lib/ は汎用的なユーティリティ、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 はかなりちょうどいい選択肢だと思います。