コンテンツにスキップ

12

データの変更

前の章では、URL検索パラメータとNext.js APIを使用して検索とページネーションを実装しました。引き続き「Invoices」ページで、請求書の作成、更新、削除の機能を追加していきましょう!

この章の内容...

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

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

フォームとServer Componentsの連携方法。

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

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

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

Server Actionsとは?

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

ウェブアプリケーションにとってセキュリティは最優先事項であり、さまざまな脅威に対して脆弱である可能性があります。そこで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がクライアントにまだロードされていなくてもフォームが機能します。例えば、インターネット接続が遅い場合でも問題ありません。

Server ActionsとNext.js

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

あなたのページは、customersをフェッチし、それを<Form>コンポーネントに渡すServer Componentです。時間を節約するために、<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 Actionとしてマークされます。これらのサーバー関数は、クライアントおよびサーバーコンポーネントでインポートして使用できます。このファイルに含まれる関数で、使用されないものは、最終的なアプリケーションバンドルから自動的に削除されます。

Server Actionは、アクション内に"use server"を追加することで、Server Component内に直接記述することもできます。しかし、このコースでは、すべてを別々のファイルにまとめておきます。アクション用のファイルを個別に持つことをお勧めします。

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のObject.fromEntries()と一緒に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);

amountstring型であり、number型ではないことに気づくでしょう。これは、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 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 />コンポーネントに移動し、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(),
  ]);
  // ...
}

ターミナルでinvoiceプロパティの一時的なTypeScriptエラーが表示されるでしょう。これはinvoiceundefinedになる可能性があるためです。今は気にしないでください。エラーハンドリングを追加する次の章で解決します。

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

Edit invoices page with breadcrumbs and form

URLもidで以下のように更新されるはずです: https://:3000/dashboard/invoice/uuid/edit

UUIDと自動増分キーの比較

増分キー(例: 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)}>

代わりに、JSのbindを使用して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: エラー処理

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