ゼロから学ぶ React, Next.js⑲【Learn Next.js】Chapter12
【Chapter12】データの変更
前の章では、URLの検索パラメータとNext.js APIを使用して検索とページネーションを実装しました。請求書を作成、更新、削除する機能を追加することで、請求書ページの作業を続けましょう!
この章で扱うトピック
- ❄️ React Server Actionsとは何か、それらを使用してデータを変更する方法
- 📄 フォームとサーバーコンポーネントの連携方法
- 🫐 ネイティブの
formData
オブジェクトの操作のベストプラクティス(型検証を含む) - 🔄
revalidatePath
APIを使用してクライアントキャッシュを再検証する方法 - ↪️ 特定のIDを使用して動的ルートセグメントを作成する方法
Server Actionsとは?
React Server Actionsを使用すると、非同期コードをサーバー上で直接実行できます。データを変更するためのAPIエンドポイントを作成する必要がなくなります。代わりに、サーバー上で実行され、クライアントまたはサーバーコンポーネントから呼び出すことができる非同期関数を記述します。
セキュリティはWebアプリケーションの最優先事項です。Webアプリケーションはさまざまな脅威に対して脆弱である可能性があるためです。ここでServer Actionsの出番です。Server Actionsは、さまざまな種類の攻撃から保護し、データを保護し、認証されたアクセスを保証することで、効果的なセキュリティソリューションを提供します。Server Actionsは、POSTリクエスト、暗号化されたクロージャ、厳密な入力チェック、エラーメッセージのハッシュ化、ホストの制限など、アプリの安全性を大幅に向上させるために連携する手法を使用してこれを実現します。
Server Actionsでフォームを使用する
Reactでは、<form>
要素のaction
属性を使用してアクションを呼び出すことができます。アクションは自動的にネイティブのFormData
オブジェクトを受け取り、キャプチャされたデータが含まれています。
例えば:
// サーバーコンポーネント
export default function Page() {
// アクション
async function create(formData: FormData) {
'use server';
// データを変更するロジック...
}
// "action"属性を使用してアクションを呼び出す
return <form action={create}>...</form>;
}
サーバーコンポーネント内でサーバーアクションを呼び出す利点は、プログレッシブエンハンスメントです。クライアントでJavaScriptが無効になっていても、フォームは機能します。
メモ:プログレッシブエンハンスメントとは
古い環境・ネットワーク回線が低速といった場合でも Web ページのコアとなるコンテンツは利用できる状態をベースとして設計し、逆に新しい環境・ネットワーク回線が高速といった場合には、段階的により充実した機能を漸進的に提供する、といったイメージです。
Next.jsとServer Actions
Server ActionsはNext.jsのキャッシュとも深く統合されています。Server Actionを介してフォームが送信されると、アクションを使用してデータを変更できるだけでなく、revalidatePath
やrevalidateTag
などのAPIを使用して関連するキャッシュを再検証することもできます。
クイズの時間です!
知識をテストし、学んだことを確認しましょう。Server Actionsを使用することの利点の1つは何ですか?
A. SEOの改善
B. プログレッシブエンハンスメント
C. 高速なWebサイト
D. データの暗号化
解答
B. プログレッシブエンハンスメント
これにより、フォーム用のJavaScriptがまだ読み込まれていない場合や、読み込みに失敗した場合でも、ユーザーがフォームとやりとりしてデータを送信できるようになります。
それがどのように連携して機能するかを見てみましょう!
請求書の作成
新しい請求書を作成するために実行する手順は次のとおりです:
- ユーザーの入力をキャプチャするフォームを作成する。
- サーバーアクションを作成し、フォームから呼び出す。
- サーバーアクション内で、
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>
コンポーネントに移動すると、フォームには次のものがあることがわかります:
-
customersのリストを含む1つの
<select>
(ドロップダウン)要素 -
amount
用のtype="number"
を持つ1つの<input>
要素 -
status
用の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}>
// ...
)
}
formData
からデータを抽出する
3. 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'),
+ };
+ // テストしてみる:
+ console.log(rawFormData);
}
ヒント:多くのフィールドを持つフォームを扱う場合は、JavaScriptのObject.fromEntries()
を使用してentries()
メソッドを使用することを検討したほうがよいでしょう。例えば:
const rawFormData = Object.fromEntries(formData.entries())
すべてが正しく接続されていることを確認するために、フォームを試してみてください。送信後、フォームに入力したデータがターミナルに記録されているはずです。
これで、データがオブジェクトの形になったので、作業がはるかに簡単になります。
4. データの検証と準備
フォームデータをデータベースに送信する前に、正しい形式と正しい型であることを確認する必要があります。コースの前半で覚えていれば、invoices
テーブルは次の形式のデータを期待しています:
export type Invoice = {
id: string; // データベースで作成される
customer_id: string;
amount: number; // セントで保存される
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の浮動小数点エラーを排除し、より高い精度を確保するために、通貨値をデータベースにセントで保存するのが通常は適切な方法です。
amount
をセントに変換しましょう:
// ...
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]
という新しい動的ルートを作成し、その中にedit
という新しいルートとpage.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>
// ...
);
}
<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. ページパラメータから請求書の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>
);
}
これが請求書の作成ページと似ていることに注目してください。ただし、別のフォーム(edit-form.tsx
ファイルから)をインポートしています。このフォームは、顧客名、請求書の金額、ステータスにデフォルト値が事前に入力されている必要があります。フォームフィールドに事前に入力するには、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({ params }: { params: { id: string } }) {
+ 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({ params }: { params: { id: string } }) {
const id = params.id;
+ const [invoice, customers] = await Promise.all([
+ fetchInvoiceById(id),
+ fetchCustomers(),
+ ]);
// ...
}
invoice
プロップのTSエラーがターミナルに一時的に表示されます。これは、invoice
が未定義の可能性があるためです。エラー処理を追加するときに、次の章でそれを解決するので、今のところは気にしないでください。
素晴らしい!すべてが正しく配線されていることをテストしてください。http://localhost:3000/dashboard/invoicesにアクセスし、鉛筆アイコンをクリックして請求書を編集します。ナビゲーション後、請求書の詳細が事前に入力されたフォームが表示されるはずです:
URLも次のようにid
で更新されているはずです:http://localhost:3000/dashboard/invoice/uuid/edit
id
を渡す
4. サーバーアクションに最後に、データベースの正しいレコードを更新できるように、サーバーアクションにid
を渡す必要があります。次のようにid
を引数として渡すことはできません:
// 引数としてIDを渡しても機能しません
<form action={updateInvoice(id)}>
代わりに、JSのバインドを使用してサーバーアクションに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>
);
}
次に、actions.ts
ファイルで、新しいアクションupdateInvoice
を作成します:
// Zodを使用して予想される型を更新します
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で型を検証します。
-
amount
をセントに変換します。 - 変数をSQLクエリに渡します。
- クライアントキャッシュをクリアし、新しいサーバーリクエストを行うために
revalidatePath
を呼び出します。
- ユーザーを請求書ページにリダイレクトするために
redirect
を呼び出します。
請求書を編集してテストしてください。フォームを送信すると、請求書ページにリダイレクトされ、請求書が更新されるはずです。
請求書の削除
Server Actionを使用して請求書を削除するには、削除ボタンを<form>
要素でラップし、バインドを使用して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 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でのセキュリティについてもっと読むこともできます。
次の章
Discussion