コンテンツにスキップ

12

データの変更

前の章では、URLSearchParamsとNext.js APIを使用して検索とページネーションを実装しました。請求書ページで、請求書を作成、更新、削除する機能を追加して作業を続けます!

このチャプターでは...

取り上げるトピックは以下の通りです。

React Server Actionsとは何か、そしてそれらを使用してデータを変更する方法。

フォームとServer Componentsの操作方法。

ネイティブFormDataオブジェクトを扱うためのベストプラクティス、型検証を含む。

revalidatePath APIを使用してクライアントキャッシュを再検証する方法。

特定のIDを持つ動的なルートセグメントの作成方法。

Server Actionsとは?

React Server Actionsを使用すると、サーバー上で直接非同期コードを実行できます。これにより、データを変更するためにAPIエンドポイントを作成する必要がなくなります。代わりに、サーバーで実行される非同期関数を記述し、クライアントまたはサーバーコンポーネントから呼び出すことができます。

セキュリティはWebアプリケーションにとって最優先事項であり、さまざまな脅威に対して脆弱になる可能性があります。そこでServer Actionsが登場します。暗号化されたクロージャ、厳密な入力チェック、エラーメッセージのハッシュ化、ホスト制限などの機能が含まれており、これらが連携してアプリケーションのセキュリティを大幅に強化します。

Server Actionsを使用したフォームの利用

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>;
}

Server Component内でServer Actionを呼び出す利点は、プログレッシブエンハンスメントです。JavaScriptがクライアントでまだロードされていない場合でも、フォームは機能します。例えば、遅いインターネット接続がない場合などです。

Next.jsとServer Actions

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

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

請求書の作成

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

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

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

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

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

あなたのページはServer Componentで、customersを取得して<Form>コンポーネントに渡します。時間の節約のため、<Form>コンポーネントはすでに作成されています。

<Form>コンポーネントに移動すると、フォームが

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

https://:3000/dashboard/invoices/createで、以下のUIが表示されるはずです。

Create invoices page with breadcrumbs and form

2. Server Actionを作成する

フォームが送信されたときに呼び出されるServer Actionを作成しましょう。

lib/ディレクトリに移動し、actions.tsという名前の新しいファイルを作成します。このファイルの先頭に、Reactの'use server'ディレクティブを追加します。

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

'use server'を追加することで、ファイル内のエクスポートされたすべての関数がServer Actionsとしてマークされます。これらのサーバー関数は、クライアントコンポーネントやサーバーコンポーネントにインポートして使用できます。このファイルに含まれるが、使用されない関数は、最終的なアプリケーションバンドルから自動的に削除されます。

"use server"をアクションに追加することで、Server Components内に直接Server Actionsを記述することもできます。ただし、このコースでは、それらをすべて別のファイルに整理しておきます。アクションには別のファイルを用意することをお勧めします。

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がアクションを呼び出せるように、その上に構築されていることを意味します。

内部的には、Server ActionsはPOST APIエンドポイントを作成します。そのため、Server Actionsを使用する際に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()を使用することを検討してください。

すべてが正しく接続されているかを確認するために、フォームを試してみてください。送信後、フォームに入力したデータがブラウザではなくターミナルにログとして表示されるはずです。

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

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);

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

型検証を処理するには、いくつかのオプションがあります。型を手動で検証することもできますが、型検証ライブラリを使用すると、時間と労力を節約できます。この例では、Zodを使用します。これはTypeScriptファーストの検証ライブラリで、このタスクを簡素化できます。

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 postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...
 
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 { revalidatePath } from 'next/cache';
import postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...
 
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 { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...
 
export async function createInvoice(formData: FormData) {
  // ...
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

おめでとうございます!最初のServer Actionを実装しました。これを請求書を追加してテストしてください。すべて正常に機能していれば、

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

/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(),
  ]);
  // ...
}

ターミナルにinvoiceプロップの一時的なTypeScriptエラーが表示されます。これはinvoiceが未定義である可能性があるためです。次の章でエラー処理を追加する際に解決するので、今は心配しないでください。

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

Edit invoices page with breadcrumbs and form

URLもidで更新され、次のようになります: https://:3000/dashboard/invoice/uuid/edit

UUID vs. 自動インクリメントキー

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

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

4. idをServer Actionに渡す

最後に、データベースの正しいレコードを更新できるように、idをServer Actionに渡す必要があります。idをこのように引数として渡すことはできません

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

代わりに、JSbindを使用してidをServer Actionに渡すことができます。これにより、Server Actionに渡される値はエンコードされます。

/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}>{/* ... */}</form>;
}

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

その後、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を呼び出して、ユーザーを請求書のページにリダイレクトしています。

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

請求書の削除

Server Actionを使用して請求書を削除するには、削除ボタンを<form>要素で囲み、bindを使用して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 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を呼び出すと、新しいサーバーリクエストがトリガーされ、テーブルが再レンダリングされます。

さらに読む

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

Server Actionsを使用したセキュリティについてをさらに学習するために読むこともできます。

チャプターを完了しました。12

おめでとうございます!フォームとReact Server Actionsを使用してデータを変更する方法を学びました。

次へ

13: エラー処理

フォームを使用したデータ変更のベストプラクティス、エラー処理とアクセシビリティについて探求しましょう。

App Router: データ変更 | Next.js