kk-web

Next.js 15 × React 19 × フォームのベストプラクティス

2025-03-24

ベストプラクティスは言いすぎかもですが、個人的にもうこれがベストだろ!という書きっぷりに至ったので書いていこうと思います。

コードはすべて ChatGPT に出力してもらったので動作は未確認です、悪しからず。


技術スタック

  • React v19
  • Next.js v15
  • React Hook Form
    • @hookform/resolvers
    • @hookform/error-message
  • Zod

『サーバーアクション主流の時代に React Hook Form 入れんの?』って方もいるかもですが、なんだかんだ便利なので組み込んだほうが結果楽で堅いかなーと。

ディレクトリ構成

app/
├── actions.ts
├── page.tsx
├── schema.ts

ケースバイケースで良いと思います。

実装

app/schema.ts

import { z } from "zod";

export const postSchema = z.object({
  title: z.string().min(1, "タイトルは必須です"),
  content: z.string().min(1, "内容を入力してください"),
});

export type PostFormData = z.infer<typeof postSchema>;

普通ですね、特筆することもないです。

app/actions.ts

"use server";

import { postSchema } from "./schema";

export async function createPost(data: any) {
  const parsed = postSchema.safeParse(data);

  if (!parsed.success) {
    return {
      error: parsed.error.flatten().fieldErrors,
    };
  }

  // 疑似遅延&保存処理
  await new Promise((r) => setTimeout(r, 1000));

  return { success: true, title: parsed.data.title };
}

postSchema.safeParse でサーバーサイドでバリデーションチェックを入れられるのが強みだよなと。

app/page.tsx

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useOptimistic, useState } from "react";
import { useFormStatus } from "react-dom";
import { ErrorMessage } from "@hookform/error-message";
import { postSchema, PostFormData } from "./schema";
import { createPost } from "./actions";

type PostItem = {
  id: string;
  title: string;
  content: string;
};

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "送信中..." : "送信"}
    </button>
  );
}

export default function Page() {
  const [posts, setPosts] = useState<PostItem[]>([]);
  const [optimisticPosts, addOptimisticPost] = useOptimistic(
    posts,
    (state, newPost: PostItem) => [newPost, ...state],
  );

  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors },
  } = useForm<PostFormData>({
    resolver: zodResolver(postSchema),
    progressive: true,
  });

  const onSubmit = handleSubmit(async (data) => {
    const fakePost: PostItem = {
      id: crypto.randomUUID(),
      title: data.title,
      content: data.content,
    };

    addOptimisticPost(fakePost);

    const result = await createPost(data);

    if (result?.error) {
      // フィールドエラーのループ処理
      Object.entries(result.error).forEach(([key, messages]) => {
        if (typeof messages?.[0] === "string") {
          setError(key as keyof PostFormData, {
            type: "server",
            message: messages[0],
          });
        }
      });

      // ロールバック:楽観的に追加したものを削除
      setPosts((prev) => prev.filter((p) => p.id !== fakePost.id));
    } else {
      // 正常に保存できたら正式に state に追加
      setPosts((prev) => [fakePost, ...prev]);
      reset();
    }
  });

  return (
    <div>
      <form action={onSubmit}>
        <input {...register("title")} placeholder="タイトル" />
        <ErrorMessage
          errors={errors}
          name="title"
          render={({ message }) => (
            <div style={{ color: "red" }}>{message}</div>
          )}
        />
        <textarea {...register("content")} placeholder="内容" />
        <ErrorMessage
          errors={errors}
          name="content"
          render={({ message }) => (
            <div style={{ color: "red" }}>{message}</div>
          )}
        />
        <SubmitButton />
      </form>
      <hr />
      <h2>投稿一覧</h2>
      <ul>
        {optimisticPosts.map((post) => (
          <li key={post.id}>
            {post.title}:{post.content}
          </li>
        ))}
      </ul>
    </div>
  );
}

もちろんクライアントサイドでもバリデーションチェックがかけられるので結構リッチですね。

とはいえ普通に考えて両サイドでバリデーションチェックをかけて損はないよなと。

備考

なんで useFormStatus

formState.isSubmitting は form action では使用できないらしい、そりゃそうか。

なんで useOptimistic

画面側の即時反映が目的、裏を返せば server action がエラーを返した際のロールバックが必須になる。

useFormStatususeOptimistic は必須ではない

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ErrorMessage } from "@hookform/error-message";
import { postSchema, PostFormData } from "./schema";
import { createPost } from "./actions";

export default function Page() {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors },
  } = useForm<PostFormData>({
    resolver: zodResolver(postSchema),
    progressive: true,
  });

  const onSubmit = handleSubmit(async (data) => {
    const result = await createPost(data);

    if (result?.error) {
      Object.entries(result.error).forEach(([key, messages]) => {
        if (typeof messages?.[0] === "string") {
          setError(key as keyof PostFormData, {
            type: "server",
            message: messages[0],
          });
        }
      });
    } else {
      reset();
      alert("送信完了!");
    }
  });

  return (
    <form action={onSubmit}>
      <input {...register("title")} placeholder="タイトル" />
      <ErrorMessage
        errors={errors}
        name="title"
        render={({ message }) => <div style={{ color: "red" }}>{message}</div>}
      />
      <textarea {...register("content")} placeholder="内容" />
      <ErrorMessage
        errors={errors}
        name="content"
        render={({ message }) => <div style={{ color: "red" }}>{message}</div>}
      />
      <button type="submit">送信</button>
    </form>
  );
}

useFormStatus は必須にしても問題ない気がしますね、useOptimistic はケースバイケースかなと。

とはいえ useOptimistic を組み込まない場合、ロールバックが不要になるのでだいぶシンプルになりますね。


そんな感じです、個人的には素の React でフォームを作るよりはこっちのほうが好みかなと。

ただ progressive モードはまだベータ版なので注意です、とはいえ個人的に感触はまったく悪くないかなと。

© 2018 kk-web