12
章12
データの変更
前の章では、URL検索パラメータとNext.js APIを使用して検索とページネーションを実装しました。請求書ページに、請求書の作成、更新、削除機能を追加して作業を続けましょう!
この章では...
以下は扱うトピックです。
Reactサーバーアクションとは何か、そしてそれらを使用してデータを変更する方法。
フォームとサーバーコンポーネントを扱う方法。
型検証を含む、ネイティブのformData
オブジェクトを扱うためのベストプラクティス。
revalidatePath
APIを使用してクライアントキャッシュを再検証する方法。
特定のIDを持つ動的なルートセグメントを作成する方法。
サーバーアクションとは?
Reactサーバーアクションを使用すると、サーバー上で非同期コードを直接実行できます。データの変更にAPIエンドポイントを作成する必要がなくなります。代わりに、サーバー上で実行され、クライアントまたはサーバーコンポーネントから呼び出すことができる非同期関数を作成します。
Webアプリケーションはさまざまな脅威に脆弱になる可能性があるため、セキュリティは最優先事項です。そこでサーバーアクションが登場します。これらは効果的なセキュリティソリューションを提供し、さまざまな種類の攻撃から保護し、データを保護し、承認されたアクセスを確保します。サーバーアクションは、POSTリクエスト、暗号化されたクロージャ、厳格な入力チェック、エラーメッセージのハッシュ化、ホスト制限などの技術を通じてこれを実現し、すべてが連携してアプリの安全性を大幅に向上させます。
サーバーアクションでフォームを使用する
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>;
}
サーバーコンポーネント内でサーバーアクションを呼び出す利点は、プログレッシブエンハンスメントです。クライアントでJavaScriptが無効になっている場合でも、フォームは機能します。
サーバーアクションを使用したNext.js
サーバーアクションは、Next.jsのキャッシュとも深く統合されています。フォームがサーバーアクションを通じて送信されると、アクションを使用してデータを変更できるだけでなく、revalidatePath
やrevalidateTag
などのAPIを使用して関連するキャッシュを再検証することもできます。
すべてがどのように連携するかを見てみましょう!
請求書の作成
新しい請求書を作成するために行う手順は次のとおりです。
- ユーザーの入力をキャプチャするためのフォームを作成します。
- サーバーアクションを作成し、フォームからそれを呼び出します。
- サーバーアクション内で、
formData
オブジェクトからデータを抽出します。 - データベースに挿入するデータを検証して準備します。
- データを挿入し、エラーを処理します。
- キャッシュを再検証し、ユーザーを請求書ページにリダイレクトします。
1. 新しいルートとフォームを作成する
まず、/invoices
フォルダー内に、/create
という新しいルートセグメントとpage.tsx
ファイルを追加します。


このルートを使用して新しい請求書を作成します。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>
コンポーネントに渡すサーバーコンポーネントです。時間を節約するために、<Form>
コンポーネントはすでに作成されています。
<Form>
コンポーネントに移動すると、フォームに次のものがあることがわかります。
- 顧客のリストを持つ1つの
<select>
(ドロップダウン)要素。 type="number"
を持つ金額用の1つの<input>
要素。type="radio"
を持つステータス用の2つの<input>
要素。type="submit"
を持つ1つのボタン。
http://localhost:3000/dashboard/invoices/createで、次のUIが表示されるはずです。


2. サーバーアクションを作成する
素晴らしい、次に、フォームが送信されたときに呼び出されるサーバーアクションを作成しましょう。
lib
ディレクトリに移動し、actions.ts
という名前の新しいファイルを作成します。このファイルの先頭に、Reactのuse server
ディレクティブを追加します。
'use server';
'use server'
を追加すると、ファイル内のすべてのエクスポートされた関数がサーバーアクションとしてマークされます。これらのサーバー関数は、クライアントおよびサーバーコンポーネントでインポートして使用できます。
アクション内に"use server"
を追加して、サーバーコンポーネント内にサーバーアクションを直接記述することもできます。ただし、このコースでは、すべてを個別のファイルに整理します。
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はアクションを呼び出すことができるように、その上に構築します。舞台裏では、サーバーアクションは
POST
APIエンドポイントを作成します。これが、サーバーアクションを使用する場合に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 の
entries()
メソッドと、Object.fromEntries()
メソッドを組み合わせて使うことを検討すると良いでしょう。例えば
const rawFormData = Object.fromEntries(formData.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
が number
ではなく string
型になっていることに気付くでしょう。これは、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 { sql } from '@vercel/postgres';
// ...
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 { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
// ...
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 { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// ...
export async function createInvoice(formData: FormData) {
// ...
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
おめでとうございます!初めてのサーバーアクションを実装しました。新しい請求書を追加してテストしてみてください。すべてが正しく動作していれば
- 送信時に
/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=%2Flearn%2Flight%2Fedit-invoice-route.png&w=3840&q=75&dpl=dpl_4FFcrev3cFP2zwnf13Q6F7Kw9i3v)
![Invoices folder with a nested [id] folder, and an edit folder inside it](/_next/image?url=%2Flearn%2Fdark%2Fedit-invoice-route.png&w=3840&q=75&dpl=dpl_4FFcrev3cFP2zwnf13Q6F7Kw9i3v)
<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
がundefined
になる可能性があるため、ターミナルでinvoice
プロパティに関する一時的なTSエラーが表示されます。今のところは心配しないでください。次の章でエラー処理を追加するときに解決します。
素晴らしい! さて、すべてが正しく配線されていることをテストしてください。 http://localhost:3000/dashboard/invoices にアクセスし、請求書を編集するために鉛筆アイコンをクリックします。ナビゲーション後、請求書の詳細が事前に入力されたフォームが表示されるはずです。


URLも、次のようにid
で更新される必要があります:http://localhost:3000/dashboard/invoice/uuid/edit
UUIDと自動インクリメントキー
インクリメントキー(例:1、2、3など)の代わりにUUIDを使用します。これにより、URLは長くなりますが、UUIDはIDの衝突のリスクを排除し、グローバルに一意であり、列挙攻撃のリスクを軽減するため、大規模なデータベースに最適です。
ただし、よりクリーンなURLを好む場合は、自動インクリメントキーの使用を好むかもしれません。
4. id
をサーバーアクションに渡す
最後に、データベース内の正しいレコードを更新できるように、id
をサーバーアクションに渡します。次のように、id
を引数として渡すことはできません。
// Passing an id as argument won't work
<form action={updateInvoice(id)}>
代わりに、JSのbind
を使用して、id
をサーバーアクションに渡すことができます。これにより、サーバーアクションに渡される値がエンコードされるようになります。
// ...
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}>
<input type="hidden" name="id" value={invoice.id} />
</form>
);
}
注:フォームに隠し入力フィールドを使用することもできます(例:
<input type="hidden" name="id" value={invoice.id} />
)。ただし、値はHTMLソースにフルテキストとして表示されます。これは、IDのような機密データには理想的ではありません。
次に、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
を呼び出します。
請求書を編集してテストします。フォームを送信すると、請求書ページにリダイレクトされ、請求書が更新されるはずです。
請求書の削除
サーバーアクションを使用して請求書を削除するには、削除ボタンを<form>
要素で囲み、bind
を使用してid
をサーバーアクションに渡します。
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
を呼び出すと、新しいサーバーリクエストがトリガーされ、テーブルが再レンダリングされます。
さらに詳しく読む
この章では、サーバーアクションを使用してデータを変更する方法を学びました。また、Next.jsキャッシュを再検証するためのrevalidatePath
APIの使用方法と、ユーザーを新しいページにリダイレクトするためのredirect
の使用方法も学びました。
サーバーアクションのセキュリティの詳細については、こちらをご覧ください。
章を完了しました12
おめでとうございます!フォームとReactサーバーアクションを使用してデータを変更する方法を学びました。
これは役に立ちましたか?