Next.jsの勉強がてら公式のチュートリアルを1からなぞってみました。
実際にチュートリアルをベースに書いたソースコードはこちら
- Next.js基礎コース App Router やってみた 1 ~ 3
- Next.js基礎コース App Router やってみた 4 ~ 6
- Next.js基礎コース App Router やってみた 7 ~ 9
- Next.js基礎コース App Router やってみた 10 ~ 12
- Next.js基礎コース App Router やってみた 13 ~ 15
- Next.js基礎コース App Router やってみた 16 ~ 17
7. データの取得
React サーバー コンポーネントを使用している場合は、API レイヤーをスキップして、データベース認証情報をクライアントに公開するリスクなしに、データベースを直接クエリできます。
サーバーコンポーネントを使用してデータを取得する
Next.jsアプリケーションはデフォルトでReact Server Componentsを使用します。Server Componentsを使ったデータ取得は比較的新しいアプローチであり、いくつかの利点があります。
- サーバーコンポーネントはJavaScriptのPromiseをサポートしており、データ取得を非同期で実行できます。
useEffectやuseStateといったデータ取得ライブラリなしに、async/await構文を使用できます。 - サーバーコンポーネントはサーバー上で実行されるため、負荷の高いデータ取得やロジックをサーバー側に保持し、結果のみをクライアントに送信できます。
- サーバーコンポーネントはサーバー上で実行されるため、追加のAPIレイヤーを介さずにデータベースを直接クエリできます。
SQLの使用
SQLは以下に用意してあります。
-
/app/lib/data.tsSQLを実行する関数群が定義されています -
/app/lib/definitions.tsSQLの実行結果のデータ型が定義されています
postgresはローカルに構築してあるので ssl: false に設定します。
/app/lib/data.ts
import postgres from 'postgres';
import { Revenue } from './definitions';
const sql = postgres(process.env.POSTGRES_URL!, { ssl: false });
export async function fetchRevenue(): Promise<postgres.RowList<Revenue[]>> {
try {
const data = await sql<Revenue[]>`SELECT * FROM revenue`;
return data;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
// ...
/app/lib/definitions.ts
export type Revenue = {
month: string;
revenue: number;
};
ダッシュボードのデータ取得準備
データ取得用のページを作成します。
/app/dashboard/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';
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">
{/* <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">
{/* <RevenueChart revenue={revenue} /> */}
{/* <LatestInvoices latestInvoices={latestInvoices} /> */}
</div>
</main>
);
}
データを受信するコンポーネントが3つあります(<Card> <RevenueChart> <LatestInvoices> )。これらは現在コメントアウトされており、まだ実装されていません。
<RevenueChart> のデータを取得
<RevenueChart/> コンポーネントのデータを取得するには、 /app/lib/data.ts の fetchRevenue 関数をインポートしてコンポーネント内で呼び出します。
/app/dashboard/page.tsx
// ...
import { fetchRevenue } from '@/app/lib/data'; // fetchRevenueのインポート
export default async function Page() { // fetchRevenueは非同期関数なので、呼び出し側のPageも非同期関数にする
const revenue = await fetchRevenue(); // fetchRevenueの呼び出し
return (
<main>
{/* ... */}
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<RevenueChart revenue={revenue} /> {/* RevenueChartをコメントイン */}
</div>
</main>
);
}
次に RevenueChart コンポーネントのコメントアウトされている部分をコメントインします。
/app/ui/dashboard/revenue-chart.tsx
<LatestInvoices /> のデータを取得
<LatestInvoices /> は最新の請求書を5軒取得して表示するコンポーネントです。
SQLのクエリは次のようになっています。
/app/lib/data.ts
// Fetch the last 5 invoices, sorted by date
const data = await sql<LatestInvoiceRaw[]>`
SELECT invoices.amount, customers.name, customers.image_url, customers.email
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
ORDER BY invoices.date DESC
LIMIT 5`;
<LatestInvoices/> コンポーネントのデータを取得するには、 /app/lib/data.ts の fetchLatestInvoices 関数をインポートしてコンポーネント内で呼び出します。
/app/dashboard/page.tsx
// ...
import { fetchRevenue, fetchLatestInvoices } from '@/app/lib/data'; // fetchLatestInvoicesのインポート
export default async function Page() {
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // fetchLatestInvoicesの呼び出し
return (
<main>
{/* ... */}
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<RevenueChart revenue={revenue} />
<LatestInvoices latestInvoices={latestInvoices} /> {/* LatestInvoicesをコメントイン */}
</div>
</main>
);
}
次に LatestInvoices コンポーネントのコメントアウトされている部分をコメントインします。
<Card> コンポーネントのデータを取得する
カードコンポーネントには以下のデータが表示されます。
- 回収した請求書の合計金額。
- 保留中の請求書の合計金額。
- 請求書の合計数。
- 顧客総数。
<Card/> コンポーネントのデータを取得するには、 /app/lib/data.ts の fetchCardData 関数をインポートしてコンポーネント内で呼び出します。
fetchCardData は4つの結果を返却します。
/app/dashboard/page.tsx
// ...
import { fetchRevenue, fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // fetchCardData のインポート
export default async function Page() {
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices();
const {
totalPaidInvoices,
totalPendingInvoices,
numberOfInvoices,
numberOfCustomers
} = await fetchCardData(); // fetchCardData の呼び出し
return (
<main>
{/* ... */}
<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">
{/* ... */}
</div>
</main>
);
}
注意すべき点
この構成はパフォーマンス上の2つの問題があります。
- データ要求が意図せず相互にブロックされ、要求ウォーターフォールが発生します。
- デフォルトでは、Next.jsはパフォーマンス向上のためにルートを事前レンダリングします。これは静的レンダリングと呼ばれます。そのため、データが変更されてもダッシュボードには反映されません。
リクエストフォーターフォール
「ウォーターフォール」とは、前のリクエストの完了に依存する一連のネットワークリクエストを指します。データ取得の場合、各リクエストは前のリクエストがデータを返した後にのみ開始されます。
例えば、以下のコードだと、 fetchLatestInvoices() を実行するには fetchRevenue() の完了を待たなければなりません。
/app/dashboard/page.tsx
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // wait for fetchRevenue() to finish
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData(); // wait for fetchLatestInvoices() to finish
このパターンは必ずしも悪いわけではありません。次のリクエストを行う前に条件を満たしたい場合、ウォーターフォールが必要となる場合があります。例えば、まずユーザーのIDとプロフィール情報を取得したい場合です。IDを取得したら、次に友達リストの取得に進むことができます。この場合、各リクエストは前のリクエストで返されたデータに依存します。
ただし、この動作は意図しないものであり、パフォーマンスに影響を与える可能性もあります。
並列データ取得
ウォーターフォールを回避する一般的な方法は、すべてのデータ要求を同時に、つまり並行して開始することです。
JavaScriptでは、Promise.all()またはPromise.allSettled()関数を使って、すべてのPromiseを同時に開始します。
例えば、 /app/lib/data.ts の fetchCardData() では関数内で Promise.all() を使用しています。
/app/lib/data.ts
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. 静的レンダリングと動的レンダリング
静的レンダリング
静的レンダリングでは、データの取得とレンダリングはサーバー上でビルド時(デプロイ時)またはデータの再検証時に実行されます。
ブログ投稿や製品ページなど、データがないUIやユーザー間で共有されるデータがある場合に有効です。
メリット
- 高速 キャッシュが効くためレスポンスを高速にすることができる
- 負荷軽減 キャッシュが効くため、サーバーのコンピューティングリソースを削減できる
- SEO 事前レンダリングされたコンテンツは検索エンジンのクローラーによるインデックス作成が容易になる
動的レンダリング
動的レンダリングではコンテンツはリクエスト時にサーバー上でレンダリングされます。
メリット
- リアルタイムデータ 頻繁に更新されるデータをリアルタイムに確認することができる
- ユーザー固有のコンテンツ ダッシュボードやユーザープロファイルなどのパーソナライズされたコンテンツを提供することが容易
- リクエスト時の情報 CookieやGETパラメータなど、リクエスト時にのみ知る事ができる情報にアクセスできる
時間のかかるデータ取得のシミュレーション
あるデータリクエストが他のすべてのリクエストよりも遅い場合をシミュレーションするために data.ts の fetchRevenue() を修正します。
/app/lib/data.ts
export async function fetchRevenue() {
try {
// We artificially delay a response for demo purposes.
// Don't do this in production :)
console.log('Fetching revenue data...'); // コメントイン
await new Promise((resolve) => setTimeout(resolve, 3000)); // コメントイン
const data = await sql<Revenue[]>`SELECT * FROM revenue`;
console.log('Data fetch completed after 3 seconds.'); // コメントイン
return data;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
http://localhost:3000/dashboard/ を開くと、fetchRevenue() が完了するまで、ページ全体がブロックされ、UIを表示できなくなります。
動的レンダリングでは、アプリケーションの速度は、最も遅いデータ取得速度と同じになります。
9. ストリーミング
前章で問題となったデータリクエストの遅延が発生した場合に、ユーザーエクスペリエンスを向上させる方法を見ていきましょう。
ストリーミング
ストリーミングとは、ルートを小さな「チャンク」に分割し、準備が整い次第、サーバーからクライアントへ順次転送することができるデータを転送する技術です。
レスポンスの遅いリクエストを別のチャンクにすることで、遅いデータリクエストがページ全体をブロックするのを防げます。
ストリーミングはReactのコンポーネントモデルと相性が良く、各コンポーネントをチャンクと見なすことができます。
Next.jsでストリーミングを実装する方法は2つあります:
-
ページレベル : loading.tsxファイルを使用(
<Suspense>を自動生成) -
コンポーネントレベル : より細かい制御のための
<Suspense>を使用
ページレベルのストリーミング ( loading.tsx )
loading.tsx は React Suspense を基盤とした Next.js の特殊なファイルです。ページコンテンツの読み込み中に表示する代替用フォールバック UI を作成できます。
/app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading...</div>;
}
-
<SideNav>は静的要素のため即時表示されます。動的コンテンツの読み込み中もユーザーは<SideNav>とインタラクション可能です。 - ユーザーはページ読み込み完了を待たずに移動できます(interruptable navigation)
ローディングスケルトン
UXをさらに向上させるために、テキストではなくローディングスケルトンを表示してみましょう。
loading.tsx ファイルで <DashboardSkeleton> をインポートします。
/app/dashboard/loading.tsx
import DashboardSkeleton from "@/app/ui/skeletons";
export default function Loading() {
return <DashboardSkeleton />;
}
Route Groupでスケルトンの読み込みバグを修正
現在、ローディング用スケルトンは /app/dashboard/page.tsx に適用されますが、 /app/dashboard/loading.tsx は下位の /invoices/page.tsx および /customers/page.tsx にも適用されてしまいます。
/app/dashboard/page.tsx のみのローディング画面としたい場合は Route Group を利用することでこれを解決できます。
Route Group /app/dashboard/(hogehoge)/page.tsx のようにフォルダ名を括弧でくくることで作成でき、() で囲まれた名前はURLパスに含まれないので、URLパスに影響を与えずにファイルを論理的なグループに整理できます。
※ 例 (marketing) (shop) など
ここでは、ルートグループを使用して loading.tsx がダッシュボードページにのみ適用されるようにします。
mkdir -p "app/dashboard/(overview)"
mv "app/dashboard/loading.tsx" "app/dashboard/(overview)/loading.tsx"
mv "app/dashboard/page.tsx" "app/dashboard/(overview)/page.tsx"
コンポーネントレベルのストリーミング (React Suspense)
ここまではページ全体をストリーミングしていましたが、React Suspense を使えば、特定のコンポーネントのみをストリーミングすることも可能です。
Suspense を使用すると、アプリケーションの一部のレンダリングを、特定の条件(データのロードなど)が満たされるまで延期できます。動的コンポーネントを Suspense でラップし、動的コンポーネントのロード中に表示されるフォールバックコンポーネントを渡すことができます。
そのためには、データ取得をコンポーネントに移動する必要があります。コードを更新して、それがどのようになるかを確認しましょう。
<RevenueChart> をSuspenseでラップする
<RevenueChart> コンポーネント側でデータ取得(fetchRevenue()) の実行を行うように修正します。
/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() { // asyncを付与して、引数を削除します
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 (
// ...
);
}
ページ側で fetchRevenue() の呼び出しを削除し、<RevenueChart> を <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 { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // fetchRevenue を削除
import { Suspense } from 'react'; // Suspenseのimportを追加
import { RevenueChartSkeleton } from '@/app/ui/skeletons'; // ローディングスケルトンのインポート
export default async function Page() {
//const revenue = await fetchRevenue() // コンポーネント側で呼び出すので削除
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">
{/* ... */}
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
{/* RevenueChartをSuspenseでラップし、フォールバックにスケルトンを指定 */}
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* ... */}
</div>
</main>
);
}
<LatestInvoices> をSuspenseでラップする
<LatestInvoices> コンポーネント側でデータ取得(fetchLatestInvoices()) の実行を行うように修正します。
/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() { // asyncを付与して、引数を削除します
const latestInvoices = await fetchLatestInvoices(); // 追加 コンポーネント側でデータ取得を行う
return (
// ...
);
}
ページ側で fetchLatestInvoices() の呼び出しを削除し、<LatestInvoices> を <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'; // 削除
import { Suspense } from 'react';
import {
RevenueChartSkeleton,
LatestInvoicesSkeleton, // LatestInvoices のローディングスケルトンをインポート
} 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">
{/* ... */}
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
{/* ... */}
{/* LatestInvoices をSuspenseでラップし、フォールバックにスケルトンを指定 */}
<Suspense fallback={<LatestInvoicesSkeleton />}>
<LatestInvoices />
</Suspense>
</div>
</main>
);
}
<Card> をSuspenseでラップする
素晴らしい!あと一歩です。次に、 <Card> コンポーネントをSuspenseでラップする必要があります。個々のカードごとにデータをフェッチすることも可能ですが、カードが読み込まれる際にポップアップ効果が発生する可能性があり、ユーザーにとって視覚的に不快に感じられる場合があります。
では、この問題をどう解決しますか?
<Card> コンポーネントは個々のカードごとにデータ取得を行うのではなく、ラッパーコンポーネント(CardWrapper)を作成し、 <Card>をグループ化します。これにより、静的な <SideNav/> が最初に表示され、その後カードが表示されるようになります。
まずは、 <Card> コンポーネントをグループ化した <CardWrapper> コンポーネントを実装します。
今まで同様、データの取得は <CardWrapper> コンポーネント内で行います。
/app/ui/dashboard/cards.tsx
// ...
import { fetchCardData } from "@/app/lib/data"; // 追加
//...
export default async function CardWrapper() { // 追加
const {
totalPaidInvoices,
totalPendingInvoices,
numberOfInvoices,
numberOfCustomers
} = 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" />
</>
)
}
ページ側で fetchCardData() の呼び出しを削除し、 <CardWrapper> を <Suspense> でラップします
/app/dashboard/(overview)/page.tsx
// import { Card } from '@/app/ui/dashboard/cards'; // 削除
import { CardWrapper } 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 { Suspense } from 'react';
import {
RevenueChartSkeleton,
LatestInvoicesSkeleton,
CardsSkeleton, // CardWrapper のローディングスケルトンをインポート
} from '@/app/ui/skeletons';
export default async function Page() {
// 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">
{/* CardWrapper をSuspenseでラップし、フォールバックにスケルトンを指定 */}
<Suspense fallback={<CardsSkeleton />}>
<CardWrapper />
</Suspense>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
{/* ... */}
</div>
</main>
);
}
Suspenseの境界線をどこに置くか
サスペンス境界の設定位置は、以下の要素によって決まります
- ページがストリーミングされる際のユーザー体験の意図
- 優先的に表示したいコンテンツ
- コンポーネントがデータ取得に依存しているかどうか
明確な答えはありません
- loading.tsxのようにページ全体をストリーミングすることも可能です
ただし、コンポーネントの1つでデータ取得が遅い場合、読み込み時間が長くなる可能性があります。 - 各コンポーネントを個別にストリーミングすることも可能です
ただし、コンポーネントの準備が整うたびにUIが画面に突然表示される可能性があります。 - ページセクションをストリーミングして段階的な表示効果を作ることもできます
ただし、ラッパーコンポーネントを作成する必要があります。
サスペンス境界をどこに配置するかは、アプリケーションによって異なります。一般的に、データ取得は必要なコンポーネントに下位配置し、それらをサスペンスで囲むのが良い実践です。ただし、アプリケーションの要件に応じてセクション単位やページ全体のストリーミングを選択しても問題ありません。