React 19 Server Actions完全ガイド - フォーム処理とデータミューテーション
React 19で導入されたServer Actionsは、フォーム処理とデータミューテーションを革新的にシンプルにしました。Next.js 14以降で利用可能なこの機能により、API Routeを書かずにサーバーサイド処理を実行できます。
Server Actionsとは
Server Actionsは、サーバーサイドで実行される非同期関数で、クライアントから直接呼び出せます。
従来の方法(React 18 + Next.js)
// pages/api/create-post.ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { title, content } = req.body;
const post = await db.posts.create({ data: { title, content } });
res.json(post);
}
// components/PostForm.tsx
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const response = await fetch('/api/create-post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
});
const post = await response.json();
}
React 19 + Server Actions
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.posts.create({ data: { title, content } });
return post;
}
// components/PostForm.tsx
import { createPost } from '@/app/actions';
export default function PostForm() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">投稿</button>
</form>
);
}
メリット:
- API Routeが不要
- 型安全(TypeScript完全サポート)
- フォームのネイティブ動作を活用
- JavaScriptなしでも動作(Progressive Enhancement)
Server Actionsの作成
基本的なServer Action
// app/actions/posts.ts
'use server'
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
// FormDataから値を取得
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// バリデーション
if (!title || !content) {
return { error: 'タイトルと本文は必須です' };
}
// データベースに保存
const post = await db.posts.create({
data: { title, content, published: false },
});
// キャッシュを再検証
revalidatePath('/posts');
return { success: true, post };
}
複数のServer Actions
// app/actions/posts.ts
'use server'
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
redirect(`/posts/${post.id}`);
}
export async function updatePost(id: string, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.posts.update({
where: { id },
data: { title, content },
});
revalidatePath(`/posts/${id}`);
return { success: true };
}
export async function deletePost(id: string) {
await db.posts.delete({ where: { id } });
revalidatePath('/posts');
redirect('/posts');
}
フォーム処理
基本的なフォーム
// app/posts/new/page.tsx
import { createPost } from '@/app/actions/posts';
export default function NewPostPage() {
return (
<div>
<h1>新規投稿</h1>
<form action={createPost}>
<div>
<label htmlFor="title">タイトル</label>
<input type="text" id="title" name="title" required />
</div>
<div>
<label htmlFor="content">本文</label>
<textarea id="content" name="content" required />
</div>
<button type="submit">投稿する</button>
</form>
</div>
);
}
useActionStateでステート管理
// app/posts/new/page.tsx
'use client'
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';
export default function NewPostPage() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" required />
<textarea name="content" required />
<button type="submit" disabled={isPending}>
{isPending ? '投稿中...' : '投稿する'}
</button>
{state?.error && (
<div className="error">{state.error}</div>
)}
{state?.success && (
<div className="success">投稿しました!</div>
)}
</form>
);
}
useFormStatusでペンディング状態を取得
// components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending, data, method } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? (
<>
<Spinner />
送信中...
</>
) : (
'送信'
)}
</button>
);
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions/posts';
import { SubmitButton } from '@/components/SubmitButton';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<SubmitButton />
</form>
);
}
バリデーション
Zodでバリデーション
npm install zod
// app/actions/posts.ts
'use server'
import { z } from 'zod';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
const PostSchema = z.object({
title: z.string().min(1, 'タイトルは必須です').max(100, 'タイトルは100文字以内です'),
content: z.string().min(10, '本文は10文字以上必要です'),
published: z.boolean().optional(),
});
export async function createPost(formData: FormData) {
// FormDataをオブジェクトに変換
const data = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on',
};
// バリデーション
const result = PostSchema.safeParse(data);
if (!result.success) {
return {
error: result.error.flatten().fieldErrors,
};
}
// データベースに保存
const post = await db.posts.create({
data: result.data,
});
revalidatePath('/posts');
return { success: true, post };
}
エラー表示
// app/posts/new/page.tsx
'use client'
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';
export default function NewPostPage() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<div>
<label htmlFor="title">タイトル</label>
<input type="text" id="title" name="title" />
{state?.error?.title && (
<p className="error">{state.error.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">本文</label>
<textarea id="content" name="content" />
{state?.error?.content && (
<p className="error">{state.error.content[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? '投稿中...' : '投稿する'}
</button>
</form>
);
}
楽観的更新
useOptimisticで即座にUI更新
// app/posts/page.tsx
'use client'
import { useOptimistic } from 'react';
import { deletePost } from '@/app/actions/posts';
type Post = {
id: string;
title: string;
content: string;
};
export default function PostsPage({ posts }: { posts: Post[] }) {
const [optimisticPosts, addOptimisticPost] = useOptimistic(
posts,
(state, deletedId: string) => state.filter(p => p.id !== deletedId)
);
async function handleDelete(id: string) {
// 即座にUIを更新(楽観的更新)
addOptimisticPost(id);
// サーバーサイド処理
await deletePost(id);
}
return (
<div>
<h1>投稿一覧</h1>
{optimisticPosts.map(post => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
<button onClick={() => handleDelete(post.id)}>削除</button>
</div>
))}
</div>
);
}
いいね機能での楽観的更新
// app/actions/likes.ts
'use server'
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function toggleLike(postId: string) {
const userId = await getCurrentUserId();
const existingLike = await db.like.findUnique({
where: { userId_postId: { userId, postId } },
});
if (existingLike) {
await db.like.delete({ where: { id: existingLike.id } });
} else {
await db.like.create({ data: { userId, postId } });
}
revalidatePath(`/posts/${postId}`);
}
// components/LikeButton.tsx
'use client'
import { useOptimistic } from 'react';
import { toggleLike } from '@/app/actions/likes';
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, delta: number) => state + delta
);
async function handleLike() {
// 即座にカウントを更新
addOptimisticLike(1);
// サーバー処理
await toggleLike(postId);
}
return (
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
);
}
エラーハンドリング
try-catchでエラー処理
// app/actions/posts.ts
'use server'
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
try {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
return { success: true, post };
} catch (error) {
console.error('Failed to create post:', error);
return { error: '投稿の作成に失敗しました' };
}
}
カスタムエラークラス
// lib/errors.ts
export class ValidationError extends Error {
constructor(public fields: Record<string, string>) {
super('Validation failed');
}
}
export class AuthError extends Error {
constructor() {
super('Unauthorized');
}
}
// app/actions/posts.ts
'use server'
import { db } from '@/lib/db';
import { ValidationError, AuthError } from '@/lib/errors';
export async function createPost(formData: FormData) {
const userId = await getCurrentUserId();
if (!userId) {
throw new AuthError();
}
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || !content) {
throw new ValidationError({
title: !title ? 'タイトルは必須です' : '',
content: !content ? '本文は必須です' : '',
});
}
const post = await db.posts.create({
data: { title, content, userId },
});
return { success: true, post };
}
認証・認可
セッション確認
// app/actions/posts.ts
'use server'
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
// セッション確認
const session = await auth();
if (!session?.user) {
return { error: 'ログインが必要です' };
}
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.posts.create({
data: {
title,
content,
userId: session.user.id,
},
});
revalidatePath('/posts');
return { success: true, post };
}
権限チェック
// app/actions/posts.ts
'use server'
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user) {
return { error: 'ログインが必要です' };
}
// 投稿の所有者確認
const post = await db.posts.findUnique({
where: { id: postId },
});
if (!post) {
return { error: '投稿が見つかりません' };
}
if (post.userId !== session.user.id) {
return { error: '削除権限がありません' };
}
await db.posts.delete({ where: { id: postId } });
return { success: true };
}
ファイルアップロード
// app/actions/upload.ts
'use server'
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { db } from '@/lib/db';
export async function uploadImage(formData: FormData) {
const file = formData.get('image') as File;
if (!file) {
return { error: 'ファイルが選択されていません' };
}
// ファイルサイズチェック(5MB以下)
if (file.size > 5 * 1024 * 1024) {
return { error: 'ファイルサイズは5MB以下にしてください' };
}
// ファイルタイプチェック
if (!file.type.startsWith('image/')) {
return { error: '画像ファイルを選択してください' };
}
// ファイル保存
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const path = join(process.cwd(), 'public', 'uploads', filename);
await writeFile(path, buffer);
// データベースに記録
const image = await db.image.create({
data: {
filename,
path: `/uploads/${filename}`,
size: file.size,
mimeType: file.type,
},
});
return { success: true, image };
}
// app/upload/page.tsx
import { uploadImage } from '@/app/actions/upload';
export default function UploadPage() {
return (
<form action={uploadImage}>
<input type="file" name="image" accept="image/*" required />
<button type="submit">アップロード</button>
</form>
);
}
revalidateとredirect
revalidatePathでキャッシュ更新
// app/actions/posts.ts
'use server'
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: { /* ... */ },
});
// 特定のパスのキャッシュを再検証
revalidatePath('/posts');
// 動的ルートの場合
revalidatePath(`/posts/${post.id}`);
return { success: true, post };
}
redirectでページ遷移
// app/actions/posts.ts
'use server'
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: { /* ... */ },
});
// 投稿詳細ページにリダイレクト
redirect(`/posts/${post.id}`);
}
まとめ
React 19 Server Actionsは以下の点で優れています。
メリット:
- API Route不要で開発効率向上
- 型安全なデータミューテーション
- フォームのネイティブ動作活用
- JavaScript無効でも動作
- 楽観的更新が簡単
ベストプラクティス:
- バリデーションはZod等を活用
- エラーハンドリングを適切に実装
- 認証・認可チェックを必ず行う
- revalidatePathでキャッシュ管理
- useOptimisticでUX向上
注意点:
- Server Actionsは’use server’ディレクティブ必須
- クライアントコンポーネントでは’use client’が必要
- Next.js 14以降で利用可能
フォーム処理とデータミューテーションが劇的にシンプルになりました。ぜひ試してみてください。
参考リンク: