パラメータ付きのパスを扱う
それではいよいよ、記事の本文を表示したいところですが、ひとつ問題が残っています。App RouterではURLをフォルダパスで表現する都合上、/top20/xxxxxxのxxxxxxの部分に記事IDをつけようとすると、フォルダ名が定まらないのです。こういったケースのために、パスをパラメータとして扱う場合のフォルダ名の命名方法が用意されています。パラメータ名を[]で囲んだ文字列をフォルダ名として利用するのです。
今回はidというパラメータ名にしたいので、記事の本文を表すフォルダとファイルはapp/top20/[id]/page.jsという名前にしましょう。まずは、簡単に記事のタイトルを表示してみます(リスト3)。
import "../_style/article.css";
import { getItem } from "../../_utils/hackerNews";
export default async function Top20IdPage({ params }) {
// (1) paramsからURLのパラメータを取り出す
const { id } = params;
// (2) 記事データを取得する
const item = await getItem(id);
return (
<article>
<h1>{item.title}</h1>{/* (3) */}
</article>
);
}
まずは、URLに記載されたIDの記事データをフェッチしてきましょう。app/top20/[id]/page.jsというパスで定義されたページでは、paramsというpropsが得られます。これを使って、(1)のようにURLに記載された記事IDであるidパラメータを取り出すことができます。もし[data_id]/page.jsというパスだったら、params.data_idを参照します。
記事IDが手に入ったら、次は(2)でfetch()関数を利用した自作関数を使って、記事データを取得します。これでデータが用意できました。このitemは記事タイトルを持っているので、(3)で画面に記事のタイトルを表示しています。
ここで一度、画面を表示してみましょう。http://localhost:3000/top20を開き、メニューから記事をひとつクリックしてみてください(図4)。
タイトルが表示されました。メニューの他の項目をクリックすると、URLとタイトルが切り替わるはずです。続いて、投稿者とURLのデータも表示してみましょう。データはすでにitemに入っているので、リスト4のようにUIで表示するよう変更します。
<article>
<h1>{item.title}</h1>
{/* 追加:ここから */}
<p>by {item.by} on {new Date(item.time * 1000).toLocaleString()}</p>
<p><a href={item.url}>{item.url}</a></p>
{/* 追加:ここまで */}
</article>
itemの中身を並べてみました。Hacker Newsではitem.timeで提供されるUNIX秒が秒単位なので、Dateに読み込ませるために1000倍してミリ秒単位にした点で工夫があるものの、データを素直にUIへ表示しただけの変更です。こちらも表示してみましょう(図5)。
問題なくデータが表示されています。これで、URLのパスからパラメータを受け取ってデータを表示することができました。
記事に付随するデータを読み込む
さて、Hacker Newsには、投稿されたURLの内容についてコメント欄で議論するプラットフォームとしての側面があるので、今回のサンプルでもコメント欄を表示してみましょう。
少し厄介な点として、Hacker News APIの記事データにはコメントのデータそのものは含まれていません。item.kidsというプロパティに、コメントデータのIDの配列が含まれているだけなので、コメントの本文を表示するには、コメントデータを取得してくる必要があります。
もしコメントが100件あって、その全てを表示したいなら、100回分の通信を行う必要があるのです。ブラウザで100件分の通信を行ってコメントを表示する場合、通信が完了したものから順にコメントを表示していくような状態管理が必要になります。ユーザーの通信環境次第では、現実的な速度で表示できるかどうか怪しいところです。
しかし、その100件分の通信をサーバー側でServer Componentsをレンダリングしている間に済ませられるとしたらどうでしょうか。一瞬で終わるとまではいかないかもしれませんが、ユーザーの手元の通信環境を酷使するのと比べれば、格段に早く100件のデータを揃えられそうです。コメントデータの取得を行えるよう、リスト5のようにTop20IdPageのデータ取得部分を改修します。
// (省略)
export default async function Top20IdRoute({ params }) {
// paramsからURLのパラメータを取り出す
const { id } = params;
// 記事データを取得する
const item = await getItem(id);
// (1) 記事データ内に含まれるコメントIDを1件ずつ取得する
const kids = await Promise.all(
item.kids.map((kidsItem) => getItem(kidsItem))
);
return (
// 省略
(1)でitem.kidsに含まれるコメントIDを元にコメントデータを引き出しています。Hacker News APIは少し特殊なデータ構造をしていて、コメントデータが記事データと全く同じAPIから取得できるため、getItem()関数を再利用しています。取得したコメントデータは、kids変数にセットして、JSX側で使えるようにしておきました。
次は、JSX側の改修です。kids変数からコメントデータが取り出せるようになったので、UIに反映します(リスト6)。
// (省略)
<article>
<h1>{item.title}</h1>
<p>by {item.by} on {new Date(item.time * 1000).toLocaleString()}</p>
<p><a href={item.url}>{item.url}</a></p>
{/* (1) */}
{/* 追加:ここから */}
<h2>Comments</h2>
{kids.map((kidsItem) => (
<div key={kidsItem.id}>
<h3>by: {kidsItem.by}</h3>
<p>{kidsItem.text}</p>
<p>{new Date(kidsItem.time * 1000).toLocaleString()}</p>
<hr />
</div>
))}
{/* 追加:ここまで */}
</article>
(1)の部分を追記しました。kids内のデータ構造は本文とほとんど変わらないので、似たような実装になりましたね。さて、これで完成です。http://localhost:3000/top20を開いた後、サイドメニューのリンクをひとつクリックしてみましょう(図6)。
コメントデータを含む各コンテンツがスムーズに画面に表示されました。最終的に、app/top20/[id]/page.jsはリスト7のようになっています。
import "../_style/article.css";
import { getItem } from "../../_utils/hackerNews";
export default async function Top20IdRoute({ params }) {
// paramsからURLのパラメータを取り出す
const { id } = params;
// 記事データを取得する
const item = await getItem(id);
// 記事データ内に含まれるコメントIDを1件ずつ取得する
const kids = await Promise.all(
item.kids.map((kidsItem) => getItem(kidsItem))
);
return (
<article>
<h1>{item.title}</h1>
<p>
by {item.by} on {new Date(item.time * 1000).toLocaleString()}
</p>
<p>{item.text}</p>
<p>
<a href={item.url}>{item.url}</a>
</p>
<h2>Comments</h2>
{kids.map((kidsItem) => (
<div key={kidsItem.id}>
<h3>by: {kidsItem.by}</h3>
<p>{kidsItem.text}</p>
<p>{new Date(kidsItem.time * 1000).toLocaleString()}</p>
<hr />
</div>
))}
</article>
);
}
メニューを表示しているapp/top20/layout.jsと、コンテンツを表示しているapp/top20/[id]/page.jsは、似たようなWeb APIにアクセスしていますが、UIとしてはタイトルを出したいだけの部品と、コメントまで表示したい部品の違いがあり、異なる関心を持っています。今回ご紹介したNested Routesを活用することで、それぞれの関心に沿った形で、それぞれのServer Componentにデータ加工のロジックを記述できます。データ取得をスッキリと書ける上に、ブラウザに渡すデータを最小限にできるので、ぜひ使ってみたい機能です。
Nested Routes内の画面遷移時にはSSRは行わない
Next.jsは、サーバーサイドでHTMLの生成を行うフレームワークとしての印象が強くなりがちですが、実はNested Routesを使っている画面では、初期表示時以外にはHTMLの生成を行わないケースがあります。
今回の例でいえば、app/top20/のフォルダ内にあるパス同士で画面遷移を行った場合、app/top20/layout.jsという「外側のコンポーネント」を共有しているため、画面遷移の前後で表示が変わらない部分が必ずあります。
こういったケースでは、HTML全体を作り直すよりも、クライアント側で動的にchildren内だけを画面遷移できるように非同期処理を行なって、Server Componentの処理結果であるReactツリーデータだけをフェッチしてきたほうが、ネットワークの通信量を節約できます。
同じ画面遷移でも、通信内容がHTMLのときとReactツリーのときがあることで、ユーザーの通信量を可能な限り削減しようとするApp Routerのポリシーが垣間見えますね。
まとめ
App Routerで直感的に扱えるようになったNested Routesを利用して、簡単なアプリケーションを作成してみました。煩雑な通信をサーバー側で終わらせるためのServer Componentや、似たような画面同士の遷移であれば画面の一部分だけの通信と画面切り替えで済ませようとするNested Layoutのような仕組みをみると、Next.jsができるだけブラウザの通信回数を減らそうとしている様子が見てとれたと思います。
次回は通信に関するAPIを深掘りしていきます。お楽しみに。
