🏆

Next.js チュートリアルやってみた②

2024/12/01に公開

初めに

今回の記事はNext.jsのチュートリアル6章から11章までの記事です。

6章 データベースの設定

vercelに搭載されているvercel postgreを使用するためには、現在のプロジェクトをデプロイしなければいけません。
というわけでデプロイしましょう。
vercel.com/signupにアクセスしてくださいアカウントを作成します。
GitHubと Vercel アカウントを接続するには、「GitHub で続行」を選択します。
次に、この画面が表示され、作成した GitHub リポジトリを選択してインポートできます。

プロジェクトに名前を付けて、「デプロイ」をクリックします。

紙吹雪が舞えばデプロイ完了です。

GitHubリポジトリを接続すると、メインブランチに変更をプッシュするたびに、Vercelは設定を必要とせずにアプリケーションを自動的に再デプロイします。

次に、データベースを設定するには、「ダッシュボードに進む」をクリックし、プロジェクト ダッシュボードから「ストレージ」タブを選択します。「ストアに接続」 → 「新規作成」 → 「Postgres」 → 「続行」を選択します。

規約に同意し、データベースに名前を割り当て、データベースのリージョンがワシントンDC(iad1)に設定されていることを確認します。これはデフォルトのリージョンでもあります。すべての新しいVercelプロジェクトで使用できます。データリクエストの場合、データベースを同じリージョンまたはアプリケーションコードの近くに配置すると、レイテンシを短縮できます。

※本来なら日本に近い場所をリージョンにするべきです。
ただ今回アプリケーションがデプロイされている場所(デフォルトなら)がワシントンなので、
リージョンはワシントンを選択するべきです。
ここでシンガポールを選択するとデータのラウンドトリップが発生してしまいます。
ラウンドトリップとは現在地が日本、デプロイ先がワシントン、リージョンがシンガポールだと仮定した際に、日本からサイトの別ページのリクエストを送った際にワシントンにリクエストが飛びます。
その後データベースにアクセスしなければならない場合、ワシントンからシンガポールにリクエストが飛び、無駄な時間がかかってしまいます。
この無駄なデータ距離がラウンドトリップです。

接続したら、.env.localタブに移動し、「シークレットを表示」「スニペットをコピー」をクリックします。シークレットをコピーする前に必ず公開してください。

コード エディターに移動し、.env.exampleファイルの名前を に変更します
.env
。Vercel からコピーした内容を貼り付けます。

**重要:**ファイルに移動して、無視されたファイルに含まれていること.gitignoreを確認し、GitHub にプッシュするときにデータベースの秘密が公開されないようにします。.env
最後に、pnpm i @vercel/postgresターミナルで実行してVercel Postgres SDKをインストールします。
その後seedフォルダのroute.tsのコメントアウトを削除します。

  // return Response.json({
  //   message:
  //     'Uncomment this file and remove this line. You can delete this file when you are finished.',
  // });

ここはコメントアウトのままにしないと下記のコードに到達しないので、永遠に実行されません。

  try {
    await client.sql`BEGIN`;
    await seedUsers();
    await seedCustomers();
    await seedInvoices();
    await seedRevenue();
    await client.sql`COMMIT`;

    return Response.json({ message: 'Database seeded successfully' });
  } catch (error) {
    await client.sql`ROLLBACK`;
    return Response.json({ error }, { status: 500 });
  }

その後、package.jsonに下記のどちらかを追加します。
拡張子によって使い分けてください。

"seed": "node -r dotenv/config app/seed/route.js",
"seed": "tsx -r dotenv/config app/seed/route.ts",

7章 データの取得

API は、アプリケーション コードとデータベース間の中間層です。API が使用されるケースはいくつかあります。

  • API を提供するサードパーティのサービスを使用している場合。
  • クライアントからデータを取得する場合は、データベースの秘密がクライアントに公開されないように、サーバー上で実行される API レイヤーが必要になります。

データベースクエリ
• React Server Components (サーバー上でデータを取得する) を使用している場合は、API レイヤーをスキップして、データベースの秘密をクライアントに公開するリスクなしに、データベースを直接クエリできます。

Server Components を使用してデータを取得するのは比較的新しいアプローチであり、それを使用することにはいくつかの利点があります。

  • サーバー コンポーネントは Promise をサポートしており、async/awaitデータ取得などの非同期タスクに簡単なソリューションを提供します。、またはデータ取得ライブラリuseEffect/useStateにアクセスせずに構文を使用できます。
  • サーバー コンポーネントはサーバー上で実行されるため、コストのかかるデータ フェッチとロジックをサーバー上に保持し、結果のみをクライアントに送信できます。
  • 前述したように、サーバー コンポーネントはサーバー上で実行されるため、追加の API レイヤーなしでデータベースを直接クエリできます。
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // fetchRevenue()の実行終了を待っている 
const {
  numberOfInvoices,
  numberOfCustomers,
  totalPaidInvoices,
  totalPendingInvoices,
} = await fetchCardData(); // fetchLatestInvoices()の実行終了を待っている

上記のコードではデータ要求が意図せず相互にブロックされ、リクエストウォーターフォール

が発生します。

「ウォーターフォール」とは、前のリクエストの完了に依存する一連のネットワーク リクエストを指します。データ取得の場合、各リクエストは前のリクエストがデータを返した後にのみ開始できます。
これだとユーザーを待たせてしまう可能性があります。

ただこのパターンは必ずしも悪いわけではありません。
次のリクエストを行う前に条件を満たす必要があるため、ウォーターフォールが必要になる場合があります。
たとえば、最初にユーザーの ID とプロフィール情報を取得する必要があるとします。ID を取得したら、次に友達リストを取得するという手順に進みます。
この場合、各リクエストは、前のリクエストから返されたデータに依存します。

デフォルトでは、Next.js はパフォーマンスを向上させるためにルートを事前レンダリングします。これは、静的レンダリングと呼ばれます。そのため、データが変更されてもダッシュボードには反映されません。

ウォーターフォールを回避する一般的な方法は、すべてのデータ要求を同時に、つまり並行して開始することです。
JavaScriptでは、Promise.all()またはPromise.allSettled()関数を使用して、すべての Promise を同時に開始します。

export async function fetchCardData() {
  try {
    const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
    const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
    const invoiceStatusPromise = sql`SELECT
         SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
         SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
         FROM invoices`;
 
    const data = await Promise.all([
      invoiceCountPromise,
      customerCountPromise,
      invoiceStatusPromise,
    ]);
    // ...
  }
}

このパターンを使用すると、次のことが可能になります。

  • すべてのデータ フェッチを同時に実行し始めると、パフォーマンスが向上します。
  • あらゆるライブラリやフレームワークに適用できるネイティブ JavaScript パターンを使用します。

ここで一つ疑問です。
1 つのデータ要求が他のすべての要求よりも遅い場合はどうなるでしょうか?

8章 静的および動的レンダリング

静的レンダリングでは、データの取得とレンダリングは、ビルド時 (デプロイ時) またはデータの再検証時にサーバー上で行われます。

ユーザーがアプリケーションにアクセスするたびに、キャッシュされた結果が提供されます。静的レンダリングにはいくつかの利点があります。

  • より高速な Web サイト
    事前にレンダリングされたコンテンツをキャッシュし、世界中に配信できます。これにより、世界中のユーザーが Web サイトのコンテンツに、より迅速かつ確実にアクセスできるようになります。
  • サーバー負荷の軽減
    コンテンツがキャッシュされるため、サーバーはユーザーのリクエストごとにコンテンツを動的に生成する必要がありません。
  • SEO
    事前にレンダリングされたコンテンツは、ページが読み込まれたときにすでにコンテンツが利用可能になっているため、検索エンジンのクローラーがインデックスを作成しやすくなります。これにより、検索エンジンのランキングが向上する可能性があります。

静的レンダリングは、静的なブログ投稿や製品ページなど、データのない UIやユーザー間で共有されるデータには便利です。定期的に更新されるパーソナライズされたデータを持つダッシュボードには適さない可能性があります。

動的レンダリングでは、リクエスト時(ユーザーがページにアクセスしたとき)に各ユーザーのコンテンツがサーバー上でレンダリングされます。ダイナミック レンダリングには、次のような利点があります。

  • リアルタイム データ
    • 動的レンダリングにより、アプリケーションはリアルタイムのデータや頻繁に更新されるデータを表示できます。これは、データが頻繁に変更されるアプリケーションに最適です。
  • ユーザー固有のコンテンツ
    • ダッシュボードやユーザー プロファイルなどのパーソナライズされたコンテンツを提供し、ユーザーの操作に基づいてデータを更新することが簡単になります。
  • リクエスト時の情報
    • 動的レンダリングを使用すると、Cookie や URL 検索パラメータなど、リクエスト時にのみ知ることができる情報にアクセスできます。

ここで先ほどの「1 つのデータ要求が他のすべての要求よりも遅い場合はどうなるでしょうか?」に対する答えは、「動的レンダリングでは**アプリケーションの速度は最も遅いデータ取得速度と同じになりる。」**ということです。

9章 ストリーミング

ストリーミングは、ルートを小さな「チャンク」(部品みたいなイメージ)に分割し、準備が整うとサーバーからクライアントに段階的にストリーミングできるデータ転送技術です。

準備ができたデータから表示していこうみたいなイメージです。

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

各コンポーネントをチャンクと見なすことができるため、ストリーミングは React のコンポーネント モデルとうまく連携します。

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

  1. ページ レベルで、loading.tsx ファイルを使用します。
  2. 特定のコンポーネントについては、<Suspense> を使用します。

loading.tsx

  1. loading.tsxはSuspense 上に構築された特別な Next.js ファイルで、ページ コンテンツの読み込み中に代わりに表示されるフォールバック UI を作成できます。
  2. <SideNav><SideNav>は静的なので、すぐに表示されます。動的コンテンツの読み込み中に、ユーザーは操作できます。
  3. ユーザーは、ページの読み込みが完了するまで待たずに別のページに移動できます (これを中断可能なナビゲーションと呼びます)。

現時点では、読み込みスケルトンは請求書ページと顧客ページにも適用されます。

loading.tsxはファイルシステム内で/invoices/page.tsx/customers/page.tsxよりもレベルが高いため、それらのページにも適用されます。

これはルートグループで変更することができます。ダッシュボード フォルダ内に/(overview)という新しいフォルダを作成します。次に、loading.tsxおよびpage.tsxファイルをフォルダ内に移動します。

これで、loading.tsxファイルはダッシュボードの概要ページにのみ適用されます。

ルート グループを使用すると、URL パス構造に影響を与えずにファイルを論理グループに整理できます。括弧を使用して新しいフォルダーを作成すると、名前は URL パスに含まれません。つまり、/dashboard/(overview)/page.tsxになります。

コンポーネントのストリーミング

現在、ページ全体をストリーミングしています。ただし、React Suspense を使用すると、より細かく特定のコンポーネントをストリーミングすることもできます。

Suspense を使用すると、何らかの条件が満たされるまで (たとえば、データがロードされるまで)、アプリケーションの一部のレンダリングを延期できます。動的コンポーネントを Suspense でラップできます。次に、動的コンポーネントのロード中に表示されるフォールバック コンポーネントを渡します。

サスペンスの境界線をどこに置くか決める

サスペンスの境界をどこに置くかは、いくつかの要素によって決まります。

  1. ストリーミング中にユーザーにページをどのように体験させたいか。
  2. 優先したいコンテンツ。
  3. コンポーネントがデータ取得に依存している場合。

サスペンスの境界をどこに置くかは、アプリケーションによって異なります。一般的には、データ フェッチをそれを必要とするコンポーネントまで移動し、それらのコンポーネントをサスペンスでラップするのが良い方法です。ただし、アプリケーションで必要な場合は、セクションまたはページ全体をストリーミングしても問題ありません。

Suspense を試してみて、何が最も効果的かを確認してください。Suspense は、より楽しいユーザー エクスペリエンスを作成するのに役立つ強力な API です。

10章 部分的な事前レンダリング (PPR)

**部分的事前レンダリング (PPR)**を使用して、静的レンダリング、動的レンダリング、ストリーミングを同じルートで組み合わせる方法を学びましょう。

現在構築されているほとんどの Web アプリでは、アプリケーション全体または特定のルートに対して、静的レンダリングと動的レンダリングのどちらかを選択します。また、Next.js では、ルート内で動的関数を呼び出すと(データベースのクエリなど)、ルート全体が動的になります。

しかし、ほとんどのルートは完全に静的または動的ではありません。たとえば、eコマースサイトを考えてみましょう。(Amazonみたいなページ)製品情報ページの大部分を静的にレンダリングし、ユーザーのカートや推奨製品を動的に取得して、ユーザーにパーソナライズされたコンテンツを表示することもできます。

ダッシュボード ページに戻って、静的コンポーネントと動的コンポーネントのどちらを検討しますか?

**Next.js 14 では、部分的事前レンダリング (PPR)**の実験的なバージョンが導入されました。これは、同じルートで静的レンダリングと動的レンダリングの利点を組み合わせることができる新しいレンダリング モデルです。

ユーザーがルートを訪問すると

  • ナビゲーション バーと製品情報を含む静的ルート シェルが提供され、初期読み込みが高速化されます。
  • シェルには、カートや推奨製品などの動的コンテンツが非同期的に読み込まれる穴が残されています。
  • 非同期ホールは並列でストリーミングされるため、ページの全体的な読み込み時間が短縮されます。

部分的な事前レンダリングはどのように機能しますか?

部分的な事前レンダリングはReactのサスペンスを使用する(前の章で学習しました) 何らかの条件が満たされるまで (たとえば、データが読み込まれるまで)、アプリケーションの一部のレンダリングを延期します。

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

コンポーネントを Suspense でラップしても、コンポーネント自体が動的になるわけではありませんが、むしろ Suspense は静的コードと動的コード間の境界として使用されます。

部分的な事前レンダリングの実装

Next.jsアプリでPPRを有効にするには、pprファイルにオプションを追加しますnext.config.mjs:

/** @type {import('next').NextConfig} */
 
const nextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};
 
export default nextConfig;

この'incremental'値により、特定のルートに PPR を採用できるようになります。
次に、ダッシュボード レイアウトにexperimental_pprセグメント設定オプションを追加します。

import SideNav from '@/app/ui/dashboard/sidenav';
export const experimental_ppr = true;
// ...

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

部分的な事前レンダリングの優れた点は、これを使用するためにコードを変更する必要がないことです。ルートの動的な部分を Suspense でラップしている限り、Next.js はルートのどの部分が静的でどの部分が動的であるかを認識します。

まとめ

要約すると、アプリケーションでのデータ取得を最適化するためにいくつかのことを行いました。
(一つ前の記事の内容も含む)

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

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

<Search placeholder="Search invoices..." />部分の実装

'use client';

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

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();
  
  const handleSearch = useDebouncedCallback((term) => {
    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 (
    <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);
        }}
        // ユーザーがURLにクエリを記載していた場合に、検索バーにクエリを表示させる処理
        defaultValue={searchParams.get('query')?.toString()}
      />
      <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>
  );
}

<Pagination/>の実装

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

  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}
                href={createPageURL(page)}
                page={page}
                position={position}
                isActive={currentPage === page}
              />
            );
          })}
        </div>

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

function PaginationNumber({
  page,
  href,
  isActive,
  position,
}: {
  page: number | string;
  href: string;
  position?: 'first' | 'last' | 'middle' | 'single';
  isActive: boolean;
}) {
  const className = clsx(
    'flex h-10 w-10 items-center justify-center text-sm border',
    {
      'rounded-l-md': position === 'first' || position === 'single',
      'rounded-r-md': position === 'last' || position === 'single',
      'z-10 bg-blue-600 border-blue-600 text-white': isActive,
      'hover:bg-gray-100': !isActive && position !== 'middle',
      'text-gray-300': position === 'middle',
    },
  );

  return isActive || position === 'middle' ? (
    <div className={className}>{page}</div>
  ) : (
    <Link href={href} className={className}>
      {page}
    </Link>
  );
}

function PaginationArrow({
  href,
  direction,
  isDisabled,
}: {
  href: string;
  direction: 'left' | 'right';
  isDisabled?: boolean;
}) {
  const className = clsx(
    'flex h-10 w-10 items-center justify-center rounded-md border',
    {
      'pointer-events-none text-gray-300': isDisabled,
      'hover:bg-gray-100': !isDisabled,
      'mr-2 md:mr-4': direction === 'left',
      'ml-2 md:ml-4': direction === 'right',
    },
  );

  const icon =
    direction === 'left' ? (
      <ArrowLeftIcon className="w-4" />
    ) : (
      <ArrowRightIcon className="w-4" />
    );

  return isDisabled ? (
    <div className={className}>{icon}</div>
  ) : (
    <Link className={className} href={href}>
      {icon}
    </Link>
  );
}

画面のコード

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';
import { fetchInvoicesPages } from '@/app/lib/data';
 
export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
  const searchParams = await props.searchParams;
  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>
  );
}
  1. <Search/>ユーザーは特定の請求書を検索できます。
  2. <Pagination/>ユーザーは請求書のページ間を移動できます。
  3. <Table/>請求書を表示します。

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

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

  • ブックマークおよび共有可能な URL

    検索パラメータは URL に含まれているため、ユーザーは検索クエリやフィルターを含むアプリケーションの現在の状態をブックマークし、後で参照したり共有したりすることができます。

  • サーバー側レンダリングと初期ロード

    URL パラメータをサーバー上で直接使用して初期状態をレンダリングできるため、サーバー レンダリングの処理が容易になります。

  • 分析と追跡

    検索クエリとフィルターを URL に直接含めることで、追加のクライアント側ロジックを必要とせずにユーザーの行動を追跡しやすくなります。

検索機能を実装するために使用する Next.js クライアント フックは次のとおりです。

  • useSearchParams

    • 現在の URL のパラメータにアクセスできます。たとえば、このURL/dashboard/invoices?page=1&query=pendingの検索パラメータは次のようになります

    {page: '1', query: 'pending'}

  • usePathname

    • 現在の URL のパス名を読み取ることができます。たとえば、ルート の場合

    /dashboard/invoicesusePathname/dashboard/invoicesを返します。

  • useRouter

    • クライアント コンポーネント内のルート間のナビゲーションをプログラムで有効にします。使用できる方法は複数あります。

こんな方法もあります。
[GitHub - 47ng/nuqs: Type-safe search params state manager for React frameworks - Like useState, but stored in the URL query string.](https://github.com/47ng/nuqs)

一旦終了

拙い記事を最後までご覧いただき、誠にありがとうございます。
この続きはまた書こうと思いますので、立ち寄っていただけると幸いです。

Discussion