12
章12
データの変更
前の章では、URL検索パラメータとNext.js APIを使用して検索とページネーションを実装しました。引き続き「Invoices」ページで、請求書の作成、更新、削除の機能を追加していきましょう!
この章の内容...
取り上げるトピックは以下の通りです
React Server Actionsとは何か、そしてそれらを使ってデータを変更する方法。
フォームとServer Componentsの連携方法。
ネイティブのFormData
オブジェクトを扱うためのベストプラクティス(型検証を含む)。
revalidatePath
APIを使ってクライアントキャッシュを再検証する方法。
特定のIDを持つ動的なルーティングセグメントを作成する方法。
Server Actionsとは?
React Server Actionsを使用すると、非同期コードをサーバー上で直接実行できます。これにより、データを変更するためのAPIエンドポイントを作成する必要がなくなります。代わりに、サーバー上で実行され、クライアントまたはサーバーコンポーネントから呼び出すことができる非同期関数を記述します。
ウェブアプリケーションにとってセキュリティは最優先事項であり、さまざまな脅威に対して脆弱である可能性があります。そこでServer Actionsの出番です。これには、暗号化されたクロージャ、厳格な入力チェック、エラーメッセージのハッシュ化、ホスト制限などの機能が含まれており、これらすべてが連携してアプリケーションのセキュリティを大幅に強化します。
Server Actionsでのフォームの使用
Reactでは、<form>
要素のaction
属性を使用してアクションを呼び出すことができます。このアクションは、キャプチャされたデータを含むネイティブのFormDataオブジェクトを自動的に受け取ります。
例
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
Server Component内でServer Actionを呼び出す利点は、プログレッシブエンハンスメントです。つまり、JavaScriptがクライアントにまだロードされていなくてもフォームが機能します。例えば、インターネット接続が遅い場合でも問題ありません。
Server ActionsとNext.js
Server ActionsはNext.jsのキャッシングと深く統合されています。Server Actionを介してフォームが送信されると、アクションを使用してデータを変更できるだけでなく、revalidatePath
やrevalidateTag
のようなAPIを使用して関連するキャッシュを再検証することもできます。
すべてがどのように連携するのか見ていきましょう!
請求書の作成
新しい請求書を作成するための手順は次のとおりです
- ユーザーの入力を取得するためのフォームを作成します。
- Server Actionを作成し、フォームから呼び出します。
- Server Action内で、
formData
オブジェクトからデータを抽出します。 - データを検証し、データベースに挿入する準備をします。
- データを挿入し、エラーを処理します。
- キャッシュを再検証し、ユーザーを請求書ページにリダイレクトします。
1. 新しいルートとフォームを作成する
まず、/invoices
フォルダー内に、page.tsx
ファイルを含む/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>
);
}
あなたのページは、customers
をフェッチし、それを<Form>
コンポーネントに渡すServer Componentです。時間を節約するために、<Form>
コンポーネントはすでに作成済みです。
<Form>
コンポーネントに移動すると、フォームが以下のようになっていることがわかります
- 顧客リストを含む1つの
<select>
(ドロップダウン)要素があります。 - 金額用の
type="number"
を持つ1つの<input>
要素があります。 - ステータス用の
type="radio"
を持つ2つの<input>
要素があります。 type="submit"
を持つ1つのボタンがあります。
https://:3000/dashboard/invoices/createで、以下のUIが表示されるはずです

2. Server Actionを作成する
では、フォームが送信されたときに呼び出されるServer Actionを作成しましょう。
lib/
ディレクトリに移動し、actions.ts
という新しいファイルを作成します。このファイルの先頭にReactのuse server
ディレクティブを追加します。
'use server';
'use server'
を追加することで、ファイル内のエクスポートされたすべての関数がServer Actionとしてマークされます。これらのサーバー関数は、クライアントおよびサーバーコンポーネントでインポートして使用できます。このファイルに含まれる関数で、使用されないものは、最終的なアプリケーションバンドルから自動的に削除されます。
Server Actionは、アクション内に"use server"
を追加することで、Server Component内に直接記述することもできます。しかし、このコースでは、すべてを別々のファイルにまとめておきます。アクション用のファイルを個別に持つことをお勧めします。
actions.ts
ファイルで、formData
を受け取る新しい非同期関数を作成します。
'use server';
export async function createInvoice(formData: FormData) {}
次に、<Form>
コンポーネントで、actions.ts
ファイルからcreateInvoice
をインポートします。<form>
要素にaction
属性を追加し、createInvoice
アクションを呼び出します。
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}>
// ...
)
}
豆知識:HTMLでは、
action
属性にURLを渡します。このURLは、フォームデータが送信される宛先(通常はAPIエンドポイント)となります。しかし、Reactでは
action
属性は特殊なプロパティと見なされ、Reactはそれに基づいてアクションが呼び出せるように構築されています。舞台裏では、Server Actionsが
POST
APIエンドポイントを作成します。これが、Server Actionsを使用する際にAPIエンドポイントを手動で作成する必要がない理由です。
3. formData
からデータを抽出する
actions.ts
ファイルに戻り、formData
の値を抽出する必要があります。使用できるいくつかのメソッドがあります。この例では、.get(name)
メソッドを使用しましょう。
'use server';
export async function createInvoice(formData: FormData) {
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
};
// Test it out:
console.log(rawFormData);
}
ヒント:フィールドが多いフォームを扱っている場合、JavaScriptの
Object.fromEntries()
と一緒にentries()
メソッドを使用することを検討すると良いでしょう。
すべてが正しく接続されているか確認するために、フォームを試してみてください。送信後、入力したデータがターミナルにログ(ブラウザではなく)されるはずです。
これでデータがオブジェクトの形になったので、はるかに扱いやすくなります。
4. データを検証し、準備する
フォームデータをデータベースに送信する前に、それが正しい形式と正しい型であることを確認する必要があります。このコースの初期段階で覚えているかもしれませんが、請求書テーブルは以下の形式でデータを期待しています
export type Invoice = {
id: string; // Will be created on the database
customer_id: string;
amount: number; // Stored in cents
status: 'pending' | 'paid';
date: string;
};
今のところ、フォームからはcustomer_id
、amount
、status
のみを取得しています。
型の検証と強制
フォームからのデータがデータベースの期待する型と一致することを検証することが重要です。例えば、アクション内にconsole.log
を追加すると
console.log(typeof rawFormData.amount);
amount
がstring
型であり、number
型ではないことに気づくでしょう。これは、type="number"
を持つinput
要素が実際には文字列を返し、数値ではないためです!
型検証を処理するにはいくつかの選択肢があります。型を手動で検証することもできますが、型検証ライブラリを使用すると時間と労力を節約できます。この例では、このタスクを簡素化できるTypeScriptファーストの検証ライブラリであるZodを使用します。
actions.ts
ファイルで、Zodをインポートし、フォームオブジェクトの形状に一致するスキーマを定義します。このスキーマは、データベースに保存する前にformData
を検証します。
'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) {
// ...
}
amount
フィールドは、その型を検証しながら、文字列から数値に強制変換(変更)するように特別に設定されています。
次に、rawFormData
をCreateInvoice
に渡し、型を検証できます。
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
}
セント単位で値を保存する
JavaScriptの浮動小数点エラーをなくし、より高い精度を確保するために、データベースで金額をセント単位で保存するのが一般的です。
金額をセントに変換しましょう
// ...
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. データベースにデータを挿入する
データベースに必要なすべての値が揃ったので、SQLクエリを作成して新しい請求書をデータベースに挿入し、変数を渡すことができます。
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には、ユーザーのブラウザにルーティングセグメントを一時的に保存するクライアントサイドのルーターキャッシュがあります。プリフェッチと合わせて、このキャッシュはユーザーがルート間を素早く移動できるようにし、サーバーへのリクエスト数を減らします。
請求書ルートに表示されるデータを更新しているため、このキャッシュをクリアし、サーバーへの新しいリクエストをトリガーしたいでしょう。これはNext.jsのrevalidatePath
関数を使って行うことができます。
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
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})
`;
revalidatePath('/dashboard/invoices');
}
データベースが更新されると、/dashboard/invoices
パスが再検証され、新しいデータがサーバーからフェッチされます。
この時点で、ユーザーを/dashboard/invoices
ページにリダイレクトしたいと思うでしょう。これはNext.jsのredirect
関数を使って行うことができます。
'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) {
// ...
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
おめでとうございます!初めてのServer Actionを実装しました。新しい請求書を追加して試してみてください。すべてが正しく動作していれば
- 送信後、
/dashboard/invoices
ルートにリダイレクトされるはずです。 - テーブルの最上部に新しい請求書が表示されるはずです。
請求書の更新
請求書を更新するフォームは、請求書作成フォームと似ていますが、データベースのレコードを更新するために請求書id
を渡す必要があります。請求書id
の取得方法と渡し方を見てみましょう。
請求書を更新するための手順は次のとおりです
- 請求書
id
を含む新しい動的なルーティングセグメントを作成します。 - ページパラメータから請求書
id
を読み取ります。 - データベースから特定の請求書をフェッチします。
- フォームに請求書データを事前入力します。
- データベースの請求書データを更新します。
1. 請求書id
を含む動的ルーティングセグメントを作成する
Next.jsでは、正確なセグメント名が不明で、データに基づいてルートを作成したい場合に動的ルーティングセグメントを作成できます。これはブログ記事のタイトルや製品ページなどです。フォルダー名を角括弧で囲むことで動的なルーティングセグメントを作成できます。例えば、 [id]
、 [post]
、または[slug]
です。
/invoices
フォルダー内に、[id]
という新しい動的ルートを作成し、次にpage.tsx
ファイルを含むedit
という新しいルートを作成します。ファイル構造は以下のようになります
![Invoices folder with a nested [id] folder, and an edit folder inside it](/_next/image?url=https%3A%2F%2Fh8DxKfmAPhn8O0p3.public.blob.vercel-storage.com%2Flearn%2Flight%2Fedit-invoice-route.png&w=3840&q=75)
<Table>
コンポーネントには、テーブルレコードから請求書のid
を受け取る<UpdateInvoice />
ボタンがあることに注目してください。
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>
// ...
);
}
<UpdateInvoice />
コンポーネントに移動し、Link
のhref
を更新してid
プロパティを受け入れるようにします。テンプレートリテラルを使用して動的なルーティングセグメントにリンクできます。
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
を読み取る
<Page>
コンポーネントに戻り、以下のコードを貼り付けます。
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() {
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>
);
}
/create
請求書ページと似ている点に注目してください。ただし、別のフォーム(edit-form.tsx
ファイルから)をインポートしています。このフォームは、顧客名、請求額、ステータスのdefaultValue
で事前入力される必要があります。フォームフィールドを事前入力するには、id
を使用して特定の請求書をフェッチする必要があります。
searchParams
に加えて、ページコンポーネントはparams
というプロパティも受け入れます。これを使ってid
にアクセスできます。<Page>
コンポーネントを更新してこのプロパティを受け取るようにします。
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 params = await props.params;
const id = params.id;
// ...
}
3. 特定の請求書をフェッチする
次に
fetchInvoiceById
という新しい関数をインポートし、id
を引数として渡します。- ドロップダウンの顧客名を取得するために
fetchCustomers
をインポートします。
Promise.all
を使用して、請求書と顧客の両方を並行してフェッチできます。
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 params = await props.params;
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
// ...
}
ターミナルでinvoice
プロパティの一時的なTypeScriptエラーが表示されるでしょう。これはinvoice
がundefined
になる可能性があるためです。今は気にしないでください。エラーハンドリングを追加する次の章で解決します。
素晴らしい!これで全てが正しく接続されているかテストしましょう。https://:3000/dashboard/invoicesにアクセスし、鉛筆アイコンをクリックして請求書を編集してください。ナビゲーション後、請求書の詳細が事前入力されたフォームが表示されるはずです。

URLもid
で以下のように更新されるはずです: https://:3000/dashboard/invoice/uuid/edit
UUIDと自動増分キーの比較
増分キー(例: 1, 2, 3など)の代わりにUUIDを使用します。これによりURLは長くなりますが、UUIDはID衝突のリスクを排除し、グローバルに一意であり、列挙攻撃のリスクを低減するため、大規模データベースに最適です。
ただし、よりクリーンなURLを好む場合は、自動増分キーの使用を推奨します。
4. id
をServer Actionに渡す
最後に、データベース内の正しいレコードを更新できるように、id
をServer Actionに渡します。以下のように引数としてid
を渡すことはできません
// Passing an id as argument won't work
<form action={updateInvoice(id)}>
代わりに、JSのbind
を使用してid
をServer Actionに渡すことができます。これにより、Server Actionに渡される値が確実にエンコードされます。
// ...
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>;
}
注:フォームで非表示の入力フィールドを使用することも機能します(例:
<input type="hidden" name="id" value={invoice.id} />
)。ただし、値がHTMLソースにプレーンテキストとして表示されるため、機密データには理想的ではありません。
次に、actions.ts
ファイルで、新しいアクションupdateInvoice
を作成します。
// Use Zod to update the expected types
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');
}
createInvoice
アクションと同様に、ここでは以下のことを行います。
formData
からデータを抽出します。- Zodで型を検証します。
- 金額をセントに変換します。
- 変数をSQLクエリに渡します。
revalidatePath
を呼び出してクライアントキャッシュをクリアし、新しいサーバーリクエストを行います。redirect
を呼び出してユーザーを請求書ページにリダイレクトします。
請求書を編集して試してみてください。フォームを送信した後、請求書ページにリダイレクトされ、請求書が更新されるはずです。
請求書の削除
Server Actionを使用して請求書を削除するには、削除ボタンを<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>
);
}
actions.ts
ファイル内に、deleteInvoice
という新しいアクションを作成します。
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
このアクションは/dashboard/invoices
パスで呼び出されるため、redirect
を呼び出す必要はありません。revalidatePath
を呼び出すことで、新しいサーバーリクエストがトリガーされ、テーブルが再レンダリングされます。
参考文献
この章では、Server Actionsを使用してデータを変更する方法を学びました。また、revalidatePath
APIを使用してNext.jsキャッシュを再検証する方法、およびredirect
を使用してユーザーを新しいページにリダイレクトする方法も学びました。
追加学習のために、Server Actionsのセキュリティについても詳しく読むことができます。
章の完了12
おめでとうございます!フォームとReact Server Actionsを使用してデータを変更する方法を学びました。
これは役立ちましたか?