😎

react router v7のレポート actionについて

に公開

私個人としては、シンプルでフルスタックなWeb開発が好きです。そのため、高機能ながらも少し複雑なNext.jsよりも、React Router v7が広く普及してほしいと考えています。

React Router v7について

React Router v7は、これまで利用してきたReact RouterとRemixを統合したバージョンです。従来の馴染みのあるReact RouterでSSR(サーバーサイドレンダリング)が利用できるようになりました。
そのため、今までReactを使ってきて、Next.jsは使いたくないけれどSSRを試してみたい方にとって、非常に良い選択肢となります。

触ってみる

1. 環境構築

npx create-react-router@latest

npmを利用するかどうかを聞かれるので、yarnなど他のパッケージマネージャーを使いたい場合は、dlxなどのコマンドで対応してください。
このコマンドを入力した時点での気づき:

  1. Tailwind v4がデフォルトで組み込まれている
    Tailwindを利用していないプロジェクトでは、これを削除する必要があります。また、Tailwind v4からはtailwind.config.jsがなくなっているので、新しい仕様をキャッチアップしましょう。
  2. app/route/home.tsxの型定義
    homeを開くと以下のようなインポートがあります:
import type { Route } from "./+types/home";

初めの段階ではこのファイルが型エラーを起こしていますが、

npm run dev

開発サーバーを起動すると、型エラーが解消されます。これはReact Routerが提供するパスの型安全性を確保するために必要なファイルです。
詳細は以下のリンクで確認できます:
https://reactrouter.com/explanation/type-safety

簡単に説明すると、Routerで設定したパラメーターなどの型情報を.react-router/配下に格納してくれており、その情報からパラメーターの型を取得します。

開発サーバーが起動されました
画面を見てみます

問題なさそうですね

action

actionとはサーバー側でデータ変更やフォーム送信を処理するための直接的で安全な方法を提供します。

試しにconform+zodを利用して簡易的なバリデーションを取り入れたtodo作成フォームを作成してみましょう

1. conformとzodのinstall

npm i @conform-to/react @conform-to/zod zod

2. todoを作成してみる

全体のコードです
ほんとだったらファイルを分割したいですが、技術ブログなので1ファイルにまとめました。

import { Form, useActionData, useNavigate, useNavigation } from "react-router";
import type { Route } from "./+types/todo";
import { z } from "zod";
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";

// todoを作成するためのschema
const todoSchema = z.object({
  title: z.string({ message: "タスク名を入力してください" }).min(1),
  description: z.string({ message: "詳細を入力してください" }).min(1),
});

type Todo = z.infer<typeof todoSchema>;

const useCreateTodo = () => {
    const [form, fields] = useForm<Todo>(
        {
            lastResult: null,
            defaultValue: {
                title: "",
                description: "",
            },
            
            onValidate({ formData }) {
                return parseWithZod(formData, { schema: todoSchema });
            }
        }
    );
    
    const navigation = useNavigation();
    const submitting = navigation.state === "submitting";

    return { form, fields, submitting };
};

// todoを作成するためのaction
export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const title = formData.get("title");
  const description = formData.get("description");

  // バリデーション
  const success = todoSchema.safeParse({ title, description });
  if (!success.success) {
    return {
      ok: false,
      errors: success.error.flatten().fieldErrors,
    };
  }

  // 自己満で作ったインジケーターを見たいがために3秒待機
  await new Promise((resolve) => setTimeout(resolve, 3000));

  // データベースに保存
  // 今回はAPIないので省略

  // 作成したtodoの結果を返す
  return {
    ok: true,
    data: {
      title: success.data.title,
      description: success.data.description,
    },
  };
}

/** todoの作成とtodoの一覧表示 */
export default function Todo() {
  // actionの結果を取得
  const actionData = useActionData<typeof action>();
  const { form, fields, submitting } = useCreateTodo();

  return (
    <div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md mx-auto bg-white rounded-xl shadow-md overflow-hidden p-8 space-y-6 transition-transform hover:scale-[1.02]">
        <h2 className="text-center text-3xl font-bold tracking-tight text-gray-700 mb-8">
          ✨ ToDo作成 ✨
        </h2>
        <Form method="post" {...getFormProps(form)} className="space-y-6">
          <div>
            <label htmlFor={fields.title.id} className="block text-sm font-medium text-gray-700 mb-2">
              タスク名
            </label>
            <input
              {...getInputProps(fields.title, { type: "text" })}
              className="block w-full rounded-lg border border-pink-200 px-4 py-3 text-gray-700 focus:border-pink-400 focus:outline-none focus:ring focus:ring-pink-200 transition-colors"
              placeholder="タスクの名前を入力"
            />
            {fields.title.errors && (
              <p className="mt-1 text-sm text-red-600">{fields.title.errors[0]}</p>
            )}
          </div>

          <div>
            <label htmlFor={fields.description.id} className="block text-sm font-medium text-gray-700 mb-2">
              詳細
            </label>
            <input
              {...getInputProps(fields.description, { type: "text" })}
              className="block w-full rounded-lg border border-pink-200 px-4 py-3 text-gray-700 focus:border-pink-400 focus:outline-none focus:ring focus:ring-pink-200 transition-colors"
              placeholder="タスクの詳細を入力"
            />
            {fields.description.errors && (
              <p className="mt-1 text-sm text-red-600">{fields.description.errors[0]}</p>
            )}
          </div>
          <button
            type="submit"
            className="w-full bg-gradient-to-r from-pink-400 to-purple-400 text-white font-semibold py-3 px-6 rounded-lg shadow-md hover:from-pink-500 hover:to-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-300 transition-all duration-300 transform hover:-translate-y-1"
          >
            作成 ✨
          </button>
        </Form>

        { !submitting && actionData && actionData.data && (
          <div className="mt-8">
            <h3 className="text-lg font-semibold text-gray-700 mb-4">
              作成したタスク
            </h3>
            <p className="text-gray-600">{actionData.data.title}</p>
            <p className="text-gray-600">{actionData.data.description}</p>
          </div>
        )}

        {submitting && (
          <div className="mt-8 flex flex-col items-center justify-center space-y-4">
            <div className="flex space-x-3">
              <div className="h-3 w-3 rounded-full bg-pink-400 animate-bounce [animation-delay:-0.3s]"></div>
              <div className="h-3 w-3 rounded-full bg-purple-400 animate-bounce [animation-delay:-0.15s]"></div>
              <div className="h-3 w-3 rounded-full bg-pink-400 animate-bounce"></div>
            </div>
            <p className="text-gray-600 font-medium animate-pulse">
              ✨ タスクを作成中です ✨
            </p>
          </div>
        )}
      </div>
    </div>
  )
}

実際の動画

これまでのreactと違うところ
actionの利用

  1. Formのリクエスト先はactionになります。
  2. actionでreturnした結果はuseActionDataで取得することができる

actionの何がいい?

とりあえずAIに聞いておきました

パフォーマンスの向上
クライアントサイドJavaScriptの削減: Server Actionsはサーバー上で実行されるため、クライアントバンドルに含まれず、初期ページ読み込み時間が短縮されます。

サーバーサイドでのデータ操作: 別個のAPIエンドポイントを作成・管理する必要がなく、不要なネットワークラウンドトリップを避けることでパフォーマンスが向上します。

開発の簡素化
フロントエンドとバックエンドの統合: コンポーネント内で直接SQLクエリを実行したり、サーバー側の関数を直接呼び出したりできるため、開発がシンプル化されます。

型の一貫性: フロントエンドとバックエンドで一貫した型定義が利用でき、型違反を防ぎやすくなります。

ユーザー体験の向上
プログレッシブエンハンスメント: JavaScriptが無効でもフォームが機能し、有効時にはより良い体験を提供します。

アクセシビリティの向上: Server ActionsのJSコードはクライアントに送信されないため、JavaScriptが無効でも機能し、アクセシビリティが向上します。

セキュリティとデータ保護
データセキュリティ: サーバーサイドで処理を行うことで、機密データの取り扱いがより安全になります。

ビジネスリソースの保護: クライアントに公開すべきでないサーバーリソースへのアクセスを必要とする操作を実装できます。

柔軟性と機能性
多様なタスク処理: 単純なデータ取得から複雑なビジネスロジックまで、幅広いタスクを処理できます。

キャッシュデータの再検証: キャッシュされたデータを再検証し、最新の状態を保つことができます。

だそうです

Discussion