前回
Next.jsのチュートリアルをやってみる【検索とページネーション編】はじめに
前回はURL検索パラメーターとNext.js APIを使用して検索とページネーションを実装しました。
今回は請求書ページに請求書を作成・更新・削除する機能を追加していきます。
サーバーアクションとは?
React Server Actions を使用すると、非同期コードをサーバー上で直接実行することができます。そうすることで、データを変更するためのAPIエンドポイントを作成する必要がなくなります。その代わりに、サーバー上で実行される非同期関数を記述し、クライアント・コンポーネントやサーバー・コンポーネントから呼び出すことができます。
Webアプリケーションは様々な脅威にさらされやすいため、セキュリティは最優先事項です。そこでサーバーアクションの出番です。効果的なセキュリティソリューションを提供し、さまざまなタイプの攻撃から保護し、許可されたアクセスを保証します。Server ActionsはPOSTリクエスト、暗号化されたクロージャ、厳密な入力チェック、エラーメッセージのハッシュ化、ホストの制限などの技術によってこれを実現し、これらすべてが連携
サーバーアクションでフォームを使用する
Reactでは<form>
要素のaction
属性を使ってアクションを呼び出すことができます。アクションは自動的に取り込んだデータを含むネイティブのFormDataオブジェクトを受け取ります。
サーバーコンポーネント内でサーバーアクションを呼び出すメリットは、プログレッシブ・エンハンスメントの手法をとるためです。
Next.jsとサーバーアクション
サーバーアクションはNext.jsのキャッシュとも深く統合されています。サーバーアクションでフォームが送信されると、アクションを使用してデータを変更できるだけではなく、revalidatePath
やrevalidateTag
などのAPIを使用して、関連するキャッシュを再検証することもできます。
請求書の作成
以下の手順で請求書を作成する機能を実装していきましょう。
- ユーザーの入力を取得するフォームを作成する。
- サーバーアクションを作成し、フォームから呼び出す。
- サーバーアクション内で、
formData
オブジェクトからデータを抽出する。 - データを検証し、データベースに挿入する準備をする。
- データを挿入し、エラーを処理する。
- キャッシュを再検証し、ユーザーを請求書ページにリダイレクトする。
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>
);
}
パンくずリストと請求書作成フォームで構成されているページです。
このページは顧客データをフェッチして<Form>
コンポーネントに渡すサーバー・コンポーネントです。<Form>
はすでに用意されていますので、サーバーが起動されていることを確認し、http://localhost:3000/dashboard/invoices/createにアクセスして以下のUIが描画されるかを確認してみましょう。
2. サーバーアクションの作成
続いて、フォームが送信された際に呼び出されるサーバーアクションを作成しましょう。lib
ディレクトリに移動し、action.ts
という名前の新しいファイルを作成します。
初めにuse server
ディレクティブを追加しましょう。
'use server';
ファイル内にエクスポートされたすべての関数をサーバーアクションとしてマークすることができます。サーバー関数はクライアントコンポーネントやサーバーコンポーネントにインポートして使用することができます。
また、アクションの中にuse server
を追加することで、サーバーコンポーネントの中に直接サーバーアクションを記述することもできます。このチュートリアルではすでに用意されています。
action.ts
にFormData
を受け取る新しい非同期関数を作成しましょう。
'use server';
export async function createInvoice(formData: FormData) {}
<Form>
コンポーネントに、actions.ts
ファイルからcreateInvoice
をインポートします。<Form>
要素にaction
属性を追加し、createInvoice
アクションを呼び出しましょう。
HTMLではaction属性にURLを渡しますが、Reactでは特別なpropとみなされます。裏ではサーバーアクションはPOST APIエンドポイントを作成されるため、手動でAPIエンドポイントを作成する必要がありません。
formData
からデータを取り出す。
action.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);
}
正しく実装できていることを確認するために、フォームを試してみましょう。入力して送信すると、入力データがターミナルに記録されるようになっているはずです。
{
customerId: 'cc27c14a-0acf-4f4a-a6c9-d45682c144b9',
amount: '12',
status: 'pending'
}
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
だけでした。
データの検証を処理するにはいくつか選択肢があります。手作業で型を検証することもできますが、型検証ライブラリを使用することで、時間と労力を節約することができます。この利絵では、TypeScriptの検証ライブラリであるZodを使用します。
action.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
に渡して型を検証することができます。
続いて、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
関数でできます。
請求書の更新
請求書の更新フォームは請求書の作成フォームと似ていますが、データベースのレコードを更新するために請求書のid
を渡す必要があります。請求書のid
を取得し、渡す方法を学んでいきましょう。
- 請求書の
id
で新しい動的ルートセグメントを作成する。 - ページパラメーターから請求書の
id
を読み取る。 - データベースから特定の請求書を取得する
- フォームに請求書データを事前に入力する。
- データベースの請求書データを更新する。
1. 請求書IDで動的ルートセグメントを作成する
Next.jsでは、正確なセグメント名が分からず、データに基づいてルートを作成したい場合に、動的なルートセグメントを作成することができます。例えば、ブログの記事のタイトルや商品ページ等です。フォルダ名を[id]
、[post]
、[slug]
の様に角括弧([]
)で囲むことで、動的なルートセグメントを作成できます。
/invoices
フォルダに[id]
という新しいダイナミックルートを作成し、page.tsx
ファイルを持つedit
という新しいルートを作成します。
/app/ui/invoices/table.tsx
を確認してみましょう。<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>
// ...
);
}
id
propを受け入れるように、<UpdateInvoice />
コンポーネントで使用しているLink
のhref
を変更しましょう。動的なルートセグメントにリンクするためにテンプレートリテラルを使用できます。
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. page
パラメーターから請求書の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
請求書ページと似ていることに注目してみましょう。このフォームには顧客名、請求書の金額、ステータスのdefaultValue
があらかじめ入力されています。フォームフィールドに事前に入力するにはid
を使用して特定の請求書を取得する必要があります。
searchParams
に加えて、ページコンポーネントはid
にアクセスするために使用できるparams
と呼ばれるpropも受け取ります。<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({ params }: { params: { id: string } }) {
const id = params.id;
// ...
}
3. 特定の請求書の取得
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({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
// ...
}
invoice
が未定義の可能性があるため、ターミナルでinvoice
propに対して一時的なTSエラーが表示されます。次回のエラー処理で解決するので、今は無視しましょう。
サーバーが起動されていることを確認し、http://localhost:3000/dashboard/invoicesにアクセスしましょう。鉛筆のアイコンをクリックすることで請求書の編集画面が表示されます。選択した値がデフォルトで入っていることも確認しましょう。
4. サーバーアクションにid
を渡す
最後に、データベースの正しいレコードを更新できるように、サーバーアクションに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>
);
}
続いて、action.ts
ファイルにudateInvoice
という新しいアクションを作成します。
// 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');
}
このコードは以下のような処理を行っています。
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>
);
}
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
このアクションは/dashboard/invoices
パスで呼び出されるのでredirect
を呼び出す必要は愛rません。revalidatePath
を呼び出すと、新しいサーバーリクエストが発生し、テーブルが再レンダリングされます。
まとめ
- React Server Action を利用することで、様々な攻撃の保護、データの保護、許可されたアクセス安全性を高めることができる。
- zodは型を検証するためのライブラリ。
- Next.jsではルートセグメントをブラウザにキャッシュさせる、クライアントサイド・ルーターキャッシュ機能がある。
revalidatePath
関数でキャッシュをクリアしてサーバーへ新しいリクエストをトリガーする。redirect
関数でリダイレクトすることができる。