コンテンツにスキップ

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のキャッシュとも深く統合されています。フォームがサーバーアクションを通じて送信されると、アクションを使用してデータを変更できるだけでなく、revalidatePathrevalidateTagなどのAPIを使用して関連するキャッシュを再検証することもできます。

すべてがどのように連携するかを見てみましょう!

請求書の作成

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

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

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

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

Invoices folder with a nested create folder, and a page.tsx file inside it

このルートを使用して新しい請求書を作成します。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>コンポーネントに移動すると、フォームに次のものがあることがわかります。

  • 顧客のリストを持つ1つの<select>(ドロップダウン)要素。
  • type="number"を持つ金額用の1つの<input>要素。
  • type="radio"を持つステータス用の2つの<input>要素。
  • type="submit"を持つ1つのボタン。

http://localhost:3000/dashboard/invoices/createで、次のUIが表示されるはずです。

Create invoices page with breadcrumbs and form

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}>
      // ...
  )
}

知っておくと良いこと:HTMLでは、action属性にURLを渡します。このURLは、フォームデータが送信される宛先(通常はAPIエンドポイント)になります。

ただし、Reactでは、action属性は特別なプロップと見なされます。つまり、Reactはアクションを呼び出すことができるように、その上に構築します。

舞台裏では、サーバーアクションはPOST APIエンドポイントを作成します。これが、サーバーアクションを使用する場合にAPIエンドポイントを手動で作成する必要がない理由です。

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'),
  };
  // Test it out:
  console.log(rawFormData);
}

ヒント: 多数のフィールドを持つフォームを扱う場合、JavaScript のentries() メソッドと、Object.fromEntries() メソッドを組み合わせて使うことを検討すると良いでしょう。例えば

const rawFormData = Object.fromEntries(formData.entries())

すべてが正しく接続されているかを確認するには、実際にフォームを試してみてください。送信後、フォームに入力したデータがターミナルに記録されているはずです。

データがオブジェクトの形になったので、扱いやすくなります。

4. データの検証と準備

フォームデータをデータベースに送信する前に、正しい形式と型であることを確認する必要があります。コースの最初の方で説明したように、請求書テーブルは次の形式のデータを想定しています。

/app/lib/definitions.ts
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_idamountstatus のみです。

型検証と型変換

フォームからのデータが、データベースで想定される型と一致することを確認することが重要です。例えば、アクション内に console.log を追加すると

console.log(typeof rawFormData.amount);

amountnumber ではなく 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 の浮動小数点エラーを排除し、精度を向上させるために、データベースに金額をセント単位で保存することをお勧めします。

金額をセント単位に変換してみましょう。

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

おめでとうございます!初めてのサーバーアクションを実装しました。新しい請求書を追加してテストしてみてください。すべてが正しく動作していれば

  1. 送信時に /dashboard/invoices ルートにリダイレクトされるはずです。
  2. テーブルの上部に新しい請求書が表示されるはずです。

請求書の更新

請求書を更新するフォームは、請求書を作成するフォームと似ていますが、データベースのレコードを更新するには、請求書の id を渡す必要があります。請求書の id を取得して渡す方法を見てみましょう。

以下は、請求書を更新するために行う手順です。

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

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

<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. ページ 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() {
  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>コンポーネントを更新してプロパティを受け取るようにしてください。

/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(props: { params: Promise<{ id: string }> }) {
  const params = await props.params;
  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(props: { params: Promise<{ id: string }> }) {
  const params = await props.params;
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);
  // ...
}

invoiceundefinedになる可能性があるため、ターミナルでinvoiceプロパティに関する一時的なTSエラーが表示されます。今のところは心配しないでください。次の章でエラー処理を追加するときに解決します。

素晴らしい! さて、すべてが正しく配線されていることをテストしてください。 http://localhost:3000/dashboard/invoices にアクセスし、請求書を編集するために鉛筆アイコンをクリックします。ナビゲーション後、請求書の詳細が事前に入力されたフォームが表示されるはずです。

Edit invoices page with breadcrumbs and form

URLも、次のようにidで更新される必要があります:http://localhost:3000/dashboard/invoice/uuid/edit

UUIDと自動インクリメントキー

インクリメントキー(例:1、2、3など)の代わりにUUIDを使用します。これにより、URLは長くなりますが、UUIDはIDの衝突のリスクを排除し、グローバルに一意であり、列挙攻撃のリスクを軽減するため、大規模なデータベースに最適です。

ただし、よりクリーンなURLを好む場合は、自動インクリメントキーの使用を好むかもしれません。

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

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

/app/ui/invoices/edit-form.tsx
// Passing an id as argument won't work
<form action={updateInvoice(id)}>

代わりに、JSのbindを使用して、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>
  );
}

注:フォームに隠し入力フィールドを使用することもできます(例:<input type="hidden" name="id" value={invoice.id} />)。ただし、値はHTMLソースにフルテキストとして表示されます。これは、IDのような機密データには理想的ではありません。

次に、actions.tsファイルで、新しいアクションupdateInvoiceを作成します。

/app/lib/actions.ts
// 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アクションと同様に、ここでは

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

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

請求書の削除

サーバーアクションを使用して請求書を削除するには、削除ボタンを<form>要素で囲み、bindを使用してidをサーバーアクションに渡します。

/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 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という新しいアクションを作成します。

/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を呼び出すと、新しいサーバーリクエストがトリガーされ、テーブルが再レンダリングされます。

さらに詳しく読む

この章では、サーバーアクションを使用してデータを変更する方法を学びました。また、Next.jsキャッシュを再検証するためのrevalidatePath APIの使用方法と、ユーザーを新しいページにリダイレクトするためのredirectの使用方法も学びました。

サーバーアクションのセキュリティの詳細については、こちらをご覧ください。

章を完了しました12

おめでとうございます!フォームとReactサーバーアクションを使用してデータを変更する方法を学びました。

次へ

13: エラーの処理

エラー処理やアクセシビリティなど、フォームでデータを変更するためのベストプラクティスを探りましょう。