コンテンツにスキップ

Chapter 12: データの更新

検索とページネーションで参照体験が整ったので,次はデータを変更する機能を追加します。React Server Actions を活用し,請求書の作成・編集・削除を安全に実装しましょう。


この章で学ぶこと

  • React Server Actions とは何か,そしてそれを使ってデータを更新する方法
  • フォームServer Componentsの連携方法
  • ネイティブな FormData のベストプラクティス(型の検証・変換を含む)
  • revalidatePath API を使ってクライアントキャッシュを再検証する方法
  • 特定の ID を持つ 動的ルートセグメント の作成方法

Server Actions とは?

React Server Actions は,サーバー上で非同期コードを直接実行できる仕組みです。 データ更新のために API エンドポイントを別途作る必要がなく,サーバーで実行される非同期関数を定義して,クライアント/サーバーいずれのコンポーネントからでも呼び出せます

セキュリティ面も考慮されており,暗号化されたクロージャ,厳格な入力チェック,エラーメッセージのハッシュ化ホスト制限などが組み合わさって,アプリのセキュリティ向上に寄与します。


フォームと Server Actions の連携

React では,<form>action 属性で Server Action を呼び出せます。 action に渡した関数には,送信された ネイティブの FormData が渡されます。

(サーバーコンポーネントの例)
export default function Page() {
  // アクション
  async function create(formData: FormData) {
    'use server';
    // データ更新ロジック...
  }

  // "action" 属性でアクションを呼び出す
  return <form action={create}>...</form>;
}

Server Component から Server Action を呼ぶ利点のひとつは プログレッシブエンハンスメントです。 クライアントに JavaScript がまだ読み込まれていなくても,フォーム送信が機能します(通信が遅い環境など)。


Next.js と Server Actions

Server Actions は Next.js のキャッシュ機構と統合されています。 フォーム送信でアクションを実行する際に,データを更新するだけでなく,revalidatePathrevalidateTag関連するキャッシュを再検証できます。


請求書(Invoice)を作成する

実装のステップは次のとおりです。

  1. フォームを作ってユーザー入力を受け取る
  2. Server Action を作り,フォームから呼び出す
  3. Action 内で formData から値を取り出す
  4. DB へ挿入できるように 検証・整形 する
  5. 挿入し,エラーを適切に処理する
  6. キャッシュ再検証リダイレクト を行う

1. ルートとフォームの作成

/invoices 配下に create セグメントを追加し,page.tsx を作成します。

alt text

/dashboard/invoices/create/page.tsx
import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';

export default async function Page() {
  const customers = await fetchCustomers();

  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Create Invoice',
            href: '/dashboard/invoices/create',
            active: true,
          },
        ]}
      />
      <Form customers={customers} />
    </main>
  );
}

このページは Server Component で,顧客一覧を取得して <Form> に渡します。 <Form> はあらかじめ用意済みで,以下を含みます:

  • 顧客用の <select>
  • 金額用の <input type="number">
  • ステータス用の <input type="radio"> が2つ
  • 送信用 <button type="submit">

http://localhost:3000/dashboard/invoices/create で UI が表示されます。


2. Server Action を作成

/app/lib/actions.ts を作成し,ファイル先頭にディレクティブを追加します。

/app/lib/actions.ts
'use server';

このファイルから エクスポートされる関数はすべて Server Action として扱われ,クライアント/サーバー双方から利用可能です(未使用の関数はビルドから除外されます)。

formData を受け取るアクションを定義します。

/app/lib/actions.ts
'use server';

export async function createInvoice(formData: FormData) {}

フォーム側でアクションを action 属性に渡します。

/app/ui/invoices/create-form.tsx
import { CustomerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
  CheckIcon,
  ClockIcon,
  CurrencyDollarIcon,
  UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice } from '@/app/lib/actions';

export default function Form({
  customers,
}: {
  customers: CustomerField[];
}) {
  return (
    <form action={createInvoice}>
      {/* ... */}
    </form>
  );
}

メモ:HTML では action に URL を渡しますが,React では action特別な propとして扱われ,裏側で POST のエンドポイントが自動生成されます。 つまり Server Actions を使うと,手動で API ルートを作らなくてよいのです。


3. formData から値を取得

/app/lib/actions.ts
'use server';

export async function createInvoice(formData: FormData) {
  const rawFormData = {
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  };
  // 動作確認:
  console.log(rawFormData);
}

フィールドが多い場合は Object.fromEntries(formData.entries()) も便利です。 フォーム送信後,サーバーのターミナルに入力値が出力されれば接続は OK です。


4. データの検証と整形

DB に送る前に,期待する型に合っているかを検証します。 invoices テーブルの型は以下のとおり(抜粋):

/app/lib/definitions.ts
export type Invoice = {
  id: string;           // DB 側で付与
  customer_id: string;
  amount: number;       // セント単位で保存
  status: 'pending' | 'paid';
  date: string;
};

<input type="number"> でも 値は文字列として届くため,型変換が必要です。 ここでは TypeScript フレンドリーなバリデーションライブラリ Zod を使います。

/app/lib/actions.ts
'use server';

import { z } from 'zod';

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),      // 文字列→数値へ強制変換+検証
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});

const CreateInvoice = FormSchema.omit({ id: true, date: true });

export async function createInvoice(formData: FormData) {
  // ...
}

rawFormData をスキーマでパースします。

/app/lib/actions.ts
// ...
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
}

金額はセント単位で保存するのが定石です(浮動小数の誤差を避けるため)。

/app/lib/actions.ts
// ...
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
}

請求日の作成(YYYY-MM-DD):

/app/lib/actions.ts
// ...
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
}

5. DB へ挿入

/app/lib/actions.ts
import { z } from 'zod';
import postgres from 'postgres';

const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });

// ...

export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];

  await sql`
    INSERT INTO invoices (customer_id, amount, status, date)
    VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
  `;
}

(エラーハンドリングは次章で扱います)


6. 再検証とリダイレクト

Next.js のクライアントルータは プリフェッチ+キャッシュで体感速度を高めます。 データを更新したら,該当パスのキャッシュを無効化して最新データを取得させましょう。

/app/lib/actions.ts
'use server';

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

const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });

// ...

export async function createInvoice(formData: FormData) {
  // ... INSERT まで完了
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

送信後は /dashboard/invoices にリダイレクトされ,テーブル先頭に新しい請求書が表示されます。


請求書の更新(Update)

更新の流れは作成時と似ていますが,更新対象の ID を受け渡す必要があります。

手順:

  1. 動的ルートセグメント[id])を作成
  2. ページの params から id を読む
  3. id に対応する請求書を DB から取得
  4. フォームの 初期値 を埋める
  5. DB を 更新 する

1. 動的ルートセグメント [id] を作成

/invoices 配下に [id]/edit の構造を作ります。<Table> 内の編集ボタンは各行の id を受け取ります。

alt text

/app/ui/invoices/table.tsx
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  return (
    // ...
    <td className="flex justify-end gap-2 whitespace-nowrap px-6 py-4 text-sm">
      <UpdateInvoice id={invoice.id} />
      <DeleteInvoice id={invoice.id} />
    </td>
    // ...
  );
}

ボタンのリンクを動的パスへ。

/app/ui/invoices/buttons.tsx
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';

export function UpdateInvoice({ id }: { id: string }) {
  return (
    <Link
      href={`/dashboard/invoices/${id}/edit`}
      className="rounded-md border p-2 hover:bg-gray-100"
    >
      <PencilIcon className="w-5" />
    </Link>
  );
}

2. params から id を取得

/create のページとほぼ同じ構成ですが、編集ページでは URL の id を使って対象の請求書を取得する必要があります。まずはベースとなるコンポーネントを用意し、params から id を受け取れる形に更新しましょう。

/app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';

export default async function Page(props: { params: Promise<{ id: string }> }) {
  const { id } = await props.params;

  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Edit Invoice',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      <Form invoice={invoice} customers={customers} />
    </main>
  );
}

props.params は App Router のページコンポーネントに暗黙で渡されるオブジェクトで、ここから id を取り出せます。この段階では invoice が未定義なので、次のステップで実際のレコードを取得してフォームを初期化します。


3. 対象の請求書を取得(並列で顧客も)

/app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';

export default async function Page(props: { params: Promise<{ id: string }> }) {
  const { id } = await props.params;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);

  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          { label: 'Edit Invoice', href: `/dashboard/invoices/${id}/edit`, active: true },
        ]}
      />
      <Form invoice={invoice} customers={customers} />
    </main>
  );
}

<Form>edit-form.tsx)は受け取った invoice の値で defaultValue を設定し,初期表示します。 URL 例:/dashboard/invoices/<uuid>/edit(UUID を使用)

alt text


4. Server Action へ ID を渡す

<form action={updateInvoice(id)}> のような直接渡しは不可なので,bind を使ってエンコードされた形で渡します。

/app/ui/invoices/edit-form.tsx
// ...
import { updateInvoice } from '@/app/lib/actions';

export default function EditInvoiceForm({
  invoice,
  customers,
}: {
  invoice: InvoiceForm;
  customers: CustomerField[];
}) {
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
  return <form action={updateInvoiceWithId}>{/* ... */}</form>;
}

アクションを実装:

/app/lib/actions.ts
// 期待型を更新
const UpdateInvoice = FormSchema.omit({ id: true, date: true });

export async function updateInvoice(id: string, formData: FormData) {
  const { customerId, amount, status } = UpdateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });

  const amountInCents = amount * 100;

  await sql`
    UPDATE invoices
    SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
    WHERE id = ${id}
  `;

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

請求書の削除(Delete)

削除ボタンを <form> で包み,bind で ID を渡して Server Action を呼びます。

/app/ui/invoices/buttons.tsx
import { deleteInvoice } from '@/app/lib/actions';

// ...

export function DeleteInvoice({ id }: { id: string }) {
  const deleteInvoiceWithId = deleteInvoice.bind(null, id);
  return (
    <form action={deleteInvoiceWithId}>
      <button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
        <span className="sr-only">Delete</span>
        <TrashIcon className="w-4" />
      </button>
    </form>
  );
}

アクション本体:

/app/lib/actions.ts
export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');
}

削除は同一パス内の処理なので redirect は不要です。revalidatePath でテーブルが再取得・再描画されます。


さらに学ぶ

この章では,Server Actions を使った 作成・更新・削除 を実装し, revalidatePath でキャッシュを再検証し,必要に応じて redirect で遷移する流れを学びました。 Server Actions のセキュリティに関する詳細も,あわせて確認してみてください。