JavaScript PR

Next.jsに入門してみた(Chapter9〜12)【Next.js, React】

Next.jsに入門してみた(Chapter5~8)【Next.js, React】
記事内に商品プロモーションを含む場合があります

Next.jsに改めて入門しようと思い、公式サイトの「Learn Next.js」というチュートリアル(?)をやっていきます。

かなり長いチュートリアルだったので複数回に分け紹介しており、こちらの記事はChapter9〜Chapter12の内容になります。

Chapter1〜Chapter8までの内容は下記をご覧ください。

Next.jsに入門してみた(Chapter1〜4)【Next.js, React】
Next.jsに入門してみた(Chapter1〜4)【Next.js, React】こちらの記事ではNext.jsに入門するために、公式サイトのチュートリアルを翻訳し、僕自身の解釈や理解をまとめております。 Reactの基礎を学び終えて、Next.jsに入門したい方はぜひご覧ください。...
Next.jsに入門してみた(Chapter5~8)【Next.js, React】
Next.jsに入門してみた(Chapter5〜8)【Next.js, React】こちらの記事ではNext.jsに入門するために、公式サイトのチュートリアルを翻訳し、僕自身の解釈や理解をまとめております。 Reactの基礎を学び終えて、Next.jsに入門したい方はぜひご覧ください。...

なお記事の内容は下記に注意してご覧くださいm(_ _)m

  • こちらの記事は2023年11月20日現在の情報になります。
  • 公式サイトのチュートリアルを翻訳し(主にGoogle翻訳を使わせていただきました)、僕自身の解釈や理解を伝えるものになります。
  • わかりやすくするために、翻訳通りの説明になっていない部分や、僕自身が調べて追加説明してある部分があります。
  • 公式に書かれていることを全て書いていませんし、公式の見解と一致しているとは限りません。

※間違え等あればご連絡くださいm(_ _)m

目次

環境・バージョン

  • Mac OS
  • Node.js 20.9.0(18よりも新しいものである必要がある)
  • VSCode

Chapter 9(Streaming)

ここではChpter8の最後に課題としてあげたことを改善していきます。

遅いデータのリクエストがある場合にユーザーエクスペリエンスを向上させる方法をみていきましょう。

ストリーミングとは?(What is streaming?)

ストリーミング」はデータの転送技術で、データを小分けにして転送し、準備ができたところから表示させることです。

ストリーミングすることで、遅いデータのリクエストよってページ全体がブロックされるのを防ぐことができます。 これにより、ユーザーは、すべてのデータが読み込まれるのを待たずに、ページの一部を表示して操作できるようになります。

Reactの各コンポーネントが小分けされた1つのかたまりとみなすことができるため、ストリーミングはReactと相性が良いです。(うまく使える)

Next.jsでストリーミングを実装するには、下記2つの方法があります。

  • loading.tsx」ファイルを使用する。(ページレベル)
  • <Suspense>」を使用する。(コンポーネントレベル)

ページ全体をloading.tsxでストリーミング(Streaming a whole page with loading.tsx)

「/app/dashboard」フォルダに、「loading.tsx」という新しいファイルを作成して、下記のコードを書きます。

/app/dashboard/loading.tsx
export default function Loading() {
  return <div>Loading...</div>;
}

「http://localhost:3000/dashboard」でリロードしてみましょう。

下記のような画面になればOKです。(ブラウザタブが読み込み中に、「Loading…」の文字が表示されている)

下記のようなことが起こっています。

  • loading.tsxはSuspenseコンポーネントよりも上のレベルで構築されるNext.jsの特別なフィルで、ページコンテンツの読み込み中に代替として表示するフォールバックUIを作成できます。
  • <Sidebar>コンポーネントは静的であるため、すぐに表示されます。 なので、ユーザーは、動的コンテンツの読み込み中に<Sidebar>コンポーネントの部分を操作できます。
  • ページ移動する際、ユーザーはページの読み込みが完了するまで待つ必要がなくなります。

これでストリーミングが実装されました。

次に、ユーザーエクスペリエンスを向上させるために、「Loading…」テキストの代わりに、読み込み中のスケルトンスクリーン(多くのWebサイトで使われるプレースホルダーまたはフォールバックとして表示されるUI)を表示にしていきましょう。

下記のように「/app/dashboard/loading.tsx」で<DashboardSkeleton>というコンポーネントを呼び出します。

/app/dashboard/loading.tsx
import DashboardSkeleton from '@/app/ui/skeletons';
 
export default function Loading() {
  return <DashboardSkeleton />;
}

「http://localhost:3000/dashboard」をリロードしてみましょう。

下記のようになってればOKです。

現状、読み込み中のスケルトンスクリーンInvoicesページと、Customersページにも適用されています。

上記は、loading.tsxが「/invoices/page.tsx」と「/customers/page.tsx」より上の階層に配置しているためです。

そして、「Route Groups」を使えば解決できます。

「/app/dashborad」に「/(overview)」というフォルダを作成して、その中に「/app/dashborad/loading.tsx」と「/app/dashboard/page.tsx」を移動します。

これで、「/app/dashborad/(overview)/loading.tsx」はダッシュボード概要ページのみに適用されます。

上記のように「()」 を使用して新しいフォルダーを作成すると、その名前はURLパスに含まれません。したがって、「/dashboard/(overview)/page.tsx」はURLでは「/dashboard」で表示されることにないります。

上記まで見てきたように、「Route Groups」を使えば、パスに影響を与えずにフォルダ分割をしてファイルをまとめることができます。(いい感じにフォルダ分割できる)

ここまででページ全体をストリーミングできるようになりました。

より詳細に特定のコンポーネントのみをストリーミングすることも可能で、<Suspense>を使います。

例えばChapter8でsetTimeoutを使って「遅いデータのリクエスト」のシミューレートをした「fetchRevenue()」関数は、ページ全体の表示速度を低下させています。

こういう時に、ページ全体をブロックするのではなく、<Suspense>を使用することでコンポーネントをストリーミングすることでページの残りのコンポーネントをデータ取得でき次第表示させることができます。

それでは上記を実現するために下記の通りコードを修正していきます。

「/app/dashboard/(overview)/page.tsx」の「fetchRevenue()」に関わる部分のコードを削除します。

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // fetchRevenueを削除
 
export default async function Page() {
  const revenue = await fetchRevenue // この行を削除
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    // ...
  );
}

次に、下記のようにReactから<Suspense>をインポートし、それで<RevenueChart />にラップします。

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
 
export default async function Page() {
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collected" value={totalPaidInvoices} type="collected" />
        <Card title="Pending" value={totalPendingInvoices} type="pending" />
        <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

最後に、<RevenueChart>コンポーネントを更新して、独自のデータをフェッチし、それに渡された prop を削除します。

/app/ui/dashboard/revenue-chart.tsx
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';
 
// ...
 
export default async function RevenueChart() { // RevenueChartコンポーネントを非同期にして、プロップスを削除
  const revenue = await fetchRevenue(); // コンポーネント内のデータを取得
 
  const chartHeight = 350;
  const { yAxisLabels, topLabel } = generateYAxis(revenue);
 
  if (!revenue || revenue.length === 0) {
    return <p className="mt-4 text-gray-400">No data available.</p>;
  }
 
  return (
    // ...
  );
}
 

「http://localhost:3000/dashboard」でリロードしてみましょう。

下の画像のように、ダッシュボードの<RevenueChart>コンポーネントの部分がフォールバックとして設定しているスケルトン表示され、それ以外の情報はすぐに表示されればOKです。

練習問題:<LatestInvoices>をストリーミングする(Practice: Streaming <LatestInvoices>)

ここまでで学んだストリーミングについて<LatestInvoices>コンポーネントで実装してみてください。

やることは下記の通りです。

  • fetchLatestInvoices()を<LatestInvoices>コンポーネントに移動
  • <LatestInvoicesSkeleton>をフォールバックとして使用して、コンポーネントを<Suspense>でラップする

※下記が答えです。

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchCardData } from '@/app/lib/data'; // Remove fetchLatestInvoices
import { Suspense } from 'react';
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
} from '@/app/ui/skeletons';
 
export default async function Page() {
  // Remove `const latestInvoices = await fetchLatestInvoices()`
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collected" value={totalPaidInvoices} type="collected" />
        <Card title="Pending" value={totalPendingInvoices} type="pending" />
        <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<LatestInvoicesSkeleton />}>
          <LatestInvoices />
        </Suspense>
      </div>
    </main>
  );
}

/app/ui/dashboard/latest-invoices.tsx
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Image from 'next/image';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices } from '@/app/lib/data';
 
export default async function LatestInvoices() { // Remove props
  const latestInvoices = await fetchLatestInvoices();
 
  return (
    // ...
  );
}

コンポーネントのグループ化(Grouping components)

次に<Card>コンポーネントを<Suspense>でラップする必要があります。

1つ1つのカードのデータをフェッチすることはできますが、カードが読み込まれるときにポップ効果が発生する可能性があり、ユーザーにとって視覚的に不快になる可能性があります。

より多くの「staggered」効果(?)を作るには、ラッパーコンポーネントを使用して、<Card>をグループ化します。(「staggered Effect」というがよくわからなかったので調べると、こちらの記事の最初で説明されている「StaggerdView」のことのような気がします。)

これは、静的なコンポーネントの<Sidebar />が最初に表示され、その後にカードなど動的なコンポーネントが表示されることを意味します。(それは自明なのでは?と思っているのだけど、どういうことなのだろう?)

「/dashboard/(overview)/page.tsx」で、下記の対応を行います。

  • <Card>コンポーネントを削除する。
  • fetchCardData()関数を削除する。
  • <CardWrapper />というコンポーネントをインポートする。
  • <CardsSkeleton />というコンポーネントをインポートする。
  • <CardWrapper />を<Suspense>でラップする。

/app/dashboard/(overview)/page.tsx
import CardWrapper from '@/app/ui/dashboard/cards';
// ...
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton,
} from '@/app/ui/skeletons';
 
export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<CardsSkeleton />}>
          <CardWrapper />
        </Suspense>
      </div>
      // ...
    </main>
  );
}

次に下記のように「/app/ui/dashboard/cards.tsx」で、fetchCardData()関数をインポートし、<CardWrapper/>コンポーネント内で呼び出します。 このコンポーネント内の必要なコードのコメントを必ず解除してください。(「NOTE: comment in this code when you get to this point in the course」の下のコメント)

/app/ui/dashboard/cards.tsx
// ...
import { fetchCardData } from '@/app/lib/data';
 
// ...
 
export default async function CardWrapper() {
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <>
      <Card title="Collected" value={totalPaidInvoices} type="collected" />
      <Card title="Pending" value={totalPendingInvoices} type="pending" />
      <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
      <Card
        title="Total Customers"
        value={numberOfCustomers}
        type="customers"
      />
    </>
  );
}

「http://localhost:3000/dashboard」でリロードしてみましょう。

すべての<Card>が同時に読み込まれていることがわかります。複数のコンポーネントを同時にロードする場合は、この方法を使用できます。

<Suspense>を使うかどうかの基準を決める(Deciding where to place your Suspense boundaries)

※上記翻訳して意味を理解するときに、React18以降でSuspenseコンポーネントと一緒に使うことがある「ErrorBoundary」というコンポーネントのことかもと迷ったが、今回は違う気がして文脈的に「boundaries」を「基準」という意味で解釈しました。

<Suspense>コンポーネントを使うかどうかは下記のようなことを考慮して決定します。

  • ストリーミング中に、ユーザーにどのように体験してもらいたいか。
  • どのコンテンツを優先したいか。
  • コンポーネントがデータの取得に依存している場合。

使うかどうかの正解はありません。

一般的には、データのフェッチを必要なコンポーネントを、<Suspense>でラップすることをお勧めします。ただし、アプリケーションが必要とする場合は、セクションまたはページ全体をストリーミングしても問題はありません。

<Suspense>は、より快適なユーザーエクスペリエンスを作成するのに役立つ強力なAPIなので、遠慮せずに試して何が最適かを確認してください。

データのフェッチを必要なコンポーネントに移動することで、より詳細なサスペンス境界を作成できます。 これにより、特定のコンポーネントをストリーミングし、UI がブロックされるのを防ぐことができます。

「Streaming Component」と「Server Component」は、データのフェッチとロードの状態を処理する新しい方法を提供し、最終的にはエンドユーザーのユーザーエクスペリエンスを向上させることを目的としています。

次のChapterでは、ストリーミングを念頭に置いて構築された新しいNext.jsのレンダリングモデルである「Partial Prerendering(部分プリレンダリング)」について学びます。

Chapter 10(Partial Prerendering (Optional))

※Partial Prerendering(部分プリレンダリング)は、Next.js14で導入された実験的な機能です。このChapterは、機能の安定性が進むにつれて更新される可能性があります。 実験的な機能を使用したくない場合は、このChapterをスキップしてもよいでしょう。このChapterはコースを完了するために必須ではありません。

静的コンテンツと動的コンテンツの結合(Combining Static and Dynamic Content)

ルート内(フォルダ配下のpage.tsxファイル)で動的関数 (noStore()、cookies() など) を呼び出すと、ルート全体が動的になります。

上記は、こんにちのほとんどのWebアプリケーション構築方法であり、アプリ全体または特定のコンポーネントで静的レンダリングにするのか動的レンダリングにするのかを設定します。

しかし、ほとんどのルートで完全に静的または動的ではありません。静的コンテンツと動的コンテンツの両方を含むルートがある場合があります。

今回のダッシュボードページの場合下の画像のように、赤枠が静的、青枠が動的、というように分けられるかと思います。

部分プリレンダリングとは?(What is Partial Prerendering?)

Next.js14には、「部分プリレンダリング(Partial Prerendering)」と呼ばれる新しいレンダリングモデルのプレビュー版(試験版)があります。

部分的な事前レンダリングは、一部の部分を動的に保ちながら、静的な読み込みでルートをレンダリングできるようにする実験的な機能です。つまり、ルートの動的部分を分離できます。

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

  • 画面全体は静的なものとして読み込まれるので、初期ロードが速くなります。
  • サイトは部分的に、動的コンテンツが非同期で読み込まれます。(ドキュメントでは「hole」と呼んでいる)
  • 上記は、並行して読み込まれるため、ページ全体の読み込み時間が短縮されます。

これは、ルート全体が完全に静的または動的であるこんにちのアプリケーションの動作とは異なります。

部分プリレンダリングは、超高速の静的エッジ配信と完全な動的機能を組み合わせたもので、静的サイトの生成と動的配信の長所を組み合わせて、Webアプリケーションのデフォルトのレンダリングモデルになる可能性があると考えています。

部分的なプリレンダリングはどのように機能する?(How does Partial Prerendering work?)

部分的なプリレンダリングは、何らかの条件が満たされるまで (データがロードされるなど)、アプリケーションの一部のレンダリングを延期します。

フォールバックは、他の静的コンテンツとともに初期の静的ファイルに埋め込まれます。 ビルド時 (または再検証中) に、ルートの静的部分が事前レンダリングされ、残りの部分はユーザーがルートを要求するまで延期されます。

コンポーネントを<Suspense>でラップしてもコンポーネント自体が動的になるのではなく、(動的にする方法は「Next.jsに入門してみた(Chapter5〜8)【Next.js, React】」を見直してください。)<Suspense>はルートの静的部分と動的部分の間の境界として使用されることに注意してください。(動的な部分には<Suspense>でラップすると良い場合があるという意味だと思います!詳しくはChapter9のこちらをご覧ください。)

部分プリレンダリングの優れた点は、それを使用するためにコードを変更する必要がないことです。 <Suspense>を使用してラップをするだけでOKということです。(<Suspense>でラップしているところが動的、それ以外のところが静的というように見極めている(?))

※部分的なプリレンダリングの構成方法の詳細については、「Partial Prerendering (experimental)」 のドキュメントを参照するか、「Partial Prerendering template and demo」を試してください。この機能は実験的なものであり、まだ運用環境に導入する準備ができていないことに注意してください。

まとめ(Summary)

ここまでで、アプリケーションでのデータ取得を最適化するために次のことを行いました。

  1. サーバーとデータベース間の待ち時間を短縮するために、アプリケーションコードと同じリージョンにデータベースを作成しました。
  2. React Server Componentsを使用してサーバー上のデータを取得しました。これにより、高価なデータのフェッチとロジックをサーバー上に保持し、クライアント側のJavaScriptバンドルを削減し、データベースのシークレット(重要な情報)がクライアントに公開されるのを防ぐことができます。
  3. SQLを使用して必要なデータのみを取得することで、リクエストごとに転送されるデータ量と、メモリ内のデータを変換するために必要なJavaScriptの量を削減しました。
  4. JavaScriptを使用してデータの取得を並列化します。(並列にして問題ない場合)
  5. 遅いデータ要求によってページ全体がブロックされるのを防ぎ、ユーザーがすべてが読み込まれるのを待たずにUIの操作を開始できるようにするために、ストリーミングを実装しました。
  6. データのフェッチを必要なコンポーネントに移動し、部分的なプレレンダリングに備えてルートのどの部分を動的にする必要があるかを分離しました。

次のChapterでは、データをフェッチするときに実装する必要がある2つの一般的なパターンの検索機能とページネーションについて説明します。

Chapter 11(Adding Search and Pagination)

このChapterでは「検索機能」と「ページネーション」を実装していきます。

コードの書き始め(Starting code)

「/app/dashboard/invoices/page.tsx」ファイル内に、次のコードを貼り付けます。

/app/dashboard/invoices/page.tsx
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
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() {
  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>
  );
}

上記コードのそれぞれのコンポーネントの役割は下記な感じです。

  • <Search/>を使用すると、ユーザーは特定の請求書を検索できます。
  • <Pagination/>を使用すると、ユーザーは請求書のページ間を移動できます。
  • <Table/>で請求書が表示されます。

検索機能はクライアントとサーバーにまたがります。ユーザーがクライアント上で請求書を検索すると、URLパラメーターが更新され、サーバー上でデータが取得されます。そして、新しいデータを使用してテーブルがサーバー上で再レンダリングされます。

なぜ(検索機能を実装するのに)URL検索パラメータを使用するのか?(Why use URL search params?)

URLパラメータを使用して検索機能を実装すると、次のような利点があります。

  • ブックマーク可能、および共有可能なURL : 検索パラメータはURL内にあるため、ユーザーは、参照や共有のために、検索クエリやフィルタを含むアプリケーションの現在の状態をブックマークできます。
  • サーバー側レンダリングと初期ロード : URLパラメーターをサーバー上で直接使用して初期状態をレンダリングできるため、サーバーサイドレンダリングの処理が容易になります。
  • 分析と追跡 : URLに検索クエリとフィルターを直接含めることで、追加のクライアント側ロジックを必要とせずに、ユーザーの行動を追跡することが容易になります。

検索機能の追加(Adding the search functionality)

下記がNext.jsで検索機能を追加するために使うクライアント側のhookです。

  • useSearchParams – 現在のURLのパラメータにアクセスできます。たとえば、 「/dashboard/invoices?page=1&query=pending」の検索パラメータは次のようになります: {page: ‘1’, query: ‘pending’}
  • usePathname – 現在のURLのパス名を読み取ることができます。たとえば、「/dashboard/invoices」の場合、usePathnameは「/dashboard/invoices」を返します。
  • useRouter – クライアントコンポーネント内のルート間のナビゲーションをプログラム的に有効にします。使用方法は複数あります(詳しくはこちらを参照)。

実装手順は下記の通りです。

①ユーザーの入力をキャプチャ(取得)する

<Search>コンポーネント(「/app/ui/search.tsx」)を見ると下記のことがわかります。

  • “use client”が使用されているのでクライアントコンポーネントということがわかります。つまり、イベントリスナーとフックが使用できます。
  • <input>要素が検索する文字を入力するものです。

新しい「handleSearch」関数を作成し、「onChange」リスナーを「input」要素に追加します。 onChangeは、入力値が変更されるたびにhandleSearchを呼び出します。(下記のようにコードを追加しましょう)

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
 
export default function Search({ placeholder }: { placeholder: string }) {
  function handleSearch(term: string) {
    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) => {
          handleSearch(e.target.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>
  );
}

開発者ツールを開いて、上記追加した機能が正しく動くか確認します。下の画像のように、何でも良いので、文字を入力して、入力ごとにコンソールに文字が出力されていればOKです。(僕は「test」と入力して確認しました!)

②検索パラメータを使用してURLを更新する

下記のように、useSearchParamsフックを「next/navigation」からインポートして、変数に割り当てます。

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
 
  function handleSearch(term: string) {
    console.log(term);
  }
  // ...
}

handleSearch内で、searchParams変数を使用して新しい「URLSearchParams」インスタンスを作成します。

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
  }
  // ...
}

「URLSearchParams」は、URLクエリパラメーターを操作するためのユーティリティメソッドを提供するWeb APIです(詳しくはこちら)。複雑な文字列リテラルを作成する代わりに、それを使用して「?page=1&query=a」のようなparams文字列を取得できます。

次に、下記のようにユーザーの入力に基づいてparams文字列を設定します。入力が空の場合は、それを削除します。

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
  }
  // ...
}

上記まででクエリ文字列が取得できました。Next.jsのuseRouterフックとusePathnameフックを使用してURLを更新できます。

下記のように、useRouterとusePathnameを「next/navigation」からインポートし、handleSearch内で、useRouter()から「replace」メソッドを使用します。

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }
}

上記のコードでは下記のことが起こっています。

  • 「${pathname}」は現在のパスを取得するコードであり、この場合は「/dashboard/invoices」です。
  • ユーザーが検索バー(input)に入力すると、「params.toString() 」がこの入力したものをURLに適した形式(文字列)に変換します。
  • 「replace(${pathname}?${params.toString()})」は、ユーザーの検索したいデータのURLに更新します。たとえば、ユーザーが「Lee」を検索する(入力した)場合は、「/dashboard/invoices?query=lee」となります。
  • Next.jsのクライアント側ナビゲーションの機能で、URLはページをリロードしなくても更新されます (これについては、ページ間のナビゲーションに関する章(5章)で学習しました)。

③URLと入力の同期(内容の一致)を維持する

現状、下の画像の赤枠のように、input要素に文字を入力すると、URLが入力の内容と同期され、更新されるようになっています。

しかし、URLパラメータを使用して検索機能を実装する利点の1つで挙げた、「ブックマーク可能、および共有可能なURL」がうまく動作しません。

試しに上の画像のURLをコピーして、新しいタブを開いて、URLをペーストしてみました。

すると、下の画像の赤枠のようにinput要素は空の状態になっています。

この問題を解決するために、下記のようにdefaultValueを設定します。

/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()}
/>

④テーブルの更新

最後に、検索クエリを反映するように<Table>コンポーネントを更新していきます。

「/app/dashboard/invoices/page.tsx」を下記のように修正していきます。

<Page>コンポーネントはpropsにsearchParamsというプロパティを設定して、その時のURLパラメータを<Table>コンポーネントに渡します。

/app/dashboard/invoices/page.tsx
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { Suspense } from 'react';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
 
export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
 
  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>
  );
}


<Table>コンポーネント(「/app/ui/invoices/table.tsx」)をみると、queryと currentPageという2つのプロパティが、クエリに一致する請求書を返す fetchFilteredInvoices()関数に渡されていることがわかります。

/app/ui/invoices/table.tsx
// ...
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);
  // ...
}

ではテストをしてみます。

まず「http://localhost:3000/dashboard/invoices」を表示すると(初期状態)、下の画像のような状態になるかと思います。

VercelのStorageで「invoices」テーブルを見ると、データは6件以上あります。

画面に6件しか表示されないのは少し違和感でしたが、下の画像のように、データを取得するfetchFilteredInvoices()(「/app/lib/data.ts」)で、6件に絞っているようでした。(SQLやテーブルのデータを詳しく確認はしていないので、もしかしたら間違えかもです…)

続いて、検索機能を試してみます。

「de」と入力すると恐らく「Customer」の名前(あるいは「Email」)の「De」にヒットして絞り込まれました。つまり上手く機能していそうです。

「89」と入力すると恐らく「Amount」の「89」にヒットして絞り込まれました。

※検索パラメータを抽出するために2つの異なる方法を使用しています。どちらを使用するかは、クライアントとサーバーのどちらでの処理かによって異なります。

  • <Search>はクライアントコンポーネントであるため、useSearchParams()フックを使用してクライアントからパラメータにアクセスしました。
  • <Table>は独自のデータを取得するサーバーコンポーネントであるため、ページからコンポーネントにsearchParamsプロパティを渡すことができます。

一般的には、クライアントからパラメータを読み取りたい場合は、useSearchParams()フックを使用します。これにより、サーバーに戻る必要がなくなります。

ベストプラクティス:「Debouncing」(Best practice: Debouncing)

上記までで、Next.jsで検索を実装できましたが、ここから最適化していきます。

下記のように「/app/ui/search.tsx」のhandleSearch関数内に、console.logを追加します。

/app/ui/search.tsx
function handleSearch(term: string) {
  console.log(`Searching... ${term}`);
 
  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}

次に、検索バーに「Emil」と入力し、開発ツールのコンソールを確認します。

Terminal
Searching... E
Searching... Em
Searching... Emi
Searching... Emil

キーを押すごとにURLが更新するため、その度にデータベースにクエリを実行することになります。このアプリケーションは小さいため、問題ではありませんが、アプリケーションに数千人のユーザーがいて、各ユーザーがキーを押すごとに新しいリクエストをデータベースに送信するとサーバーが耐えられないかもしれません。(アプリケーションが大きくなると問題になるよねということ)

Debouncing」は、関数が起動できる速度を制限するプログラミング手法です。この例では、ユーザーが入力をやめたときにのみデータベースにクエリを実行するようになります。

「Debouncing」の仕組み

  1. トリガーイベント : デバウンスすべきイベント(検索ボックスへの入力など) が発生すると、タイマーが開始します。
  2. 待機 : タイマーが期限切れになる前に新しいイベントが発生すると、タイマーはリセットされます。
  3. 実行 : タイマーが期限切れすると、「Debouncing」関数が実行されます。

「デバウンス」は、独自のデバウンス関数を手動で作成するなど、いくつかの方法で実装できますが、ここでは簡単に実装するために、下記のように「use-debounce」というライブラリを使用します。

Terminal
npm i use-debounce


「/app/ui/search.tsx」の<Search>コンポーネントで、下記のように「useDebouncedCallback」という関数をインポートします。

/app/ui/search.tsx
// ...
import { useDebouncedCallback } from 'use-debounce';
 
// Inside the Search Component...
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);

この関数はhandleSearchの内容をラップし、ユーザーが入力をやめてから特定の時間(ここでは300ミリ秒に設定している)が経過した後にのみコードを実行されるようになります。(再度、検索バーに「Emil」と打ち、開発ツールのコンソールを確認しましょう)

デバウンスすると、データベースに送信されるリクエストの数が減り、リソースが節約されます。

ページネーションの追加(Adding pagination)

URLパラメータを使ってページネーションを実装していきます。(請求書が6件しか表示されないのは、6件以上取得される場合はページネーションを使って別のページで表示させるためでした)

「/app/ui/invoices/pagination.tsx」の<Pagination/>コンポーネントはクライアントコンポーネントということがわかります。クライアントでデータフェッチするのは、データベースのシークレット(重要な情報)が漏洩する可能性があるので望ましくないです。代わりに、サーバー上のデータをフェッチして、それをpropとしてコンポーネントに渡すことができます。

下記のように「/app/dashboard/invoices/page.tsx」で、fetchInvoicesPagesという新しい関数をインポートし、searchParamsからのクエリを引数として渡します。

/app/dashboard/invoices/page.tsx
// ...
import { fetchInvoicesPages } from '@/app/lib/data';
 
export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string,
    page?: string,
  },
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
 
  const totalPages = await fetchInvoicesPages(query);
 
  return (
    // ...
  );
}

fetchInvoicesPagesは、検索クエリに基づいてページの合計数を返します。たとえば、検索クエリに一致する請求書が12件あり、各ページに6件の請求書が表示される場合、ページの合計数は2になります。

次に、下記のようにtotalPagesプロパティを<Pagination/>コンポーネントに渡します。

/app/dashboard/invoices/page.tsx
// ...
 
export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
 
  const totalPages = await fetchInvoicesPages(query);
 
  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>
  );
}

「/app/ui/invoices/pagination.tsx」の<Pagination/>コンポーネントでusePathname フックとuseSearchParamsフックをインポートします。そして、それを使用して現在のページを取得し、新しいページを設定します。このコンポーネント内のコードのコメントも解除してください。(「NOTE: comment in this code when you get to this point in the course」の下のコード)

/app/ui/invoices/pagination.tsx
'use client';
 
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';
 
export default function Pagination({ totalPages }: { totalPages: number }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;
 
  // ...
}

次に、下記のように <Pagination/>コンポーネント内に「createPageURL」という新しい関数を作成します。検索機能と同様に、URLSearchParamsを使用して新しいページ番号を設定し、pathNameを使用してURL文字列を作成します。

/app/ui/invoices/pagination.tsx
'use client';
 
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';
 
export default function Pagination({ totalPages }: { totalPages: number }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;
 
  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };
 
  // ...
}

下記のことが起こっています。

  • createPageURLは、現在指定している検索パラメータのインスタンスを作成します。(「
  • const params = new URLSearchParams(searchParams)」)
  • 次に、上記で作ったインスタンスの「page」というパラメータを、指定されたページ番号に更新します。(「params.set(‘page’, pageNumber.toString())」)
  • 最後に、パス名と更新された検索パラメーターを使用して完全な URL を構築します。(「
  • return `${pathname}?${params.toString()}`」)

<Pagination>コンポーネントの残りの部分は、ページネーションのスタイルとさまざまな状態 (ページネーションの1つ目、ページネーションの最後、アクティブなページネーション、無効なページネーションのなど) を設定する処理をしています。(このコースではコードの詳細には説明しないと書かれている)

最後に、ユーザーが新しい検索クエリを入力したときに、ページ番号を1にリセットします。これを行うために、下記のように<Search>コンポーネントのhandleSearch関数を更新します。

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
 
export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const { replace } = useRouter();
  const pathname = usePathname();
 
  const handleSearch = useDebouncedCallback((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);
 

「http://localhost:3000/dashboard/invoices」を確認すると、ページネーションが実装されており、しっかり機能することも確認できました。

Chapter 12(Mutating Data)

このChapterでは、請求書を作成、更新、削除する機能を追加していきます。

Server Actionsとは?(What are Server Actions?)

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

Webアプリケーションはさまざまな脅威に対して脆弱になる可能性があるため、セキュリティは最優先事項です。

Server Actionsは、

  • さまざまな種類の攻撃から保護
  • データ保護
  • 承認されたアクセスを保証

といったことへ効果的なセキュリティソリューションを提供します。

また、Server Actionsは、POSTリクエスト、暗号化されたクロージャ、厳格な入力チェック、エラーメッセージのハッシュ化、ホスト制限などの技術を通じて、上記へのソリューションを実現し、すべて連携してアプリの安全性を大幅に強化します。

Server Actionsでのフォームの使用(Using forms with Server Actions)

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

例えば下記のようにです。

TypeScript
// Server Component
export default function Page() {
  // Server Actionのもの
  async function create(formData: FormData) {
    'use server';
 
    // データを変更するロジック
  }
 
  // formのaction属性を使ってServer Actionsのものを呼び出している。
  return <form action={create}>...</form>;
}

サーバーコンポーネント内でServer Actionsを呼び出すことの利点は、段階的な機能拡張です。クライアントでJavaScriptが無効になっている場合でもフォームは機能します。(いまいち利点がわからない…^^;)

Next.jsでのServer Actions(Next.js with Server Actions)

Server ActionsはNext.jsのキャッシュとも深く関わっています。 Server Actionsを通じてフォームが送信されると、そのアクションを使用してデータを変更できるだけでなく、「revalidatePath」や「revalidateTag」などのAPIを使用して関連するキャッシュを再検証することもできます。

請求書の作成機能(Creating an invoice)

新しい請求書を作成する機能を作る手順は下記のとおりです。

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

下の画像のように、まず、「/app/dashboard/invoices」フォルダー内に、page.tsxファイルを使用して /create という新しいルートセグメント(createフォルダとその中にpage.tsxファイルを作り、アプリケーションに「/dashboard/create」という新しいルーティングを作るという意味かと思います。)を追加します。

下記のコードを「/app/dashboard/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>
  );
}

<Breadcrumbs>コンポーネントは、propsのbreadcrumbsをmapでループさせてパンクズリストを作るコンポーネントです。breadcrumbsのプロパティはそれぞれ下記の漢字で使われていました。

  • label : パンクズリストの文字になる。
  • href ; パンクズリストのリンク先になる。
  • active : 現在開いているページかどうかを判定するもの。

<Form>コンポーネントは、DBに登録されている顧客を全て取得してきた(fetchCustomers())ものを代入したcustomers変数をpropsで受け取って、セレクトボックスでそのお客さんが選択できるようになるコンポーネントです。そのセレクトボックスで選んだ顧客の請求書を作る感じになるようです。

「http://localhost:3000/dashboard/invoices/create」を開くと下記のようになっていればOKです。

②「Server Action」の作成

フォーム送信時に呼び出されるServer Actionを作成していきます。

「/app/lib」フォルダに「actions.ts」という新しいファイルを作成してください。そして下記のようにファイルの先頭に「’use server’」を書きましょう。

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

「’use server’」を追加すると、ファイル内のエクスポートされたすべての関数がサーバー関数となります。これらのサーバー機能はクライアントコンポーネントとサーバーコンポーネントからインポートできるため、非常に多用途なものになります。

アクション内に「’use server’」を追加することで、サーバーコンポーネント内にServer Actionを直接記述することもできます。ただし、このコースでは、それらをすべて別のファイルに書くことにします。

「/app/lib/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
'use client';
 
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では、URLをaction属性に渡します。このURLは、フォームデータの送信先 (通常はAPIエンドポイント) になります。ただし、Reactでは、action属性は特別なpropとみなされます。つまり、Reactで作られたformでは、アクションを呼び出すことができるようになります。「Server Actions」はこの時、POST APIエンドポイントを作成しており、それが「Server Actions」を使用するときに、APIエンドポイントを手動で作る必要がなり理由です。

③formDataからデータを抽出する

「/app/lib/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);
}

試しに下の画像のようにフォームに入力して、「Create Invoice」ボタンを押してみると

下の画像のように、ターミナルのコンソールにそのデータが表示されます。(「Delba de Oliveira」さんのcustomerIdはデータベースのusersテーブルのidを確認すると「3958dc9e-712f-4377-85e9-fec4b6a6442a」になっていました。)

④データを検証して準備する

フォームのデータをデータベースに送信する前に、データが正しい形式かつ正しいタイプであることを確認する必要があります。請求書テーブルでは下記の形式のデータが必要です。

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

しかし、現状フォームから取得できるのは「customerId」、「amount」、「status」のみです。

フォームからのデータがデータベース内の予期される型と一致していることを検証することが大切なので、下記のように「/app/lib/actions.ts」のアクション内にconsole.logを追加して、請求書を作成してください。(フォームの値を入力して「Create Invoice」ボタンを押してください)

/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);
  console.log(typeof rawFormData.amount);
}

下の画像の赤枠のように、amountの型が数値ではなく文字列であることがわかります。これは、type=”number”の入力要素が実際には数値ではなく文字列を返すためです。

型の検証を処理するには、いくつかの方法がありますが、ここでは型検証ライブラリの「Zod」を使用していきます。

「/app/lib/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: z.coerce.number()」でamountは数値(number型)に変更されます。

そして、「FormSchema.omit({ id: true, date: true })」でFormSchemaからFormDataの型に合わせたデータに変更しています。

そして「/app/lib/actions.ts」を下記のように変更します。

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

⑤データベースへのデータの挿入

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

⑥再検証とリダイレクト

Next.jsには、ユーザーのブラウザにルートセグメントを一時的に保存するクライアント側ルーターキャッシュ(Client-side Router Cache)があります。 このキャッシュは、プリフェッチ(prefetching)と併せて、サーバーに対するリクエストの数を減らしながら、ユーザーがルート間を迅速に移動できるようにします。

invoicesページで表示されるデータを更新しているため、上記で説明したキャッシュをクリアして、サーバーへの新しいリクエストをしたいです。 これは、Next.js の「revalidatePath」関数を使用して実行できます。(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」関数を使用して下記のようにするとリダイレクトができます。

/app/lib/actions.ts
'use server';
 
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
 
// ...
 
export async function createInvoice(formData: FormData) {
  // ...
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

これで、最初のServer Actionが実装できました。正しく機能しているか確認しましょう。

まず「http://localhost:3000/dashboard/invoices」を開いて現状取得される請求書を見ておきます。下の画像のようになっているはずです。

続いて、「http://localhost:3000/dashboard/invoices/create」に移動して、例えばですが下の画像のように入力して、「Create Invoice」ボタンを押します。

※ここでは下記のように設定しました。

  • Choose customer : 「Amy Burns」を選択
  • Choose an amount : 「100」を入力(あまり大きな数値にするとDBの型がINTなのでエラーになる)
  • Set the invoice status : 「Pending」にチェック

下の画像のように「http://localhost:3000/dashboard/invoices」に自動的に遷移して、先ほど入力したデータが一番上に表示されていればOKです!

請求書の更新機能(Updating an invoice)

請求書を更新する機能を作る手順は下記のとおりです。

①「invoice id」を使用して動的なルートセグメント(ページ)を作成する。

Next.jsではフォルダ名を「[]」で囲むことで動的なページを作成できます。たとえば、[id]、[post]、[slug] といった感じです。

「/app/dashboard/invoices」フォルダーに、[id]という新しい動的ルートを作成し、次にpage.tsx ファイルを使用して「edit」という新しいルートを作成します。ファイル構造は下の画像のようになります。

続いて、「/app/ui/invoices/table.tsx」の<Tabel>コンポーネントに、Invoicesテーブルのid(fetchFilteredInvoices関数で取得している)を受け取る<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>
    // ...
  );
}

「/app/ui/invoices/buttons.tsx」の<UpdateInvoice/>コンポーネントで、下記のようにinvoviceのidを使って動的にhref属性を設定できるようにコードを変更します。

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

②ページパラメータからInvoice idを読み取る。

「/app/dashboard/invoices/[id]/edit/page.tsx」に下記のコードを貼ります。

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

別のフォーム (edit-form.tsx ファイルから) をインポートする点を除けば、「/create invoice」ページと似ています。このフォームには、顧客の名前、請求金額、およびステータスのdefaultValueを事前に設定される必要があります。フォームフィールドに事前に設定するには、invoice idを使用して特定の請求書を取得できるようにする必要があります。

下記のようにsearchParamsを追加し、<Page>コンポーネントを更新してpropsとしてidを受け取るようにします。

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

③特定の請求書を取得

続いて、下記を

  • fetchInvoiceById関数をインポートして、引数としてidを渡します。
  • fetchCustomersをインポートして、ドロップダウンに設定する顧客名を取得します。

また、「Promise.all」を使用して、請求書と顧客の両方を並行して取得できます。

/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({ params }: { params: { id: string } }) {
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);
  // ...
}

下の画像のように、請求書が未定義である可能性があるため、端末の請求書プロパティに対して一時的なTSエラーが表示されます。次のChapterでエラー処理を追加するときに解決するので一旦無視してOKです。

これで実装は完了です。

正しく動くか確認するため「http://localhost:3000/dashboard/invoices」を開いて、鉛筆アイコンをクリックして請求書を編集ページを表示してください。遷移が完了すると、請求書の詳細が事前に入力されたフォームが表示されます。

URLは「http://localhost:3000/dashboard/invoice/uuid/edit」ようになっています。

UUIDと自動インクリメントキー使い分けについて

インクリメントキー (1、2、3というように自動で連番になる) の代わりにここではUUIDを使用しています。これによりURLが長くなります。ただし、UUIDはID衝突のリスクがなくなり、列挙型攻撃のリスクを軽減するため、大規模なデータベースで使うのが最適です。

ただし、より綺麗なURLを好む場合は、自動インクリメントキーを使用することをお勧めします。

④IDをServer Actionに渡す

最後に、データベース内の適切なレコードを更新できるように、IDをServer Actionに渡します。次のようにIDを引数として渡すことはできません。

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

代わりに下記のように、JS バインドを使用して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}>
      <input type="hidden" name="id" value={invoice.id} />
    </form>
  );
}

※フォームで非表示の入力フィールドを使用することもできます (例 : <input type=”hidden” name=”id” value={invoice.id} /> )。 ただし、値は HTMLソースにフルテキストとして表示されるため、IDなどの機密データには理想的ではありません。なので、上記の15行目のコードは今回は削除してください。

次に下記のように、「/app/lib/actions.ts」ファイルで、新しいServer Actionの「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. リダイレクトを呼び出して、ユーザーを請求書のページにリダイレクトします。

これで実装できました。正しく機能しているか確認しましょう。

まず「http://localhost:3000/dashboard/invoices」を開いて現状取得される請求書を見ておきます。下の画像のようになっているはずです。(「Amy Burns」さんのAmoutとStatusを編集するので確認しておきます)

鉛筆アイコンをクリックして請求書編集ページを表示します。

そして、Amountを「900」、Statusを「Paid」に変えて、「Edit Invoice」ボタンをクリックします。

請求書一覧ページが再び表示されて、請求書の変更した部分が更新されていればOKです!

請求書の削除機能(Deleting an invoice)

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 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」ファイル内に、deleteInvoiceという新しいServer Actionを作成します。

/app/lib/actions.ts
export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');
}

上記Server Actionは 「/dashboard/invoices」パスで呼び出されるため、リダイレクトする必要はありません。revalidatePathを呼び出すと、新しいサーバーへのリクエスト実行され、テーブルが再レンダリングされます。

終わりに

Chapter9〜Chapter12までを見てきました!

次の記事ではChapter13〜Chapter16をまとめる予定です。

しばしお待ちを٩( ‘ω’ )و

Next.jsに入門してみた(Chapter13〜16)【Next.js, React】
Next.jsに入門してみた(Chapter13〜16)【Next.js, React】こちらの記事ではNext.jsに入門するために、公式サイトのチュートリアルを翻訳し、僕自身の解釈や理解をまとめております。 Reactの基礎を学び終えて、Next.jsに入門したい方はぜひご覧ください。...

Reactを体系的に学ぶならこちらの動画と書籍がおすすめです!
(僕も使いました)

動画

【2023年最新】React(v18)完全入門ガイド|Hooks、Next.js、Redux、TypeScript icon

書籍