Server Actions とフォーム
Server Actions で API ルートなしに mutation を処理し、React 19 の `useActionState`・`useFormStatus`・`useOptimistic` で UX を整えるパターン。第4部締めくくりのミニプロジェクト(ゲストブック)まで。
第26章で Suspense と use() で段階的なロードを作りました。これまで見てきたすべてのコードは データを読むだけ でした。本章ではユーザーがデータを変更する作業 — mutation — を Next.js の新しい武器である Server Actions でどう扱うかを整理します。
本章は第9章(制御フォーム)と第19章(イベントとフォームの型付け)で作ったクライアントサイドフォームパターンが、RSC 時代にどう単純になるかを正面から示します。そして本章で扱う useActionState / useFormStatus / useOptimistic は第28章(React 19 新機能まとめ)でもう一度カタログとして出会うことになり、第34章(フルスタック Todo キャップストーン)の mutation の土台になります。
伝統的な mutation の複雑さ #
これまでのフロントエンド mutation パターンを思い出してみてください。第9章で作った制御フォームと第19章でかぶせた型を合わせると、だいたいこんな形になります。
'use client';
import { useState, type FormEvent } from 'react';
export default function CommentForm() {
const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error('送信失敗');
setText('');
} catch (err) {
setError(err instanceof Error ? err.message : '不明なエラー');
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} />
<button disabled={submitting}>{submitting ? '送信中...' : '送信'}</button>
{error && <p>{error}</p>}
</form>
);
}毎回繰り返されるボイラープレート。
- API エンドポイントを 1 つ作らないといけない(
/api/comments) - JSON シリアライズ / デシリアライズ
- クライアントで fetch ハンドリング
- ローディング state、エラー state
- 成功後にデータを取得し直す(一覧の更新)
API エンドポイントとクライアント間のこの往復を 1 つの関数呼び出しのように 表現できたらどうでしょうか。Server Actions がそれを可能にします。
Server Action の基本 #
Server Action は 'use server' ディレクティブの付いた非同期関数 です。クライアントから呼び出すと自動でサーバーで実行されます。
'use server';
export async function createComment(text: string) {
// このコードは常にサーバーで実行される
await db.query('INSERT INTO comments (text) VALUES ($1)', [text]);
}'use client';
import { createComment } from './actions';
export default function CommentForm() {
async function handleSubmit(formData: FormData) {
const text = formData.get('text');
if (typeof text === 'string') {
await createComment(text);
}
}
return (
<form action={handleSubmit}>
<input name="text" />
<button>送信</button>
</form>
);
}核心的な変化。
- API ルートを作らない —
createCommentをただ関数のように import して呼び出す - JSON シリアライズしない — Next.js が処理してくれる
<form action={fn}>— フォーム送信を直接関数につなぐ(ブラウザネイティブのフォームを使用)- ディレクティブでセキュリティ境界が明確。
'use server'の付いた関数だけがクライアントから呼び出せる
見た目は関数を 1 つ呼ぶように見えますが、内部的には Next.js が RPC(Remote Procedure Call)を自動で作ってくれます。クライアントは関数 ID と引数をサーバーに送り、サーバーが実際の関数を実行した結果を返してくれるのです。第24章で「シリアライズ制約の優雅な例外」と言ったまさにそのメカニズムが、本章の主役です。
Progressive Enhancement #
<form action={fn}> の形は JavaScript が無効化されていても動作します。ブラウザのデフォルトのフォーム送信が起き、サーバーがそのリクエストを受けて Server Action を実行します。JavaScript がロードされるとその上に滑らかなクライアントサイド遷移と pending UI がかぶさります。
これが progressive enhancement の正確な意味です。基本動作は HTML 標準だけで保証され、向上は JavaScript がある場合に追加で起きます。第9章で作った onSubmit ベースのフォームは JavaScript が死ぬと一緒に死にます。Server Actions の <form action={fn}> はそうではありません。
検証は dev tools で Disable JavaScript をオンにしてフォームを送信してみればよいです。同じ URL に POST が行き、サーバーが処理した後ページが新たに描画されます。
ディレクティブの位置 — ファイル単位 vs 関数単位 #
'use server' は 2 つの方式で使えます。
1. ファイルの先頭(そのファイルのすべての export が Server Action) #
'use server';
export async function createPost(formData: FormData) { /* ... */ }
export async function deletePost(id: string) { /* ... */ }
export async function updatePost(id: string, data: FormData) { /* ... */ }2. 関数の中(Server Component の中にインライン定義) #
import PostForm from './PostForm';
export default function PostsPage() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title');
if (typeof title === 'string') {
await db.insertPost(title);
}
}
return <PostForm onCreate={createPost} />;
}関数インライン方式は Server Component のクロージャ(上位変数)にアクセスできて便利です。ただし毎レンダリングごとに新しい関数が生成されるので、リストの各項目で無差別に使うと効率が落ちることがあります。
規模が大きくなれば、普通は別のファイル(actions.ts)に集めておく方が保守に良いです。
ページの更新 — revalidatePath / revalidateTag #
mutation 後には画面が新しいデータを反映する必要があります。単純な再読み込みではなく、変更されたページのキャッシュを無効化して取り直させる ことです。第25章の next.tags / revalidate オプションが本章の無効化関数たちと組をなします。
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title');
if (typeof title !== 'string') return;
await db.insertPost(title);
revalidatePath('/posts'); // /posts ページのキャッシュを無効化
}revalidatePath('/posts') を呼び出すと次の /posts 訪問時に新しくレンダリングされます。既にそのページにいるユーザーなら画面が自動で更新されます(Server Action を実行した後、Next.js がルートのキャッシュを更新します)。
revalidateTag は第25章で見た next.tags オプションと組をなします。
// フェッチ側(第25章)
const posts = await fetch(url, { next: { tags: ['posts'] } });
// Action 側(本章)
revalidateTag('posts'); // 'posts' タグの付いたすべての fetch を無効化
複数のページで同じデータを使うときに一度に無効化できて便利です。
useActionState — state を持つ Action #
Action の結果(成功 / 失敗メッセージ、検証エラーなど)をフォームの画面に表示しなければならないことが多いです。React 19 の新フック useActionState がこれを助けてくれます。
'use client';
import { useActionState } from 'react';
import { createComment } from './actions';
type State = { message: string };
export default function CommentForm() {
const [state, formAction] = useActionState<State, FormData>(createComment, { message: '' });
return (
<form action={formAction}>
<input name="text" />
<button>送信</button>
{state.message && <p>{state.message}</p>}
</form>
);
}'use server';
type State = { message: string };
export async function createComment(prevState: State, formData: FormData): Promise<State> {
const text = formData.get('text');
if (typeof text !== 'string' || !text.trim()) {
return { message: '内容を入力してください' };
}
await db.insertComment(text);
return { message: '送信完了!' };
}useActionState(action, initialState) の戻り値。
- 第 1 戻り値(
state): Action の最後の戻り値(または初期 state) - 第 2 戻り値(
formAction):<form action={...}>に渡すラップされた関数 - (第 3 戻り値
isPendingもあり、ローディング表示に活用可能)
Action 関数の第 1 引数は 以前の state、第 2 が FormData です(上の actions.ts のシグネチャがそのため (prevState, formData))。
このパターンのおかげで、検証エラーを画面に表示したり成功メッセージを見せたりするのが自然になります。第19章でかぶせたフォームの型の上に、本章の State ジェネリックがもう一層重なる形です。
useFormStatus — 送信中の表示 #
フォームが送信中か(pending)は useFormStatus フックで分かります。
'use client';
import { useFormStatus } from 'react-dom';
import type { ReactNode } from 'react';
export default function SubmitButton({ children }: { children: ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '送信中...' : children}
</button>
);
}<form action={formAction}>
<input name="text" />
<SubmitButton>送信</SubmitButton>
</form>useFormStatus は 親フォーム の状態を教えてくれます。なので SubmitButton をフォームの中のどこに置いても、そのフォームが送信中なら pending が true になります。
同様の効果は useActionState の isPending(第 3 戻り値)でも出せますが、useFormStatus は別のコンポーネントでフォームの状態を購読できるので、再利用可能な SubmitButton のようなパターンに有用です。
Optimistic UI — useOptimistic #
mutation の応答を待つ間に画面を先に更新して、即座に反映されたように見せるパターンです。useOptimistic フックが助けてくれます。
'use client';
import { useOptimistic } from 'react';
import { deletePost } from './actions';
type Post = { id: string; title: string };
export default function PostList({ posts }: { posts: Post[] }) {
const [optimisticPosts, deleteOptimistic] = useOptimistic<Post[], string>(
posts,
(state, postId) => state.filter(p => p.id !== postId),
);
async function handleDelete(id: string) {
deleteOptimistic(id); // 即座に UI から削除
await deletePost(id); // 実際のサーバー呼び出し
}
return (
<ul>
{optimisticPosts.map(post => (
<li key={post.id}>
{post.title}
<button onClick={() => handleDelete(post.id)}>削除</button>
</li>
))}
</ul>
);
}useOptimistic(state, reducer) は楽観的な一時 state とそれを変更する関数を返します。クリック即座に UI から削除してサーバー呼び出しを開始し、サーバー応答で本物の state が更新されると自然に同期されます。もしサーバー呼び出しが失敗すれば自動で元の state にロールバックされます。
体感速度が劇的に良くなる強力なパターンですが、データが一貫して表示されるかの検証が必要なので、普通は学習の後半で扱います。本章では概念のみ押さえて通過し、第34章のキャップストーン Todo アプリで実際の使用例をもう一度見てみます。
試してみる — ゲストブックのミニプロジェクト #
これまで学んだことをすべて合わせた小さなアプリを作ってみます。メモリに保存される単純なゲストブックです(実際の DB 連携は別主題なので省略)。
src/app/data.ts(メモリ保存):
export type Message = {
id: string;
name: string;
text: string;
createdAt: string;
};
const messages: Message[] = [
{ id: '1', name: '管理者', text: 'ようこそ :)', createdAt: new Date().toISOString() },
];
export async function getMessages(): Promise<Message[]> {
await new Promise(r => setTimeout(r, 200)); // 仮の遅延
return [...messages].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function addMessage(name: string, text: string) {
messages.push({
id: crypto.randomUUID(),
name,
text,
createdAt: new Date().toISOString(),
});
}
export async function deleteMessage(id: string) {
const idx = messages.findIndex(m => m.id === id);
if (idx >= 0) messages.splice(idx, 1);
}src/app/guestbook/actions.ts:
'use server';
import { revalidatePath } from 'next/cache';
import { addMessage, deleteMessage } from '../data';
export type PostState = { error?: string; success?: boolean };
export async function postMessage(prevState: PostState, formData: FormData): Promise<PostState> {
const name = (formData.get('name') ?? '').toString().trim();
const text = (formData.get('text') ?? '').toString().trim();
if (!name) return { error: '名前を入力してください' };
if (!text) return { error: 'メッセージを入力してください' };
if (text.length > 200) return { error: 'メッセージは 200 文字以内で' };
await addMessage(name, text);
revalidatePath('/guestbook');
return { success: true };
}
export async function removeMessage(id: string) {
await deleteMessage(id);
revalidatePath('/guestbook');
}src/app/guestbook/page.tsx:
import { Suspense } from 'react';
import { getMessages } from '../data';
import MessageForm from './MessageForm';
import { removeMessage } from './actions';
export default function GuestbookPage() {
return (
<div style={{ padding: '24px', maxWidth: '600px', margin: '0 auto' }}>
<h1>ゲストブック</h1>
<MessageForm />
<Suspense fallback={<p>メッセージを読み込み中...</p>}>
<MessageList />
</Suspense>
</div>
);
}
async function MessageList() {
const messages = await getMessages();
if (messages.length === 0) {
return <p>まだメッセージがありません。最初のメッセージを残してみてください!</p>;
}
return (
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{messages.map(msg => (
<li key={msg.id} style={{ padding: '12px', borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong>{msg.name}</strong>
<small style={{ color: '#888' }}>
{new Date(msg.createdAt).toLocaleString('ja-JP')}
</small>
</div>
<p style={{ margin: '4px 0' }}>{msg.text}</p>
<form action={async () => {
'use server';
await removeMessage(msg.id);
}}>
<button style={{ fontSize: '12px', color: '#888' }}>削除</button>
</form>
</li>
))}
</ul>
);
}src/app/guestbook/MessageForm.tsx:
'use client';
import { useActionState, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import { postMessage, type PostState } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} style={{ padding: '6px 16px' }}>
{pending ? '登録中...' : '登録'}
</button>
);
}
const initialState: PostState = {};
export default function MessageForm() {
const [state, formAction] = useActionState(postMessage, initialState);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state]);
return (
<form
ref={formRef}
action={formAction}
style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}
>
<input name="name" placeholder="名前" required style={{ padding: '6px' }} />
<textarea name="text" placeholder="メッセージ" rows={3} required style={{ padding: '6px' }} />
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<SubmitButton />
{state.error && <span style={{ color: 'tomato', fontSize: '14px' }}>{state.error}</span>}
{state.success && <span style={{ color: 'green', fontSize: '14px' }}>登録完了!</span>}
</div>
</form>
);
}これで全部です。この小さなアプリで起きることを整理すると以下のとおりです。
GuestbookPage(Server Component): ページの外殻MessageList(Server Component): メッセージ一覧のレンダリング、Suspense の中にあるのでロード中は fallbackMessageForm(Client Component): フォーム、useActionState でサーバー状態を受け取り、useFormStatus で送信中表示postMessage(Server Action): サーバーで検証 + 保存 + revalidatePathremoveMessage(Server Action): インラインで作成、フォームの action に直接接続
API エンドポイントを一行も作りませんでした。検証もサーバーで行うのでクライアントから迂回不可、データはサーバーメモリに安全に保管、画面は mutation 後に自動更新。
/guestbook に移動して動作を確認してみてください。メッセージを登録し、空の入力で送信してみて(エラー)、200 文字を超えて入力してみて(エラー)、削除ボタンも押してみてください。ページを再読み込みしても(サーバーが死んでいなければ)メッセージはそのまま残っているはずです — メモリ保存にあるからです。
練習問題 #
- progressive enhancement の検証。dev tools で JavaScript を無効化した後、ゲストブックフォームでメッセージを登録してみてください。フォーム送信が普通の POST リクエストとして起き、ページが新たに描画されつつメッセージが追加されるかを確認します。第9章の
onSubmitベースのフォームが同じ条件で動作しない理由を 1 文で説明してみてください。 useOptimisticの適用。上のゲストブックの削除ボタンにuseOptimisticを適用してみてください。クリック即座に UI からメッセージが消え、サーバー応答が届いても画面はそのまま維持されるはずです。その後deleteMessageの中にthrow new Error('失敗')をわざと入れてみて、画面が元の状態に自動でロールバックされるかを確認します。- タグベースの無効化実験。
getMessagesをfetch(url, { next: { tags: ['guestbook'] } })で外部 API を呼び出すように変え(mock で簡単に)、postMessage/removeMessageのrevalidatePath('/guestbook')をrevalidateTag('guestbook')に置き換えます。第25章のnext.tagsと本章のrevalidateTagが組をなす流れを直接手に馴染ませてみてください。
一行まとめ: Server Action は
'use server'が付いた非同期関数で、クライアントから import して呼び出すと自動でサーバーで実行される。<form action={fn}>で progressive enhancement が保証され、useActionStateで検証結果を、useFormStatusで送信中状態を、useOptimisticで即座反映 UX をかぶせる。mutation の後にはrevalidatePath/revalidateTagで第25章のキャッシュと同期する。API エンドポイントと JSON シリアライズのボイラープレートが消え、第9・19章で作ったクライアントサイドフォームが RSC 時代のフォームへと進化する。
次の章 #
第4部の本内容は本章で締めくくられますが、もう一章残っています。次の 第28章 React 19 新機能まとめでは、この第4部のあちこちで出会った React 19 の新機能たち — use フック、Actions API(useActionState・useFormStatus・useOptimistic)、ref as prop、そして React Compiler — を一か所に集めてカタログとして整理します。第4部で散らばっていた断片が一章の中で大きな絵にまとまる章で、第4部の本当の締めくくりです。第28章以降、第29章から第5部(運用・テスト・デプロイ)が始まります。