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 のキャッシュ機構と統合されています。
フォーム送信でアクションを実行する際に,データを更新するだけでなく,revalidatePath や revalidateTag で 関連するキャッシュを再検証できます。
請求書(Invoice)を作成する
実装のステップは次のとおりです。
- フォームを作ってユーザー入力を受け取る
- Server Action を作り,フォームから呼び出す
- Action 内で
formDataから値を取り出す - DB へ挿入できるように 検証・整形 する
- 挿入し,エラーを適切に処理する
- キャッシュ再検証と リダイレクト を行う
1. ルートとフォームの作成
/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 を作成し,ファイル先頭にディレクティブを追加します。
このファイルから エクスポートされる関数はすべて Server Action として扱われ,クライアント/サーバー双方から利用可能です(未使用の関数はビルドから除外されます)。
formData を受け取るアクションを定義します。
フォーム側でアクションを action 属性に渡します。
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 から値を取得
'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 テーブルの型は以下のとおり(抜粋):
export type Invoice = {
id: string; // DB 側で付与
customer_id: string;
amount: number; // セント単位で保存
status: 'pending' | 'paid';
date: string;
};
<input type="number"> でも 値は文字列として届くため,型変換が必要です。
ここでは TypeScript フレンドリーなバリデーションライブラリ Zod を使います。
'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 をスキーマでパースします。
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
}
金額はセント単位で保存するのが定石です(浮動小数の誤差を避けるため)。
// ...
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):
// ...
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 へ挿入
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 のクライアントルータは プリフェッチ+キャッシュで体感速度を高めます。 データを更新したら,該当パスのキャッシュを無効化して最新データを取得させましょう。
'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 を受け渡す必要があります。
手順:
- 動的ルートセグメント(
[id])を作成 - ページの
paramsからidを読む idに対応する請求書を DB から取得- フォームの 初期値 を埋める
- DB を 更新 する
1. 動的ルートセグメント [id] を作成
/invoices 配下に [id]/edit の構造を作ります。<Table> 内の編集ボタンは各行の id を受け取ります。

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>
// ...
);
}
ボタンのリンクを動的パスへ。
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 を受け取れる形に更新しましょう。
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. 対象の請求書を取得(並列で顧客も)
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 を使用)

4. Server Action へ ID を渡す
<form action={updateInvoice(id)}> のような直接渡しは不可なので,bind を使ってエンコードされた形で渡します。
// ...
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>;
}
アクションを実装:
// 期待型を更新
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 を呼びます。
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>
);
}
アクション本体:
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 のセキュリティに関する詳細も,あわせて確認してみてください。