モダンReact + Next.js #6 Server Actionsとフォーム (まとめ)

読了 12分

前回はSuspenseとuse()で段階的なローディングを扱いました。これまではデータを読むだけでしたね。今回の記事、そしてシリーズの最終回ではユーザーがデータを変更する作業をどう扱うか — Next.jsの最も新しい武器であるServer Actionsを見ていき、ここまで学んだことをすべて合わせた小さなミニプロジェクトでシリーズを締めくくります。

従来のmutationの複雑さ #

これまでのフロントエンドのmutationパターンを思い浮かべてみてください。

従来のパターン
'use client';

function CommentForm() {
  const [text, setText] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState(null);

  async function handleSubmit(e) {
    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.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'ディレクティブが付いた非同期関数です。クライアントから呼び出すと、自動的にサーバーで実行されます。

src/app/actions.js
'use server';

export async function createComment(text) {
  // このコードは常にサーバーで実行される
  await db.query('INSERT INTO comments (text) VALUES ($1)', [text]);
}
src/app/CommentForm.jsx
'use client';

import { createComment } from './actions';

export default function CommentForm() {
  async function handleSubmit(formData) {
    const text = formData.get('text');
    await createComment(text);
  }

  return (
    <form action={handleSubmit}>
      <input name="text" />
      <button>送信</button>
    </form>
  );
}

主な変化点は次のとおりです。

  • APIルートを作らないcreateCommentをただの関数のようにimportして呼ぶ
  • JSONシリアライズしない — Next.jsが処理してくれる
  • <form action={fn}> — フォーム送信を直接関数につなぐ (ブラウザネイティブのformを使用)
  • ディレクティブでセキュリティ境界が明確 — 'use server'が付いた関数だけがクライアントから呼び出せる

見た目は関数を1つ呼んでいるだけのようですが、内部的にはNext.jsがRPC(Remote Procedure Call)を自動的に作ってくれます。クライアントは関数IDと引数をサーバーに送り、サーバーが実際の関数を実行して結果を返してくれるわけです。

ディレクティブの位置 — ファイル単位 vs 関数単位 #

'use server'は2つの方法で使えます。

1. ファイルの先頭 (そのファイルのすべてのexportがServer Action) #

src/app/actions.js
'use server';

export async function createPost(formData) { /* ... */ }
export async function deletePost(id) { /* ... */ }
export async function updatePost(id, data) { /* ... */ }

2. 関数内 (Server Component内にインライン定義) #

src/app/posts/page.js (Server Component)
import PostForm from './PostForm';

export default function PostsPage() {
  async function createPost(formData) {
    'use server';
    const title = formData.get('title');
    await db.insertPost(title);
  }

  return <PostForm onCreate={createPost} />;
}

関数インライン方式はServer Componentのクロージャ(上位の変数)にアクセスできて便利です。ただし毎回のレンダリングで新しい関数が生成されるので、リストの各項目で無差別に使うと効率が落ちることがあります。

規模が大きくなると、通常は別ファイル(actions.js)にまとめておく方が保守しやすいです。

ページの更新 — revalidatePath / revalidateTag #

mutationの後は画面が新しいデータを反映する必要があります。単純な再読み込みではなく、変更されたページのキャッシュを無効化して再取得させるわけです。

src/app/posts/actions.js
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData) {
  const title = formData.get('title');
  await db.insertPost(title);
  revalidatePath('/posts');  // /posts ページのキャッシュを無効化
}

revalidatePath('/posts')を呼ぶと、次の/posts訪問時に新しくレンダリングされます。すでにそのページにいるユーザーであれば、画面が自動的に更新されます (Server Actionを実行した後にNext.jsがルートのキャッシュを更新するからです)。

revalidateTagは#4で見たnext.tagsオプションと対になります。

タグベースの無効化
// フェッチ側
const posts = await fetch(url, { next: { tags: ['posts'] } });

// Action 側
revalidateTag('posts');  // 'posts' タグが付いたすべての fetch を無効化

複数のページで同じデータを使うときに、一度に無効化できて便利です。

useActionState — 状態を持つAction #

Actionの結果(成功/失敗メッセージ、検証エラーなど)をフォームの画面に表示する必要があることがよくあります。React 19の新しいフックuseActionStateがそれを助けます。

src/app/CommentForm.jsx
'use client';

import { useActionState } from 'react';
import { createComment } from './actions';

export default function CommentForm() {
  const [state, formAction] = useActionState(createComment, { message: '' });

  return (
    <form action={formAction}>
      <input name="text" />
      <button>送信</button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}
src/app/actions.js
'use server';

export async function createComment(prevState, formData) {
  const text = formData.get('text');
  if (!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.jsのシグネチャがそのため(prevState, formData)になっています)。

このパターンのおかげで、検証エラーを画面に表示したり、成功メッセージを見せたりするのが自然になります。

useFormStatus — 送信中の表示 #

フォームが送信中か(pending)はuseFormStatusフックで知ることができます。

src/app/SubmitButton.jsx
'use client';

import { useFormStatus } from 'react-dom';

export default function SubmitButton({ children }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? '送信中...' : children}
    </button>
  );
}
使用
<form action={formAction}>
  <input name="text" />
  <SubmitButton>送信</SubmitButton>
</form>

useFormStatus親フォームの状態を教えてくれます。なのでSubmitButtonをフォームのどこに置いても、そのフォームが送信中ならpendingtrueになります。

似た効果をuseActionStateisPending(3つ目の戻り値)でも出せますが、useFormStatusは別のコンポーネントからフォームの状態を購読できるので、再利用可能なSubmitButtonのようなパターンに有用です。

Optimistic UI — useOptimistic #

mutationのレスポンスを待っている間、画面を先に更新して即時反映されたかのように見せるパターンです。useOptimisticフックが助けてくれます。

src/app/posts/PostList.jsx
'use client';

import { useOptimistic } from 'react';
import { deletePost } from './actions';

export default function PostList({ posts }) {
  const [optimisticPosts, deleteOptimistic] = useOptimistic(
    posts,
    (state, postId) => state.filter(p => p.id !== postId)
  );

  async function handleDelete(id) {
    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にロールバックされます。

体感速度が劇的に良くなる強力なパターンですが、データが一貫性をもって表示されるかの検証が必要なので、学習の後半に身につけるのが普通です。このシリーズでは概念だけ押さえて先に進みます。

ミニプロジェクト — シンプルな芳名帳 #

ここまで学んだことをすべて合わせた小さなアプリを作ってみましょう。メモリに保存されるシンプルな芳名帳です (実際のDBは#4のキャッシングの記事で触れたとおり、本格的に入ると別の主題なので省略)。

src/app/data.js (メモリストア) は次のとおりです。

src/app/data.js
const messages = [
  { id: '1', name: '管理者', text: 'ようこそ :)', createdAt: new Date().toISOString() },
];

export async function getMessages() {
  await new Promise(r => setTimeout(r, 200));  // 擬似遅延
  return [...messages].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

export async function addMessage(name, text) {
  messages.push({
    id: crypto.randomUUID(),
    name, text,
    createdAt: new Date().toISOString(),
  });
}

export async function deleteMessage(id) {
  const idx = messages.findIndex(m => m.id === id);
  if (idx >= 0) messages.splice(idx, 1);
}

src/app/guestbook/actions.js は次のとおりです。

src/app/guestbook/actions.js
'use server';

import { revalidatePath } from 'next/cache';
import { addMessage, deleteMessage } from '../data';

export async function postMessage(prevState, formData) {
  const name = formData.get('name')?.trim();
  const text = formData.get('text')?.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) {
  await deleteMessage(id);
  revalidatePath('/guestbook');
}

src/app/guestbook/page.js は次のとおりです。

src/app/guestbook/page.js (Server Component)
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('ko-KR')}
            </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.jsx は次のとおりです。

src/app/guestbook/MessageForm.jsx (Client)
'use client';

import { useActionState, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import { postMessage } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} style={{ padding: '6px 16px' }}>
      {pending ? '送信中...' : '投稿'}
    </button>
  );
}

export default function MessageForm() {
  const [state, formAction] = useActionState(postMessage, {});
  const formRef = useRef(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内にあるのでロード中はfallback
  • MessageForm (Client Component) — フォーム、useActionStateでサーバー状態を受け取り、useFormStatusで送信中表示
  • postMessage (Server Action) — サーバーで検証 + 保存 + revalidatePath
  • removeMessage (Server Action) — インラインで作成、フォームのactionに直接接続

APIエンドポイントは1行も作っていません。検証もサーバーで行うのでクライアントから迂回不可、データはサーバーメモリに安全に保管、画面はmutation後に自動更新。

/guestbookに移動して動作を確認してみてください。メッセージを登録し、空入力で送信してみて(エラー)、200文字を超えて入力してみて(エラー)、削除ボタンも押してみてください。ページを再読み込みしても (サーバーが落ちていなければ) メッセージはそのまま残っているはずです — メモリストアの中に。

シリーズの振り返り #

このシリーズで私たちはモダンReactのメンタルモデルの転換を扱いました。

#テーマ核心
1なぜ Next.js + Server ComponentsCSR/SSR/RSC の違い、motivation
2App Routerファイルベースルーティング、layout、動的ルート
3Server vs Client Components'use client'、境界、children パターン
4データフェッチとキャッシングasync コンポーネント、fetch オプション、並列
5Suspense と use()streaming、loading.js、skeleton
6Server Actionsmutation、useActionState、useFormStatus

最も重要なメンタルモデル2つを再度強調すると次のとおりです。

  1. 「このコードはどこで実行されるのか?」を常に意識する
  2. 基本はServer、必要なものだけClient

この2つの感覚がつかめると、モダンReactの作業が自然になります。最初は違和感がありますが、一度慣れてしまうと、再びクライアントサイドだけの思考に戻るのが難しいほどです。

ここまでの全体像 #

ブログシリーズ全体で見ると、この記事は26番目のReactの記事です。

  • Reactの基礎講座 #1〜#15 — クライアントサイドReactのファンダメンタルズ
  • ReactでTodoアプリを作る #1〜#5 — 基礎の上に小さな実戦ビルド
  • モダンReact + Next.js #1〜#6 — Server Componentsとモダンパラダイム

これをすべて追いかけてこられた方は、Reactエコシステムのほぼすべての主要な流れを一度ずつ経験したことになります。これで、自分が作りたいアプリを始めるのに十分なベースが整いました。

次の学習のおすすめ #

このシリーズを終えられたら、次のような主題に進めます。

  • TypeScript + React — 大きなコードベースの安全性 ↑。Next.js + TS は標準レベル
  • テスティング — Vitest + React Testing Library、Playwright (E2E)
  • 状態管理ライブラリ — Zustand、Jotai、Redux Toolkit (大きなアプリ)
  • データフェッチライブラリ — TanStack Query (Server Action だけでは足りないとき)
  • 認証 — NextAuth.js / Clerk / Lucia
  • DB連携 — Prisma、Drizzle、Supabase (実際の永続化)
  • デプロイ — Vercel、Cloudflare Pages、またはセルフホスティング
  • 自分の本当のプロジェクト — 結局これが最も速い学習。上のツールを必要なだけ導入しながら、小さなアプリ → 大きなアプリ

おわりに #

ここまで付いてきてくださって本当にありがとうございます。クライアントサイドReactの最初のコンポーネントから始まり、Server ComponentsとServer ActionsでフルスタックなReactアプリを作るところまで来ました。

Reactは速く進化するライブラリですが、変わらないファンダメンタルズがあります — コンポーネント単位の思考、単方向のデータフロー、宣言的なUI。このシリーズで学んだのはそのファンダメンタルズで、その上に新しいツールが積み上がり続けるはずです。新しいツールが出ても本質を知っていれば速く身につけられるという自信が、最大の収穫です。

自分のプロジェクトを直接作りながら、自分なりの小さな成功体験を積んでいってください。楽しいReact旅になりますように!

X