これはNext.jsの公式チュートリアルの7. Fetching Data に関するメモです
前章のメモ
Next.jsの公式チュートリアルの該当ページ
学ぶこと
- API、ORM、SQLなどデータを取得する方法
- サーバーコンポーネントでバックエンドへ安全にアクセス
- リクエスト ウォーターフォールとは?
- 並列にデータを取得する方法
データを取得する方法
API
- サードパーティサービスのAPIを使う
- クライアントにデータベースの機密情報を公開したくない
などの場合にAPIを使用します。
Next.jsでは、Route Handlersを使用してAPIのエンドポイントを作成できる
データベースへのクエリ
- APIエンドポイントを作成するため
- React Server Componentsを使用してデータベースにアクセスするため
- データベースの機密情報漏洩のリスクはない
などの場合にデータベースへのクエリを使用します。
MySQLやPostgreSQLのようなRDBの場合は、PrismaなどのORMを使用してもいい。
Prismaを使ってみたい方は↓を参考にしてみてください!
React Server Componentsを使用したデータ取得
Next.jsではデフォルトでReact Server Componentsを使用します。
SQLを使うには
今回は、Vercel Postgres SDKを使用してDBにSQLを投げます。
接続先のDBは環境変数を参照(今回だと.envファイル)
/app/lib/data.ts のように、@vercel/postgresからsql関数 をインポートしてSQLを投げます。
投げるSQL文はテンプレートリテラルで記述するので変数の展開も可能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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`; ...... |
ダッシュボードのUIを作りこむ
DBからデータを取得する方法を学んだところでダッシュボードのUIを作っていきます
まずは↓のコードを /app/dashboard/page.tsx にコピペします
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
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> ); } |
データを取得してRevenueChartコンポーネントに渡す
/app/dashboard/page.tsx でRevenueChartコンポーネントに渡すrevenueを定義します。
これにはDBからとってきたrevenueのデータを入れます。
/app/lib/data.ts のfetchRevenue関数でデータを取りに行きます。
関数内のSQL文を見ると、たしかにrevenueテーブルをSELECTしてますね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# 一部抜粋 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にデータを入れたのでコメントアウトを外しておきます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
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 を開きコメントアウトを外していきます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
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> ); } |
準備完了なので、ローカルサーバを起動してブラウザからアクセスします
1 2 3 4 5 |
// ローカルサーバー起動 $ npm run dev // /app/dashboard/page.tsx を見たいので // http://localhost:3000/dashboard にアクセス! |
↓の感じで棒グラフが出現すればOK!
データを取得してLatestInvoicesコンポーネントに渡す
お次は、LatestInvoicesコンポーネントにデータを渡していきます。
先ほどと同じように/app/lib/data.ts でデータを取得するfetchLatestInvoices関数が定義。
こちらは、JOINやらORDER BY やら LIMIT使ってますねー
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// 一部抜粋 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にデータを入れたのでコメントアウトを外しておきます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
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つになります。
具体的には、
- 支払われた請求の合計金額
- まだ支払われていない請求の合計金額
- 請求の総数
- 顧客の総数
例によって、/app/lib/data.ts にあるfetchCardData関数をつかいます。
総数を求めるCOUNTだったり、合計するSUM、フィルターするWHENなどを使ってます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
// 一部抜粋 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に格納されているセントをドルに変換するためです。
1 2 3 4 5 6 7 8 |
// 一部抜粋 export const formatCurrency = (amount: number) => { return (amount / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD', }); }; |
このfetchLatestInvoices関数を/app/dashboard/page.tsx でimportして、
Card に渡すデータを分割代入で変数に入れたのでコメントアウトを外しておきます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
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!
リクエストウォーターフォールとは?
複数のリクエストを順番に処理していくこと。
前のリクエストが完了してから、次のリクエストを開始する。
今回のコードだと
- fetchRevenue()
- fetchLatestInvoices()
- fetchCardData()
の順にリクエストしていく。
1 2 3 4 5 6 7 8 9 10 11 |
// 一部抜粋 export default async function Page() { const revenue = await fetchRevenue(); const latestInvoices = await fetchLatestInvoices(); const { numberOfCustomers, numberOfInvoices, totalPaidInvoices, totalPendingInvoices, } = await fetchCardData(); |
複数のデータフェッチを並列で実行
リクエストウォーターフォールへの一般的な対応法はリクエストを並列に行うこと。
JavaScriptでは、Promise.all() もしくは Promise.allSettled() を使います。
- Promise.all():1つでも処理が失敗した瞬間に終了してしまう
- Promise.allSettled():処理の成否にかかわらずすべて実行。
今回のアプリケーションだと、/app/lib/data.ts の fetchCardData関数で使われてます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// 一部抜粋 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秒かかることになります。
次章のメモ
コメント