📝

ゼロから学ぶ React, Next.js⑲【Learn Next.js】Chapter12

2024/05/25に公開

【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 ページのコアとなるコンテンツは利用できる状態をベースとして設計し、逆に新しい環境・ネットワーク回線が高速といった場合には、段階的により充実した機能を漸進的に提供する、といったイメージです。

https://zenn.dev/cybozu_frontend/articles/think-about-pe


Next.jsとServer Actions

Server ActionsはNext.jsのキャッシュとも深く統合されています。Server Actionを介してフォームが送信されると、アクションを使用してデータを変更できるだけでなく、revalidatePathrevalidateTagなどのAPIを使用して関連するキャッシュを再検証することもできます。

クイズの時間です!
知識をテストし、学んだことを確認しましょう。

Server Actionsを使用することの利点の1つは何ですか?

A. SEOの改善
B. プログレッシブエンハンスメント
C. 高速なWebサイト
D. データの暗号化

解答

B. プログレッシブエンハンスメント
これにより、フォーム用のJavaScriptがまだ読み込まれていない場合や、読み込みに失敗した場合でも、ユーザーがフォームとやりとりしてデータを送信できるようになります。

それがどのように連携して機能するかを見てみましょう!


請求書の作成

新しい請求書を作成するために実行する手順は次のとおりです:

  1. ユーザーの入力をキャプチャするフォームを作成する。
  2. サーバーアクションを作成し、フォームから呼び出す。
  3. サーバーアクション内で、formDataオブジェクトからデータを抽出する。
  4. データを検証し、データベースに挿入できるように準備する。
  5. データを挿入し、エラーを処理する。
  6. キャッシュを再検証し、ユーザーを請求書ページにリダイレクトする。

1. 新しいルートとフォームを作成する

まず、/invoicesフォルダ内に、/createという新しいルートセグメントとpage.tsxファイルを追加します:

ファイルを含むネストされたフォルダを示す請求書フォルダ

このルートを使用して、新しい請求書を作成します。page.tsxファイルの中に、次のコードを貼り付けて、時間をかけて理解してください:

/dashboard/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>
  );
}

ページはサーバーコンポーネントで、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ディレクティブを追加します:

/app/lib/actions.ts
'use server';

'use server'を追加することで、ファイル内のすべてのエクスポートされた関数をサーバー関数としてマークします。これらのサーバー関数は、クライアントコンポーネントとサーバーコンポーネントの両方にインポートできるため、非常に汎用性があります。

サーバーコンポーネント内で直接サーバーアクションを記述し、アクション内に"use server"を追加することもできます。ただし、このコースでは、すべてを別のファイルに整理しておきます。

actions.tsファイルで、formDataを受け取る新しい非同期関数を作成します:

/app/lib/actions.ts
 'use server';

+export async function createInvoice(formData: FormData) {}

次に、<Form>コンポーネントで、actions.tsファイルからcreateInvoiceをインポートします。<form>要素にaction属性を追加し、createInvoiceアクションを呼び出します。

/app/ui/invoices/create-form.tsx
 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}>
       // ...
   )
 }

3. formDataからデータを抽出する

actions.tsファイルに戻ると、formDataの値を抽出する必要があります。使用できるメソッドはいくつかあります。この例では、.get(name)メソッドを使用しましょう。

/app/lib/actions.ts
 '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テーブルは次の形式のデータを期待しています:

/app/lib/definitions.ts
export type Invoice = {
  id: string; // データベースで作成される
  customer_id: string;
  amount: number; // セントで保存される
  status: 'pending' | 'paid';
  date: string;
};

これまでのところ、フォームからcustomer_idamountstatusのみを取得しています。

型の検証と強制

フォームのデータがデータベースの予想される型と一致していることを検証することが重要です。例えば、アクション内にconsole.logを追加すると:

console.log(typeof rawFormData.amount);

amountの型がnumberではなくstringであることがわかります。これは、type="number"を持つinput要素が実際には文字列を返すためです!

型の検証を処理するには、いくつかの選択肢があります。型を手動で検証することもできますが、型検証ライブラリを使用すると時間と労力を節約できます。この例では、TypeScriptファーストの検証ライブラリであるZodを使用します。

actions.tsファイルで、Zodをインポートし、フォームオブジェクトの形状に一致するスキーマを定義します。このスキーマは、データベースに保存する前にformDataを検証します。

/app/lib/actions.ts
 '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フィールドは、文字列から数値に強制(変更)されるように特別に設定されており、型も検証されます。

次に、rawFormDataCreateInvoiceに渡して型を検証できます:

/app/lib/actions.ts
 // ...
 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をセントに変換しましょう:

/app/lib/actions.ts
 // ...
 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」形式の新しい日付を作成しましょう:

/app/lib/actions.ts
 // ...
 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クエリを作成し、変数を渡すことができます:

/app/lib/actions.ts
 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関数を使用して実行できます:

/app/lib/actions.ts
 '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関数を使用して実行できます:

/app/lib/actions.ts
 '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を取得して渡す方法を見てみましょう。

請求書を更新するために実行する手順は次のとおりです:

  1. 請求書のidを使用して新しい動的ルートセグメントを作成する。
  2. ページパラメータから請求書のidを読み取る。
  3. データベースから特定の請求書をフェッチする。
  4. 請求書データでフォームを事前に入力する。
  5. データベース内の請求書データを更新する。

1. 請求書のIDで動的ルートセグメントを作成する

Next.jsでは、正確なセグメント名がわからず、データに基づいてルートを作成する場合に、動的ルートセグメントを作成できます。これは、ブログ投稿のタイトル、製品ページなどの可能性があります。フォルダ名を角括弧で囲むことで、動的ルートセグメントを作成できます。例えば、[id][post][slug]などです。

/invoicesフォルダで、[id]という新しい動的ルートを作成し、その中にeditという新しいルートとpage.tsxファイルを作成します。ファイル構造は次のようになります:

フォルダ内のフォルダを示す請求書フォルダ

<Table>コンポーネントでは、テーブルレコードから請求書のIDを受け取る<UpdateInvoice />ボタンがあることに注目してください。

/app/ui/invoices/table.tsx
 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 />コンポーネントに移動し、Linkhrefを更新してidプロップを受け入れるようにします。テンプレートリテラルを使用して、動的ルートセグメントにリンクできます:

/app/ui/invoices/buttons.tsx
 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>コンポーネントに戻り、次のコードを貼り付けます:

/app/dashboard/invoices/[id]/edit/page.tsx
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>コンポーネントを更新してプロップを受け取ります:

/app/dashboard/invoices/[id]/edit/page.tsx
 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を使用して、請求書と顧客を並行してフェッチできます:

/dashboard/invoices/[id]/edit/page.tsx
 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

4. サーバーアクションにidを渡す

最後に、データベースの正しいレコードを更新できるように、サーバーアクションにidを渡す必要があります。次のようにidを引数として渡すことはできません:

/app/ui/invoices/edit-form.tsx
// 引数としてIDを渡しても機能しません
<form action={updateInvoice(id)}>

代わりに、JSのバインドを使用してサーバーアクションにidを渡すことができます。これにより、サーバーアクションに渡される値がエンコードされます。

/app/ui/invoices/edit-form.tsx
// ...
+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を作成します:

/app/lib/actions.ts
// 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アクションと同様に、ここでは次のことを行っています:

  1. formDataからデータを抽出します。
  2. Zodで型を検証します。
  3. amountをセントに変換します。
  4. 変数をSQLクエリに渡します。
  5. クライアントキャッシュをクリアし、新しいサーバーリクエストを行うためにrevalidatePathを呼び出します。
  • ユーザーを請求書ページにリダイレクトするためにredirectを呼び出します。

請求書を編集してテストしてください。フォームを送信すると、請求書ページにリダイレクトされ、請求書が更新されるはずです。


請求書の削除

Server Actionを使用して請求書を削除するには、削除ボタンを<form>要素でラップし、バインドを使用してidをServer Actionに渡します:

/app/ui/invoices/buttons.tsx
+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という新しいアクションを作成します。

/app/lib/actions.ts
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でのセキュリティについてもっと読むこともできます。


次の章

https://zenn.dev/gunjo/articles/fb55d801f92313

Discussion