📚

【7章】Next.jsのチュートリアルをやってみた

2025/01/12に公開

これは Next.js の公式チュートリアルの7. Fetching Dataにやってみたメモです

前章のメモ

https://zenn.dev/kuuki/articles/nextjs-tutorial-06/

Next.js の公式チュートリアルの該当ページ

https://nextjs.org/learn/dashboard-app/fetching-data

学ぶこと

  • API、ORM、SQL などデータを取得する方法
  • サーバーコンポーネントでバックエンドへ安全にアクセス
  • リクエスト ウォーターフォールとは?
  • 並列にデータを取得する方法

データを取得する方法

データベースへクエリを投げる

MySQL や PostgreSQL のような RDB の場合は、Prismaなどの**ORM**を使用してもいい。

Prisma を使ってみたい方は ↓ を参考にしてみてください!
https://zenn.dev/kuuki/articles/nextjs-use-prisma-postgresql-local/

React Server Components を使用したデータ取得

デフォルトで React Server Components を使用します。

  • Promiseをサポートしており、非同期タスクを実行できる
    • useEffect, useState, ライブラリなしにasync/await が使える
  • データの取得や処理をサーバ上で実施し、結果のみをクライアントに返す。
  • 直接 DB にクエリを発行するため、API 層がいらない

SQL を使うには

今回は、Vercel Postgres SDKを使用して DB に SQL を投げます。

接続先の DB は環境変数を参照(今回だと.env ファイル)

/app/lib/data.tsのように、@vercel/postgres から sql 関数 をインポートして SQL を投げます。

投げる SQL 文はテンプレートリテラルで記述するので変数の展開も可能。

import { sql } from '@vercel/postgres';
import {
  CustomerField,
  CustomersTableType,
  InvoiceForm,
  InvoicesTable,
  LatestInvoiceRaw,
  User,
  Revenue,
} from './definitions';
import { formatCurrency } from './utils';

export async function fetchRevenue() {
  // Add noStore() here to prevent the response from being cached.
  // This is equivalent to in fetch(..., {cache: 'no-store'}).

  try {
    // 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`;

......

sql 関数はサーバーコンポーネントで使用してください。
また、各コンポーネントで SQL を投げると収拾がつかなくなるので、
/app/lib/data.tsのようにあるコンポーネントで SQL を記載し、他のコンポーネントでインポートするのがオススメ

ダッシュボードの UI を作りこむ

DB からデータを取得する方法を学んだところでダッシュボードの UI を作っていきます

まずは ↓ のコードを /app/dashboard/page.tsxにコピペします

/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>
  );
}
  • async fucntionとし、awaitなどを使用した非同期処理を可能にしています。
  • Card, RevenueChart, LatestInvoices はこの後コメントアウトを外します

データを取得して RevenueChart コンポーネントに渡す

/app/dashboard/page.tsxで RevenueChart コンポーネントに渡すrevenueを定義します。

これには DB からとってきた revenue のデータを入れます。

/app/lib/data.tsfetchRevenue関数でデータを取りに行きます。

関数内の SQL 文を見ると、たしかにrevenue テーブルを SELECTしてますね。

/app/lib/data.ts
# 一部抜粋

export async function fetchRevenue() {
  // Add noStore() here to prevent the response from being cached.
  // This is equivalent to in fetch(..., {cache: 'no-store'}).

  try {
    // 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.rows;
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch revenue data.');
  }
}

このfetchRevenue関数を /app/dashboard/page.tsx で import して使っていく。

RevenueChartに渡すrevenueにデータを入れたのでコメントアウトを外しておきます

/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';
import { fetchRevenue } from '@/app/lib/data';

export default async function Page() {
  const revenue = await fetchRevenue();

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

最後に /app/ui/dashboard/revenue-chart.tsx を開きコメントアウトを外していきます

/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 { Revenue } from '@/app/lib/definitions';

// This component is representational only.
// For data visualization UI, check out:
// https://www.tremor.so/
// https://www.chartjs.org/
// https://airbnb.io/visx/

export default async function RevenueChart({
  revenue,
}: {
  revenue: Revenue[];
}) {
  const chartHeight = 350;
  // NOTE: comment in this code when you get to this point in the course

  const { yAxisLabels, topLabel } = generateYAxis(revenue);

  if (!revenue || revenue.length === 0) {
    return <p className="mt-4 text-gray-400">No data available.</p>;
  }

  return (
    <div className="w-full md:col-span-4">
      <h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Recent Revenue
      </h2>
      {/* NOTE: comment in this code when you get to this point in the course */}

      <div className="rounded-xl bg-gray-50 p-4">
        <div className="sm:grid-cols-13 mt-0 grid grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4">
          <div
            className="mb-6 hidden flex-col justify-between text-sm text-gray-400 sm:flex"
            style={{ height: `${chartHeight}px` }}
          >
            {yAxisLabels.map((label) => (
              <p key={label}>{label}</p>
            ))}
          </div>

          {revenue.map((month) => (
            <div key={month.month} className="flex flex-col items-center gap-2">
              <div
                className="w-full rounded-md bg-blue-300"
                style={{
                  height: `${(chartHeight / topLabel) * month.revenue}px`,
                }}
              ></div>
              <p className="-rotate-90 text-sm text-gray-400 sm:rotate-0">
                {month.month}
              </p>
            </div>
          ))}
        </div>
        <div className="flex items-center pb-2 pt-6">
          <CalendarIcon className="h-5 w-5 text-gray-500" />
          <h3 className="ml-2 text-sm text-gray-500 ">Last 12 months</h3>
        </div>
      </div>
    </div>
  );
}

準備完了なので、ローカルサーバを起動してブラウザからアクセスします

// ローカルサーバー起動
$ npm run dev

// /app/dashboard/page.tsx を見たいので
// http://localhost:3000/dashboard   にアクセス!

↓ の感じで棒グラフが出現すれば OK!

データを取得して LatestInvoices コンポーネントに渡す

お次は、LatestInvoices コンポーネントにデータを渡していきます。

先ほどと同じように /app/lib/data.tsでデータを取得するfetchLatestInvoices関数が定義。

こちらは、JOIN やら ORDER BY やら LIMIT 使ってますねー

*/app/lib/data.ts
// 一部抜粋

export async function fetchLatestInvoices() {
  try {
    const data = await sql<LatestInvoiceRaw>`
      SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
      FROM invoices
      JOIN customers ON invoices.customer_id = customers.id
      ORDER BY invoices.date DESC
      LIMIT 5`;

    const latestInvoices = data.rows.map((invoice) => ({
      ...invoice,
      amount: formatCurrency(invoice.amount),
    }));
    return latestInvoices;
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch the latest invoices.');
  }
}

このfetchLatestInvoices関数を /app/dashboard/page.tsxで import して、

LatestInvoices  に渡すlatestInvoicesにデータを入れたのでコメントアウトを外しておきます

/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';
import { fetchRevenue, fetchLatestInvoices } from '@/app/lib/data';

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();

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

最後に /app/ui/dashboard/latest-invoices.tsx を開き、19,56 行目のコメントアウトを外します

再度ローカルサーバーを起動して、先ほどと同じ URL にアクセスしてみます

棒グラフの右側に、↓ のように5人の情報が表示されれば OK!

データを取得して Card コンポーネントに渡す

最後はCardコンポーネント。流れは今までと一緒。

ただ、必要なデータが 1 つではなく4 つになります。

具体的には、

  1. 支払われた請求の合計金額
  2. まだ支払われていない請求の合計金額
  3. 請求の総数
  4. 顧客の総数

例によって、/app/lib/data.tsにあるfetchCardData関数をつかいます。

総数を求めるCOUNTだったり、合計するSUM、フィルターするWHENなどを使ってます

/app/lib/data.ts
// 一部抜粋

export async function fetchCardData() {
  try {
    // You can probably combine these into a single SQL query
    // However, we are intentionally splitting them to demonstrate
    // how to initialize multiple queries in parallel with JS.
    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,
    ]);

    const numberOfInvoices = Number(data[0].rows[0].count ?? '0');
    const numberOfCustomers = Number(data[1].rows[0].count ?? '0');
    const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0');
    const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0');

    return {
      numberOfCustomers,
      numberOfInvoices,
      totalPaidInvoices,
      totalPendingInvoices,
    };
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch card data.');
  }
}

また、↑ の 21-24 行目で SQL の実行結果を使いやすいフォーマットに変えています。

formatCurrency関数は /app/lib/data.ts にあり、USD 表記の文字列に変換。

100 で割っているのは、DB に格納されているセントをドルに変換するためです。

/app/lib/data.ts
// 一部抜粋

export const formatCurrency = (amount: number) => {
  return (amount / 100).toLocaleString('en-US', {
    style: 'currency',
    currency: 'USD',
  });
};

このfetchLatestInvoices関数を /app/dashboard/page.tsx で import して、

Cardに渡すデータを分割代入で変数に入れたのでコメントアウトを外しておきます

/app/dashboard/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import { lusitana } from '@/app/ui/fonts';
import { fetchCardData, fetchLatestInvoices, fetchRevenue } from '@/app/lib/data';

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfCustomers,
    numberOfInvoices,
    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">
        <RevenueChart revenue={revenue} />
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

再度ローカルサーバーを起動して、先ほどと同じ URL にアクセスしてみます

棒グラフの上に、↓ のように 4 つの数字が表示されれば OK!

データを取得する際の注意点

  1. リクエストウォーターフォールによるパフォーマンスの低下
  2. 静的レンダリングによる最新情報の未反映(次章で詳細を見ていく)

リクエストウォーターフォールとは?

複数のリクエストを順番に処理していくこと。

前のリクエストが完了してから、次のリクエストを開始する。

今回のコードだと

  1. fetchRevenue()
  2. fetchLatestInvoices()
  3. fetchCardData()

の順にリクエストしていく。

// 一部抜粋

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfCustomers,
    numberOfInvoices,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();

初めにユーザ情報を取得しその情報を使って次のリクエストをするときなど リクエストする順番がある場合には有効

先ほどのコードで例えば各リクエストに 3 秒ずつかかるとすると、 3req × 3seq で計 9seq かかるため、パフォーマンス低下の可能性あり

複数のデータフェッチを並列で実行

リクエストウォーターフォールへの一般的な対応法はリクエストを並列に行うこと。

JavaScript では、**Promise.all() もしくは Promise.allSettled()**を使います。

  • Promise.all():1 つでも処理が失敗した瞬間に終了してしまう

  • Promise.allSettled():処理の成否にかかわらずすべて実行。

  • 同時にリクエストを処理できるのでパフォーマンスが向上する。

  • ライブラリやフレームワーク固有でなく、JavaScript の文法で書ける

処理が重いリクエストが 1 つでもあると、その処理時間に影響を受ける

今回のアプリケーションだと、/app/lib/data.ts の fetchCardData 関数で使われてます

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

この場合は、invoiceCountPromise、customerCountPromise、invoiceStatusPromise が同時に処理されます。

例えば

  • invoiceCountPromise ⇒ 1 秒かかる
  • customerCountPromise ⇒ 2 秒かかる
  • invoiceStatusPromise ⇒ 3 秒かかる

とすると、全体の処理としては 3 秒かかることになります。

次章のメモ

https://zenn.dev/kuuki/articles/nextjs-tutorial-08/

Discussion