データの変更

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

サーバーアクションとは?

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

セキュリティはWebアプリケーションの最優先事項であり、さまざまな脅威に対して脆弱になる可能性があります。ここでサーバーアクションが役立ちます。暗号化されたクロージャ、厳密な入力チェック、エラーメッセージのハッシュ化、ホスト制限など、アプリケーションのセキュリティを大幅に向上させる機能が含まれています。

サーバーアクションとフォームの使用

Reactでは、<form>要素のaction属性を使用してアクションを呼び出すことができます。アクションは自動的にキャプチャされたデータを含むネイティブのFormDataオブジェクトを受け取ります。

例:

// サーバーコンポーネント
export default function Page() {
  // アクション
  async function create(formData: FormData) {
    'use server';
 
    // データ変更のロジック...
  }
 
  // "action"属性を使用してアクションを呼び出す
  return <form action={create}>...</form>;
}

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

Next.jsとサーバーアクション

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

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

請求書の作成

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

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

1. 新しいルートとフォームの作成

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

createフォルダとその中のpage.tsxファイルを含むInvoicesフォルダ

このルートを使用して新しい請求書を作成します。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: '請求書', href: '/dashboard/invoices' },
          {
            label: '請求書作成',
            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が表示されます:

パンくずリストとフォームを含む請求書作成ページ

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'),
  };
  // テスト:
  console.log(rawFormData);
}

ヒント:多くのフィールドを持つフォームを扱う場合は、JavaScriptのObject.fromEntries()entries()メソッドの使用を検討してください。

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

データがオブジェクトの形になったので、作業がずっと簡単になります。

4. データの検証と準備

フォームデータをデータベースに送信する前に、正しい形式と型であることを確認する必要があります。コースの前半で覚えているかもしれませんが、請求書テーブルは次の形式のデータを期待しています:

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

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フィールドは、型を検証しながら文字列から数値に強制的に変更(coerce)するように特別に設定されています。

次に、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');
}

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

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

請求書の更新

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

請求書を更新する手順は次のとおりです:

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

1. 請求書idで動的ルートセグメントを作成

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

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

[id]フォルダがネストされたinvoicesフォルダと、その中のeditフォルダ

<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: '請求書', href: '/dashboard/invoices' },
          {
            label: '請求書編集',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      <Form invoice={invoice} customers={customers} />
    </main>
  );
}

/create請求書ページと似ていますが、異なるフォーム(edit-form.tsxファイルから)をインポートしていることに注目してください。このフォームには、顧客名、請求金額、ステータスのdefaultValueが事前入力されている必要があります。フォームフィールドに事前入力するには、idを使用して特定の請求書を取得する必要があります。

searchParamsに加えて、ページコンポーネントはidにアクセスするために使用できるparamsプロップも受け入れます。<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が潜在的にundefinedである可能性があるため、ターミナルでinvoiceプロップに関する一時的なTypeScriptエラーが表示されます。今のところ心配する必要はありません。次の章でエラー処理を追加する際に解決します。

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

パンくずリストとフォームのある請求書編集ページ

URLもid付きで更新されるはずです: http://localhost:3000/dashboard/invoice/uuid/edit

UUID vs 自動増分キー

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

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

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

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

/app/ui/invoices/edit-form.tsx
// idを引数として渡すのは機能しません
<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}>{/* ... */}</form>;
}

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

次に、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. 金額をセントに変換
  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">削除</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の使用方法も学びました。

さらに学ぶために、サーバーアクションのセキュリティについても読むことができます。