3
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js基礎コース App Router やってみた 10 ~ 12

Last updated at Posted at 2026-01-31

Next.jsの勉強がてら公式のチュートリアルを1からなぞってみました。

実際にチュートリアルをベースに書いたソースコードはこちら

10. Partial Prerendering (PPR)

PPRとは端的に言えば同じページ内で静的レンダリングの領域と動的レンダリングの領域を組み合わせることができるレンダリングモデルです。
現在の殆どのWebアプリでは、ページ全体あるいは特定のルートに対して、静的レンダリングと動的レンダリングのどちらかを選択しています。

このアプリを動的コンポーネントと静的コンポーネントに分割すると次のようになります。

10_ppr_site.png (123.8 kB)

ユーザーがこのサイトに訪問すると

  1. ナビゲーションバーを含む静的ルートシェルが概況され、初期読み込みが高速化されます
  2. シェルはその他の動的コンテンツが非同期に読み込まれる場所に穴を残します
  3. 非同期のホールは並列でストリーミングされるため、ページの全体的な読み込み時間が短縮されます

PPRによる事前レンダリングの仕組み

PPRは、Reactの Suspense を使用して、アプリケーションの一部を特定の条件(データ読み込み完了など)が満たされるまでレンダリングを遅延させます。

Suspense のフォールバックは、静的コンテンツと共に初期HTMLファイルに埋め込まれます。ビルド時(または再検証時)に静的コンテンツがプリレンダリングされ、静的シェルが生成されます。
動的コンテンツのレンダリングは、ユーザーがルートをリクエストするまで延期されます。

コンポーネントを Suspense でラップしても、コンポーネント自体が動的になるわけではありません。サスペンスは静的コードと動的コードの境界として機能します。

ダッシュボードルートでPPRを実装する方法を見てみましょう。

PPRの実装

PPRを利用するにはNext.jsのカナリアリリースが必要です。

pnpm install next@canary

Next.jsでPPRを有効にするには next.config.ts にオプションを追加します。

next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {  // 追加
    ppr: 'incremental'  // incremental を設定すると特定のルートにPPRを設定できるようになります。
  }
};

export default nextConfig;

次に、ダッシュボードレイアウトに experimental_ppr セグメント設定オプションを追加します:

/app/dashboard/layout.tsx

import SideNav from '@/app/ui/dashboard/sidenav'

export const experimental_ppr = true;

// ...

これで完了です。開発中のアプリケーションでは違いが見られないかもしれませんが、本番環境ではパフォーマンスの向上が実感できるはずです。Next.js はルートの静的部分を事前にレンダリングし、動的な部分はユーザーが要求するまで遅延させます。

部分的事前レンダリングの素晴らしい点は、コードを変更することなく使用できることです。ルートの動的な部分をサスペンスでラップしている限り、Next.jsはルートのどの部分が静的でどの部分が動的であるかを認識します。

11. 検索とページネーションの追加

この章では useSearchParams usePathname useRouter の使いを学びます。

請求書一覧ページの雛形を作成します。

/dashboard/invoices/page.tsx

import Search from '@/app/ui/search';  // 特定の請求書を検索するためのコンポーネント
import Pagination from '@/app/ui/invoices/pagination';  // 請求書のページネーション用コンポーネント
import Table from '@/app/ui/invoices/table';  // 請求書テーブルの表示用コンポーネント
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';


export default async function Page() {
  await new Promise((resolve) => setTimeout(resolve, 3000));
  return (
    <div className="w-full">
      <div className="flex w-full items-center justify-between">
        <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>
      {/*  <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense> */}
      <div className="mt-5 flex w-full justify-center">
        {/* <Pagination totalPages={totalPages} /> */}
      </div>
    </div>
  )
}

検索にGETパラメータを使う理由

  • ブックマーク可能
  • 初回アクセス時にサーバーサイドレンダリングが容易
  • GAなどでユーザーの行動を追跡しやすくなる

検索機能の追加

以下は検索機能を実装するために利用する Next.js クライアントフックです。

1. ユーザーの入力をキャプチャする

handleSearch() 関数を作成し、検索フォームの onChange にイベントリスナーとして追加します。

/app/ui/search.tsx

'use client';  // クライアントコンポーネントなのでイベントリスナーとクライアントフックを使用できます。

export default function Search({ placeholder }: { placeholder: string }) {
  function handleChange(term: string) { // handleChangeの追加
    console.log(term);
  }

  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={(e) => {  // onChangeイベントのリスナーにhandleChangeを設定
          handleChange(e.currentTarget.value)
        }}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

2. 検索パラメータでURLを更新する

検索ボックスに入力された値でURLを書き換えます。 (ページをリロードせずにURLを更新します)

  • useSearchParams
    現在のURLのGETパラメータを取得します。例えば /dashboard/invoices?page=1&query=pending であれば {page: '1', query: 'pending'} となります。
  • usePathname
    現在のURLのパス名を取得します。例えば、ルートが /dashboard/invoices なら '/dashboard/invoices' となります。
  • useRouter
    クライアントコンポーネント内でルート間のナビゲーションをプログラムで実現します。複数の方法が利用可能です。

/app/ui/search.tsx

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams: URLSearchParams = useSearchParams(); // アクセス時のURL
  const pathname = usePathname(); // アクセス時のパス
  const { replace } = useRouter();

  // 検索ボックスに入力された値でURLを更新するイベントハンドラ
  function handleChange(term: string) {
    const params = new URLSearchParams(searchParams);
    // console.log('params:', [...params.entries()]);
    if (term) {
      params.set('query', term)
    } else {
      params.delete('query')
    }
    // useRouterのルーターフックを利用することでページをリロードせずにURLを更新できます。
    replace(`${pathname}?${params.toString()}`);
  }

  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={(e) => {handleChange(e.currentTarget.value)}}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

3. URLと入力内容を同期させる

?query=hogehoge というGETパラメータ付きのURLにアクセスしたときに検索ボックスにパラメータが入力されるようにします。

/app/ui/search.tsx

<input
  className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
  placeholder={placeholder}
  onChange={(e) => {
    handleSearch(e.target.value);
  }}
  defaultValue={searchParams.get('query')?.toString()}
/>

4. テーブルの更新

検索クエリを反映するようにテーブルコンポーネントを更新します。

/app/dashboard/invoices/page.tsx

// ...

export default async function Page(props: {searchParams?: Promise<{query?: string; page?: string;}>; }) {
  // URLから検索クエリ(query)と現在ページ(page)を取得
  const searchParams = await props.searchParams;
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
 
  return (
    <div className="w-full">
      {/* ... */}
      <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense>
      {/* ... */}
    </div>
  );
}

補足1: Page関数の引数について

page.tsxPage() 関数は paramssearchParams という要素を持つオブジェクトを引数に取ります。
page.js - File Conventions - API Reference | NEXT.js

export default function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
  return <h1>My Page</h1>
}

補足2: 繰り返しの描画

繰り返しの描画は map を利用する

<div>
  {invoices?.map((invoice) => (
    <div key={invoice.id}>
      <div>{invoice.email}</div>
    </div>
  )}
</div>

/app/ui/invoices/table.tsx

// ...
import { fetchFilteredInvoices } from '@/app/lib/data';

export default async function InvoicesTable({query, currentPage}: {query: string; currentPage: number;}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);

  return (
    <div className="mt-6 flow-root">
      <div className="inline-block min-w-full align-middle">
        <div className="rounded-lg bg-gray-50 p-2 md:pt-0">
          {/* ... */}
          <table className="hidden min-w-full text-gray-900 md:table">
            <thead className="rounded-lg text-left text-sm font-normal">
              <tr>
                {/* ... */}
              </tr>
            </thead>
            <tbody className="bg-white">
              {invoices?.map((invoice) => (
                <tr key={invoice.id} className="..." >
                  <td className="whitespace-nowrap py-3 pl-6 pr-3">
                    <div className="flex items-center gap-3">
                      <Image
                        src={invoice.image_url}
                        className="rounded-full"
                        width={28}
                        height={28}
                        alt={`${invoice.name}'s profile picture`}
                      />
                      <p>{invoice.name}</p>
                    </div>
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    {invoice.email}
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    {formatCurrency(invoice.amount)}
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    {formatDateToLocal(invoice.date)}
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    <InvoiceStatus status={invoice.status} />
                  </td>
                  <td className="whitespace-nowrap py-3 pl-6 pr-3">
                    <div className="flex justify-end gap-3">
                      <UpdateInvoice id={invoice.id} />
                      <DeleteInvoice id={invoice.id} />
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

5. デバウンス

現在の実装だと、入力の都度イベントハンドラが発火してしまうため、use-debounce を利用してユーザーが入力を一定時間移譲止めたときにのみ発火するようにします。

pnpm i use-debounce

useDebouncedCallback をインポートし、 handleSearch の内容をラップします。
今回は300ミリ秒経過後にコードを実行します。

/app/ui/search.tsx

// ...
import { useDebouncedCallback } from 'use-debounce';  // インポート
 
// イベントハンドラの処理をuseDebouncedCallbackでラップ
const handleSearch = useDebouncedCallback((term) => {
  console.log(`Searching... ${term}`);
 
  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}, 300);

ページネーションの追加

/app/dashboard/invoices/page.tsx

// ...
import { fetchInvoicesPages } from '@/app/lib/data';


export default async function Page(props: { searchParams: { query?: string, page?: string } }) {
  // ...
  const totalPages: number = await fetchInvoicesPages(query); // 検索結果に基づく総ページ数を取得

  await new Promise((resolve) => setTimeout(resolve, 3000));
  return (
    <div className="w-full">
      {/* ... */}

      {/* Paginationコンポーネントの読み込み */}
      <div className="mt-5 flex w-full justify-center">
        <Pagination totalPages={totalPages} />
      </div>
    </div>
  )
}

ページネーションを描画するコンポーネントを実装します。

/app/ui/invoices/pagination.tsx

'use client';

// ...
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation'; // 追加

export default function Pagination({ totalPages }: { totalPages: number }) {
  // 現在のURL情報
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;

  // 指定されたページのURLを生成する関数
  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  }

  // 表示するページネーションボタンを表す配列
  // [1, "...", 5, 6, 7, "...", 10] みたいな
  const allPages = generatePagination(currentPage, totalPages);

  return (
    {/* ページネーションの描画 */}
    <>
      <div className="inline-flex">
        <PaginationArrow
          direction="left"
          href={createPageURL(currentPage - 1)}
          isDisabled={currentPage <= 1}
        />

        <div className="flex -space-x-px">
          {allPages.map((page, index) => {
            let position: 'first' | 'last' | 'single' | 'middle' | undefined;

            if (index === 0) position = 'first';
            if (index === allPages.length - 1) position = 'last';
            if (allPages.length === 1) position = 'single';
            if (page === '...') position = 'middle';

            return (
              <PaginationNumber
                key={`${page}-${index}`}
                href={createPageURL(page)}
                page={page}
                position={position}
                isActive={currentPage === page}
              />
            );
          })}
        </div>

        <PaginationArrow
          direction="right"
          href={createPageURL(currentPage + 1)}
          isDisabled={currentPage >= totalPages}
        />
      </div>
    </>
  );
}

// ...

検索クエリが更新されたときに、、ページを 1 にリセットします。

/app/ui/search.tsx

// ...

export default function Search({ placeholder }: { placeholder: string }) {
  // ...

  const handleSearch = useDebouncedCallback((term: string) => {
    console.log(`Searching... ${term}`);
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');      // 追加: 検索時にページ番号をリセット
    if (term) {
      params.set('query', term)
    } else {
      params.delete('query')
    }
    replace(`${pathname}?${params.toString()}`);
  }, 300);

  return (
    {/* ... */}
  );
}

11_search_and_pagination.png (116.1 kB)

12. データの変更

React Server Actions

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

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

Server Actions でフォームを利用する

Reactでは、<form> 要素の action 属性を使用してアクションを呼び出すことができます。この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 Actions を呼び出す利点は、クライアント側でJavaScriptの読み込みが完了していなくてもフォームが機能するということです。

Server Actions と Next.js

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

Invoiceの作成

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

/dashboard/invoices に新しいルート /create を追加します。

mkdir -p app/dashboard/invoices/create
touch app/dashboard/invoices/create/page.tsx

/app/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> コンポーネントに渡します。

フォームの実態は /app/ui/invoices/breadcrumbs

12_form.png (43.5 kB)

2. Server Actions を作成する

フォームが送信されたときに呼び出されるサーバーアクションを定義します。

/app/lib/actions.ts を作成し、 'use server'; を指定します。
'use server' はクライアント側から呼び出せるサーバー側の関数をマークするために利用します。

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

※ 関数内に 'use server' を追加することで、サーバーコンポーネント内に直接サーバーアクションを記述することも可能です。

/app/lib/actions.ts

'use server';
 
export async function createInvoice(formData: FormData) {

}

次に、 <Form> コンポーネント内で、 createInvoice をインポートします。<form> 要素に action 属性を追加し、 createInvoice アクションを呼び出します。

/app/ui/invoices/create-form.tsx

// ...
import { createInvoice } from '@/app/lib/actions';  // 追加

export default function Form({ customers }: { customers: CustomerField[] }) {
  return (
    <form action={createInvoice}> {/* action属性を追加 */}

補足: action属性に関して

HTMLではaction属性にはフォームの送信先URLを指定しますが、Reactでは action 属性は特別なプロパティとみなされており、アクション(関数)を指定することができます。
サーバーアクションはバックグラウンドで POST APIエンドポイントを作成するため、サーバーアクションを使用する際にAPIエンドポイントを手動で作成する必要はありません。

3. formData からデータを抽出する

FormDataget メソッド でフォームのデータを取得します。
※ まとめて取得したい場合は entries メソッド も利用できます。

/app/lib/actions.ts

'use server';
 
export async function createInvoice(formData: FormData) {
  // FormData: https://developer.mozilla.org/ja/docs/Web/API/FormData
  const rawFormData = {
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  }
  console.log('Raw Form Data:', rawFormData);  // サーバー側で実行されるのでターミナル側にログが出力されます
}

4. データのバリデーション

フォームをデータベースに送信する前にバリデーションを行います。
請求書テーブルのデータ型は /app/lib/definitions.tsInvoice として定義されています。

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

型のバリデーションと強制

DBの amountnumber 型ですが、フォームの amountstring 型になっていることがわかります。

/app/lib/actions.ts

console.log('Type of amount:', typeof rawFormData.amount);  // Type of amount: string

TypeScriptでは型の検証と強制には Zod を利用します。

/app/lib/actions.ts

'use server';

import { z } from 'zod';

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),  // https://zod.dev/api?id=strings
  amount: z.coerce.number(),  // 入力データを適切な型に強制変換 (https://zod.dev/api?id=coercion)
  status: z.enum(['pending', 'paid']),  // https://zod.dev/api?id=enums
  date: z.string(),
})

// idとdateはサーバー側で生成するため、フォームからは受け取らない (https://zod.dev/api?id=omit)
const CreateInvoice = FormSchema.omit({id: true, date: true})

export async function createInvoice(formData: FormData) {
  // FormData: https://developer.mozilla.org/ja/docs/Web/API/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」の形式で新しい日付を作成します
  const data = new Date().toISOString().split("T")[0];
   console.log({customerId, amountInCents, status, data});  // {customerId: '3958dc9e-712f-4377-85e9-fec4b6a6442a', amountInCents: 2222200, status: 'pending', data: '2025-10-09'}
}

5. データベースにデータを挿入する

データベースに必要な値がすべて揃ったので、新しい請求書をデータベースに挿入し、変数を渡す SQL クエリを作成できます。

/app/lib/actions.ts

'use server';

import { z } from 'zod';
import postgres from 'postgres';

const sql = postgres(process.env.POSTGRES_URL!, { ssl: false });

// ...

export async function createInvoice(formData: FormData) {
  // ...

  await sql`
    INSERT INTO invoices (customer_id, amount, status, date)
    VALUES (${customerId}, ${amountInCents}, ${status}, ${data})
  `;
}

6. 再検証してリダイレクト

Next.js には、ルートセグメントをユーザーのブラウザに一定期間保存するクライアント側ルーターキャッシュがあります。このキャッシュは、プリフェッチと併用することで、ユーザーがルート間を素早く移動できるようにし、サーバーへのリクエスト数を削減します。

請求書ルートに表示されるデータを更新するため、このキャッシュをクリアしてサーバーへの新しいリクエストをトリガーする必要があります。これは、revalidatePathNext.jsの関数を使用して実行できます。

/app/lib/actions.ts

// ...

import { revalidatePath } from 'next/cache';

// ...

export async function createInvoice(formData: FormData) {
  // ...

  revalidatePath('/dashboard/invoices');  // キャッシュをクリアして、請求書一覧ページを再検証・データを再取得
}

データベースが更新されると、/dashboard/invoicesパスが再検証され、サーバーから最新のデータが取得されます。

この時点で、ユーザーを元のページに戻すリダイレクトも行います

/app/lib/actions.ts

// ...

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

// ...

export async function createInvoice(formData: FormData) {
  // ...

  revalidatePath('/dashboard/invoices');  // キャッシュをクリアして、請求書一覧ページを再検証・データを再取得
  redirect('/dashboard/invoices');  // 請求書一覧ページにリダイレクト
}

Invoiceの更新

1. invoices/ 配下に動的ルートセグメント([id])を作成する

Next.js では、正確なセグメント名がわからず、データに基づいてルートを作成したい場合に、 動的ルートセグメントを作成できます。
フォルダ名を角括弧で囲むことで、動的ルートセグメントを作成できます。例えば[id][post] などです。

mkdir -p "app/dashboard/invoices/[id]/edit"
touch "app/dashboard/invoices/[id]/edit/page.tsx"

<Table> コンポーネントには、テーブル レコードから請求書を受け取るボタン <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 /> コンポーネントに移動し、id プロパティを受け入れるよう href にを更新します。
<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`}  // idを含めたパスに修正
      className="rounded-md border p-2 hover:bg-gray-100"
    >
      <PencilIcon className="w-5" />
    </Link>
  );
}

2. 指定された id の請求書を読み込む

URLのパスから id を取得し、その id で対象の invoice を取得します。
取得した invoice の情報をデフォルト値として編集フォームを描画します。

/app/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()
  ])
  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>
  )
}
12_edit_form.png (46.8 kB)

3. サーバーアクションに渡す

データベースを更新するためのサーバーアクションを定義します。

/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) {
  // フォームから送信されたデータを検証
  // FormData: https://developer.mozilla.org/ja/docs/Web/API/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');  // 請求書一覧ページにリダイレクト
}

最後に、サーバーアクションをコンポーネントの <form>action に指定しますが、
サーバーアクションの id 引数は以下のような形式で渡すことはできません。

// Passing an id as argument won't work
<form action={updateInvoice(id)}>

代わりに、JavaScriptの Function.prototype.bind() 関数を使用して id がすでに設定された updateInvoice() を生成することができます。

/app/ui/invoices/edit-form.tsx

'use client';
// ...
import { updateInvoice } from '@/app/lib/actions';  // 追加

export default function EditInvoiceForm({ invoice, customers, }: { invoice: InvoiceForm; customers: CustomerField[]; }) {

  // updateInvoice()の第一引数(id)に invoice.id を指定した関数を生成
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);

  return (
    <form action={updateInvoiceWithId}>  {/* form の action に請求書を更新する関数を指定 */}
    {/* ... */}
    </form>
  )
}

Function.prototype.bind() とは

bind() 関数は元の関数の「 this キーワード」と「引数」を設定した新しい関数を生成します。
いわゆる カリー化 みたいなことができる

  • シグネチャ
    bind(thisArg, arg1, arg2, ... argN)
  • 引数
    • thisArg
      関数内で this を利用したときに参照されるオブジェクトを指定します。
      null undefined を指定するとグローバルオブジェクトとなります。
    • arg1, ..., argN
      元の関数の引数を一部または全部指定できます
  • 戻り値
    this の値と初期の引数が設定された関数のコピー

例1: 引数の一部を指定する

function product (a, b) {
    return a * b;
}

console.log(product(5, 4)) // 20

var double = product.bind(null, 2)  // productの第一引数に2を指定した新しい関数を生成

console.log(double(5))  // 10

例2: 任意の this を指定する


const module = {
  x: 42,
  getX() {
    return this.x;
  }
}

var unboundGetX = module.getX;
console.log(unboundGetX()); // undefined (関数内の this がグローバルオブジェクトなので)

var boundGetX = module.getX.bind(module)
console.log(boundGetX())  // 42 (関数内の this が module オブジェクトなので)

Invoiceの削除

invoiceを削除するサーバーアクションを定義します。

/app/lib/actions.ts

export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');  // キャッシュをクリアして、請求書一覧ページを再検証・データを再取得
}

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

/app/ui/invoices/buttons.tsx

import { deleteInvoice } from '@/app/lib/actions';
 
// ...
 
export function DeleteInvoice({ id }: { id: string }) {
  // deleteInvoice()の第一引数(id)に invoice.id を指定した関数を生成
  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>
  );
}
3
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?