パラメータ付きのパスを扱う
それではいよいよ、記事の本文を表示したいところですが、一つ問題が残っています。RemixではURLをファイルパスで表現する都合上、 /top20/xxxxxx の xxxxxx の部分に記事IDをつけようとすると、ファイル名が定まらないのです。
こういったケースのために、パスをパラメータとして扱う場合のファイル名の命名方法が用意されています。ファイル名の先頭にドルマーク $ をつけて、その後ろにパラメータ名を付与するのです。
今回は id というパラメータ名にしたいので、記事の本文を表すファイル名は app/routes/top20/$id.jsx という名前にしましょう。まずは、簡単に記事のタイトルを表示してみます(リスト3)。
import { useLoaderData } from '@remix-run/react';
import { json } from '@remix-run/node';
import { getItem } from '~/utils/hackerNews.server';
import stylesUrl from '~/style/article.css';
export const links = () => {
return [{ rel: "stylesheet", href: stylesUrl }];
};
// (1) routes内のコンポーネントファイルなのでloaderが持てる
export const loader = async ({ params }) => {
// (2) paramsからURLのパラメータを取り出す
const { id } = params;
// (3) 記事データを取得する
const item = await getItem(id);
// (4) useLoaderData() で取り出すためのデータを application/json としてクライアントに送る
return json({
item,
});
};
export default function Top20IdRoute() {
// (5) loaderの戻り値がそのまま渡される
const loaderData = useLoaderData();
const item = loaderData.item;
return (
<article>
<h1>{item.title}</h1>{/* (6) */}
</article>
);
}
まず、本文として記事データをフェッチしてくる方法ですが、 app/routes 内のコンポーネントファイルは、すべて loader() を持つことができるので、 $id.jsx にも(1)のように loader() を設置して、データフェッチに利用できます。ファイル名にパラメータを定義した場合は、 loader() の引数から(2)のように取り出すことができます。今回は $id.jsx というファイル名なので、URLに記載された記事IDを見つけるには params.id を参照しました。もし $data_id.jsx というファイル名だったら、 params.data_id を参照します。
記事IDが手に入ったら、次は(3)で fetch() 関数を利用した自作関数を使って、記事データを取得します。この記事データを画面に表示したいので、(4)で loader() 関数の戻り値として記事データを返します。 json() 関数は、 loader() 関数の戻り値がHTTPレスポンスとしてブラウザに送られる際に Content-Type ヘッダーを application/json にするための便利関数です。
loader() の実行後、 Top20IdRoute コンポーネントのレンダリングが始まります。loader() が単純なオブジェクトを返している場合、(5)の useLoaderData() では loader() の戻り値と全く同じデータを取り出すことができます。言い換えると、 loaderData 変数のオブジェクトは、 item というただ一つのプロパティを持っており、他のデータは含まれていません。この item は記事タイトルを持っているので、(6)で画面に記事のタイトルを表示しています。
ここで一度、画面を表示してみましょう。 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のパスからパラメータを受け取ってデータを表示することができました。
記事に付随するデータをloaderで読み込む
さて、Hacker Newsには、投稿されたURLの内容についてコメント欄で議論するプラットフォームとしての側面がありますので、今回のサンプルでもコメント欄を表示してみましょう。
少し厄介な点として、Hacker News APIの記事データにはコメントのデータそのものは含まれていません。 item.kids というプロパティに、コメントデータのIDの配列が含まれているだけなので、コメントの本文を表示するには、コメントデータを取得してくる必要があります。
もしコメントが100件あって、その全てを表示したいなら、100回分の通信を行う必要があるのです。ブラウザで100件分の通信を行ってコメントを表示する場合、通信が完了したものから順にコメントを表示していくような状態管理が必要になります。ユーザーの通信環境次第では、これは現実的な速度で表示できるかどうか、怪しいところです。
しかし、その100件分の通信を、サーバー側の loader() で済ませられるとしたらどうでしょうか。一瞬で終わる、とまではいかないかもしれませんが、ユーザーの手元の通信環境を酷使するのと比べれば、格段に早く100件のデータを揃えられそうです。 loader() でコメントデータの取得を行えるよう、リスト5のように loader() を改修します。
export const loader = async ({ params }) => {
// paramsからURLのパラメータを取り出す
const { id } = params;
// 記事データを取得する
const item = await getItem(id);
// (1) kids(コメント欄)にはID配列しか入っていないので
// コメントデータを取得する(コメントデータは記事と同じAPIで取れる)
const kidsItems = await Promise.all(
item.kids.map((kidsItemId) => getItem(kidsItemId))
);
return json({
item, // 記事データ
kids: kidsItems, // (2) コメントデータ
});
};
(1)で item.kids に含まれるコメントIDを元にコメントデータを引き出しています。Hacker News APIは少し特殊なデータ構造をしていて、コメントデータが記事データと全く同じAPIから取得できるため、 getItem() 関数を再利用しています。取得したコメントデータは、(2)で記事データと同じ階層にセットして、コンポーネント側で使えるようにしました。
次は、コンポーネント側の改修です。 loader() から返された item と kids を useLoaderData() から取り出せるようになったので、UIに反映します(リスト6)。
export default function Top20IdRoute() {
// (1)記事データとコメントデータを取り出す
const loaderData = useLoaderData();
const item = loaderData.item;
const kids = loaderData.kids;
return (
<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>
{/* 追加:ここから */}
<h2>Comments</h2>
{kids.map((kidsItem) => ( // (2)
<div key={kidsItem.id}>
<h3>by: {kidsItem.by}</h3>
<p>{kidsItem.text}</p>
<p>{new Date(kidsItem.time * 1000).toLocaleString()}</p>
<hr />
</div>
))}
{/* 追加:ここまで */}
</article>
);
}
loader() で用意したデータを(1)で取り出し、(2)で画面に表示しています。
さて、これで完成です。 http://localhost:3000/top20 を開いた後、サイドメニューのリンクを一つクリックしてみましょう(図6)。
$id.jsx の loader() で取得したデータが画面に表示されました。
最終的に、 $id.jsx はリスト7のようになっています。
import { useLoaderData } from '@remix-run/react';
import { getItem } from '~/utils/hackerNews.server';
import stylesUrl from '~/style/article.css';
import { json } from '@remix-run/node';
export const links = () => {
return [{ rel: 'stylesheet', href: stylesUrl }];
};
export const loader = async ({ params }) => {
const { id } = params;
const item = await getItem(id);
const kidsItems = await Promise.all(
item.kids.map((kidsItem) => getItem(kidsItem))
);
return json({
item,
kids: kidsItems,
});
};
export default function Top20IdRoute() {
const loaderData = useLoaderData();
const item = loaderData.item;
const kids = loaderData.kids;
return (
<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>
<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/routes/top20.jsx と、コンテンツを表示している app/routes/top20/$id.jsx は、似たようなWeb APIにアクセスしていますが、UIとしてはタイトルを出したいだけの部品とコメントまで表示したい部品という違いがあり、異なる関心を持っています。今回ご紹介したNested Routesを活用することで、それぞれの関心に沿った形で、それぞれの loader() にデータ加工のロジックを記述できます。データ取得をスッキリと書ける上に、ブラウザに渡すデータを最小限にできるので、ぜひ使ってみたい機能です。
