Next.jsのチュートリアルをやってみる【データの変異編】

前回

Next.jsのチュートリアルをやってみる【検索とページネーション編】

はじめに

前回はURL検索パラメーターとNext.js APIを使用して検索とページネーションを実装しました。

今回は請求書ページに請求書を作成・更新・削除する機能を追加していきます。

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

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

Webアプリケーションは様々な脅威にさらされやすいため、セキュリティは最優先事項です。そこでサーバーアクションの出番です。効果的なセキュリティソリューションを提供し、さまざまなタイプの攻撃から保護し、許可されたアクセスを保証します。Server ActionsはPOSTリクエスト、暗号化されたクロージャ、厳密な入力チェック、エラーメッセージのハッシュ化、ホストの制限などの技術によってこれを実現し、これらすべてが連携

サーバーアクションでフォームを使用する

Reactでは<form>要素のaction属性を使ってアクションを呼び出すことができます。アクションは自動的に取り込んだデータを含むネイティブのFormDataオブジェクトを受け取ります。

サーバーコンポーネント内でサーバーアクションを呼び出すメリットは、プログレッシブ・エンハンスメントの手法をとるためです。

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

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

請求書の作成

以下の手順で請求書を作成する機能を実装していきましょう。

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

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

まずは/invoicesフォルダの中に/createという新しいルートセグメントを追加し、以下の内容の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>
  );
}

パンくずリストと請求書作成フォームで構成されているページです。

このページは顧客データをフェッチして<Form>コンポーネントに渡すサーバー・コンポーネントです。<Form>はすでに用意されていますので、サーバーが起動されていることを確認し、http://localhost:3000/dashboard/invoices/createにアクセスして以下のUIが描画されるかを確認してみましょう。

2. サーバーアクションの作成

続いて、フォームが送信された際に呼び出されるサーバーアクションを作成しましょう。libディレクトリに移動し、action.tsという名前の新しいファイルを作成します。

初めにuse serverディレクティブを追加しましょう。

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

ファイル内にエクスポートされたすべての関数をサーバーアクションとしてマークすることができます。サーバー関数はクライアントコンポーネントやサーバーコンポーネントにインポートして使用することができます。

また、アクションの中にuse serverを追加することで、サーバーコンポーネントの中に直接サーバーアクションを記述することもできます。このチュートリアルではすでに用意されています。

action.tsFormDataを受け取る新しい非同期関数を作成しましょう。

/app/lib/actions.ts
'use server';
 
export async function createInvoice(formData: FormData) {}

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

HTMLではaction属性にURLを渡しますが、Reactでは特別なpropとみなされます。裏ではサーバーアクションはPOST APIエンドポイントを作成されるため、手動でAPIエンドポイントを作成する必要がありません。

formDataからデータを取り出す。

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

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

正しく実装できていることを確認するために、フォームを試してみましょう。入力して送信すると、入力データがターミナルに記録されるようになっているはずです。

{
  customerId: 'cc27c14a-0acf-4f4a-a6c9-d45682c144b9',
  amount: '12',
  status: 'pending'
}

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だけでした。

データの検証を処理するにはいくつか選択肢があります。手作業で型を検証することもできますが、型検証ライブラリを使用することで、時間と労力を節約することができます。この利絵では、TypeScriptの検証ライブラリであるZodを使用します。

action.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に渡して型を検証することができます。

続いて、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関数でできます。

請求書の更新

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

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

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

Next.jsでは、正確なセグメント名が分からず、データに基づいてルートを作成したい場合に、動的なルートセグメントを作成することができます。例えば、ブログの記事のタイトルや商品ページ等です。フォルダ名を[id][post][slug]の様に角括弧([])で囲むことで、動的なルートセグメントを作成できます。

/invoicesフォルダに[id]という新しいダイナミックルートを作成し、page.tsxファイルを持つeditという新しいルートを作成します。

/app/ui/invoices/table.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>
    // ...
  );
}

idpropを受け入れるように、<UpdateInvoice />コンポーネントで使用しているLinkhrefを変更しましょう。動的なルートセグメントにリンクするためにテンプレートリテラルを使用できます。

/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. pageパラメーターから請求書の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請求書ページと似ていることに注目してみましょう。このフォームには顧客名、請求書の金額、ステータスのdefaultValueがあらかじめ入力されています。フォームフィールドに事前に入力するにはidを使用して特定の請求書を取得する必要があります。

searchParamsに加えて、ページコンポーネントはidにアクセスするために使用できるparamsと呼ばれるpropも受け取ります。<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. 特定の請求書の取得

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が未定義の可能性があるため、ターミナルでinvoicepropに対して一時的なTSエラーが表示されます。次回のエラー処理で解決するので、今は無視しましょう。

サーバーが起動されていることを確認し、http://localhost:3000/dashboard/invoicesにアクセスしましょう。鉛筆のアイコンをクリックすることで請求書の編集画面が表示されます。選択した値がデフォルトで入っていることも確認しましょう。

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

最後に、データベースの正しいレコードを更新できるように、サーバーアクションに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>
  );
}

続いて、action.tsファイルにudateInvoiceという新しいアクションを作成します。

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

このコードは以下のような処理を行っています。

  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>
  );
}
/app/lib/actions.ts
export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');
}

このアクションは/dashboard/invoicesパスで呼び出されるのでredirectを呼び出す必要は愛rません。revalidatePathを呼び出すと、新しいサーバーリクエストが発生し、テーブルが再レンダリングされます。

まとめ

  • React Server Action を利用することで、様々な攻撃の保護、データの保護、許可されたアクセス安全性を高めることができる。
  • zodは型を検証するためのライブラリ。
  • Next.jsではルートセグメントをブラウザにキャッシュさせる、クライアントサイド・ルーターキャッシュ機能がある。
  • revalidatePath関数でキャッシュをクリアしてサーバーへ新しいリクエストをトリガーする。
  • redirect関数でリダイレクトすることができる。

次回

Next.jsのチュートリアルをやってみる【エラー処理編】

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です