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 がエラーを返した際のロールバックが必須になる。
useFormStatus
と useOptimistic
は必須ではない
"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 モードはまだベータ版なので注意です、とはいえ個人的に感触はまったく悪くないかなと。