C
Next.js/レンダリング/Lesson 06

Server Actions — APIルートなしでmutationを直接処理する

40分·theory
このチャプター
3/5
TypeScript

Server Actions — APIルートなしでmutationを直接処理する

💡 なぜ学ぶのか? — 「フォーム送信 = APIルート + fetch + useState」時代の終焉

🎯 Pages Router 時代にフォームを一つ作る場合: `pages/api/posts.ts` を作成し、クライアントから `fetch('/api/posts', {method:'POST', body})` を呼び出し、useState でローディング・エラーを管理し、成功時に router.refresh を呼ぶ。
💼 Server Actions: 関数を一つ作って `
` に直接紐付けるだけ。API ルート・fetch・loading state がすべて不要になる。
関数の内部から DB に直接アクセスできる(Server コードとして実行されるため)。完了後は `revalidatePath` の一行でキャッシュを無効化できる。
🔗 JavaScript が無効な環境でも動作する — 通常の HTML フォームとしてフォールバックされる。Progressive Enhancement。
📈 React 19 の `useActionState` と `useFormStatus` が、フォーム UX(ローディング表示・エラー表示)を一つのフックで完結させる。
🏢 실무에서는
記事の作成・コメント・いいね・削除 — あらゆる mutation が Server Action でシンプルになる。「POST API ルートを作る → クライアントから fetch を呼ぶ → loading state を管理する → optimistic update → revalidate」という 6 ステップが「関数一つ + form action」に集約される。

'use server' · formAction · revalidatePath · useActionState

1. 'use server' の2つの配置場所

ts
// (a) ファイルの先頭 — そのファイルのすべてのexportがServer Actionになる
// 📁 app/posts/actions.ts
'use server';

export async function createPost(formData: FormData) { ... }
export async function deletePost(id: number) { ... }
ts
// (b) 関数本体の中 — その関数だけがServer Actionになる
// Server Componentの中でインライン宣言
export default async function Page() {
  async function createPost(formData: FormData) {
    'use server';
    await db.post.create({ data: { title: formData.get('title') as string } });
  }
  return <form action={createPost}><input name="title" /><button>保存</button></form>;
}

インラインは短い場合のみ使用。通常は別ファイルに分離する。

2. のシグネチャ

ts
async function createPost(formData: FormData): Promise<void> {
  'use server';
  const title = formData.get('title') as string;
  await db.post.create({ data: { title } });
  revalidatePath('/posts');
}

<form action={createPost}>
  <input name="title" />
  <button type="submit">保存</button>
</form>
  • 第1引数は常に FormData
  • 戻り値はPromiseがデフォルト。(useActionStateと一緒に使う場合はstateを返す)
  • フォームが送信されるとブラウザはサーバーへ POST リクエストを送信。Next.jsが自動的に受け取り関数を実行。

3. revalidatePath / revalidateTag — キャッシュの無効化

ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: formData.get('title') as string } });
  revalidatePath('/posts');           // /posts ページのfetchキャッシュを無効化
  // revalidateTag('posts');           // tags: ['posts'] が付いたfetchを無効化
}

新しい投稿後にリストを再表示する必要があるため、キャッシュをクリアする必要があります。これを忘れると「投稿したのに表示されない」というバグが発生します。

4. useActionState — フォーム状態フック(React 19)

tsx
'use client';
import { useActionState } from 'react';
import { createPost } from './actions';

export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, {
    error: null as string | null,
    success: false,
  });

  return (
    <form action={formAction}>
      <input name="title" required />
      <button disabled={isPending}>
        {isPending ? '保存中...' : '保存'}
      </button>
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">保存しました!</p>}
    </form>
  );
}

actionのシグネチャもstateを受け取る形にする必要があります:

ts
'use server';
export async function createPost(
  prevState: { error: string | null; success: boolean },
  formData: FormData,
) {
  const title = formData.get('title') as string;
  if (!title) return { error: 'タイトルが空です', success: false };
  try {
    await db.post.create({ data: { title } });
    revalidatePath('/posts');
    return { error: null, success: true };
  } catch (e) {
    return { error: '保存に失敗しました', success: false };
  }
}

⚠️ React 18の useFormState はReact 19で useActionState にリネームされました。新しいコードでは useActionState を使用してください。

5. useFormStatus — 子コンポーネントが親formの状態を読み取る

tsx
'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? '保存中...' : '保存'}</button>;
}

親の <form> の送信状態をhookで直接取得。props drillingは不要。

💻 🅰️ Pages Router — APIルート + クライアントfetch + useStateのボイラープレート
// ❌ Pages Router — フォーム1つにファイル3つ + ボイラープレート

// 📁 pages/api/posts.ts — APIルート
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end();
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: 'タイトルが必要です' });
  try {
    const post = await db.post.create({ data: { title } });
    return res.status(200).json(post);
  } catch (e) {
    return res.status(500).json({ error: '保存失敗' });
  }
}

// 📁 components/PostForm.tsx — クライアントフォーム
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/router';

export function PostForm() {
  const router = useRouter();
  const [title, setTitle] = useState('');
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: FormEvent) {
    e.preventDefault();
    setPending(true);
    setError(null);
    try {
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title }),
      });
      if (!res.ok) {
        const { error } = await res.json();
        throw new Error(error);
      }
      setTitle('');
      router.refresh();  // リストをリフレッシュ
    } catch (e) {
      setError(e instanceof Error ? e.message : '不明なエラー');
    } finally {
      setPending(false);
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} required />
      <button disabled={pending}>{pending ? '保存中...' : '保存'}</button>
      {error && <p>{error}</p>}
    </form>
  );
}

// 欠点:
// - ファイル2つ + 60行
// - ローディング・エラー・キャッシュ無効化を手動で実装
// - JSが有効でない環境では動作しない (onSubmit preventDefault)
// - 型安全性: API request/response の両方にインターフェースを重複定義
💻 🅱️ App Router — Server Action 1つの関数に統合
// ✅ App Router — Server Action + useActionState

// 📁 app/posts/actions.ts — Server Action群
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export type FormState = {
  error: string | null;
  success: boolean;
};

export async function createPost(
  prevState: FormState,
  formData: FormData,
): Promise<FormState> {
  const title = (formData.get('title') as string)?.trim();
  if (!title) return { error: 'タイトルが空です', success: false };

  try {
    await db.post.create({ data: { title } });
  } catch (e) {
    return { error: '保存失敗', success: false };
  }

  revalidatePath('/posts');           // リストのキャッシュを無効化
  return { error: null, success: true };
}

export async function deletePost(id: number): Promise<void> {
  'use server';
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

// 📁 app/posts/PostForm.tsx — Client (form UX)
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost, type FormState } from './actions';

const initialState: FormState = { error: null, success: false };

export function PostForm() {
  const [state, formAction] = useActionState(createPost, initialState);

  return (
    <form action={formAction}>
      <input name="title" required placeholder="タイトル" />
      <SubmitButton />
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">保存されました!</p>}
    </form>
  );
}

// 子コンポーネントから親フォームのpending状態を直接読み取る
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>
      {pending ? '保存中...' : '保存'}
    </button>
  );
}

// 📁 app/posts/page.tsx — Server Component
import { PostForm } from './PostForm';
import { deletePost } from './actions';

export default async function Posts() {
  const posts = await db.post.findMany({ orderBy: { id: 'desc' } });
  return (
    <div>
      <PostForm />
      <ul>
        {posts.map((p) => (
          <li key={p.id}>
            {p.title}
            {/* Server Action を form action として直接 — JS がなくても動作 */}
            <form action={deletePost.bind(null, p.id)}>
              <button>削除</button>
            </form>
          </li>
        ))}
      </ul>
    </div>
  );
}

// 利点:
// - ファイル1つ + 関数1つ
// - キャッシュ無効化1行 (revalidatePath)
// - JSが有効でない環境でもフォーム送信が動作 (Progressive Enhancement)
// - 型: actionのシグネチャがそのまま契約 — クライアント・サーバー両方で自動推論

💡 💡 Server Actions 実践5選

1. mutation後は必ずrevalidateする

ts
revalidatePath('/posts');     // パス単位
revalidateTag('posts');       // タグ単位(fetchのtagsオプションと対になる)

忘れると「投稿したのに表示されない」バグの第1位になります。

2. 引数バインディングでIDを渡す

tsx
<form action={deletePost.bind(null, postId)}>
  <button>削除</button>
</form>

第1引数にID、第2引数にformData。hidden inputは不要。

3. useActionStateはReact 19の標準(旧useFormStateの代替)

tsx
import { useActionState } from 'react';
const [state, formAction, isPending] = useActionState(action, initialState);

useFormState(react-dom)はdeprecated。

4. Server Action内でredirect()を呼び出してもOK

ts
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
  'use server';
  const post = await db.post.create({ ... });
  redirect(`/posts/${post.id}`);
}

throwのようにフローを終了させます — その後のコードは実行されません。

5. セキュリティ — Server Actionでも入力バリデーションは必須

クライアントでフォームをバリデーションしても、誰かが直接POSTリクエストを送れます。action内でzodなどを使って再度バリデーション:

ts
const schema = z.object({ title: z.string().min(1).max(200) });
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: '無効な入力です', success: false };

⚡ 実際に試してみる — Server Actionフローのシミュレーション

Server Actionのフォーム送信 → mutation → キャッシュ無効化のフローをシミュレーションします。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

確認クイズ

Server Action内でmutationの後に `revalidatePath('/posts')` を呼び出さなかった場合、何が起きますか?
💡 Next.js の fetch はデフォルトで force-cache のため、同じ URL のデータをキャッシュする。mutation 後に `revalidatePath` または `revalidateTag` を呼ばないとキャッシュが残り、ユーザーには古いデータが表示される。これが「DB には保存されているのに画面に反映されない」バグの原因の 80% を占める。mutation の最後には必ず revalidate を呼ぶことがパターン。
Server Actions — APIルートなしのmutation - Next.js