SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

DeveloperZine(デベロッパージン)- エンジニアの意思決定を支える技術情報メディア ProductZine

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

Remixを通じてWebを学ぶ

Remixを触る上できちんと知っておきたい機能「Nested Routes」を解説

Remixを通じてWebを学ぶ 第3回

パラメータ付きのパスを扱う

 それではいよいよ、記事の本文を表示したいところですが、一つ問題が残っています。RemixではURLをファイルパスで表現する都合上、 /top20/xxxxxxxxxxxx の部分に記事IDをつけようとすると、ファイル名が定まらないのです。

 こういったケースのために、パスをパラメータとして扱う場合のファイル名の命名方法が用意されています。ファイル名の先頭にドルマーク $ をつけて、その後ろにパラメータ名を付与するのです。

 今回は id というパラメータ名にしたいので、記事の本文を表すファイル名は app/routes/top20/$id.jsx という名前にしましょう。まずは、簡単に記事のタイトルを表示してみます(リスト3)。

[リスト3]app/routes/top20/$id.jsx
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)。

図4:記事のタイトルが表示される
図4:記事のタイトルが表示される

 タイトルが表示されました。メニューの他の項目をクリックすると、URLとタイトルが切り替わるはずです。

 続いて、投稿者とURLのデータも表示してみましょう。データはすでに item に入っているので、リスト4のようにUIで表示するよう変更します。

[リスト4]app/routes/top20/$id.jsx
<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)。

図5:タイトル以外の情報を表示した
図5:タイトル以外の情報を表示した

 問題なくデータが表示されています。

 これで、URLのパスからパラメータを受け取ってデータを表示することができました。

記事に付随するデータをloaderで読み込む

 さて、Hacker Newsには、投稿されたURLの内容についてコメント欄で議論するプラットフォームとしての側面がありますので、今回のサンプルでもコメント欄を表示してみましょう。

 少し厄介な点として、Hacker News APIの記事データにはコメントのデータそのものは含まれていません。 item.kids というプロパティに、コメントデータのIDの配列が含まれているだけなので、コメントの本文を表示するには、コメントデータを取得してくる必要があります。

 もしコメントが100件あって、その全てを表示したいなら、100回分の通信を行う必要があるのです。ブラウザで100件分の通信を行ってコメントを表示する場合、通信が完了したものから順にコメントを表示していくような状態管理が必要になります。ユーザーの通信環境次第では、これは現実的な速度で表示できるかどうか、怪しいところです。

 しかし、その100件分の通信を、サーバー側の loader() で済ませられるとしたらどうでしょうか。一瞬で終わる、とまではいかないかもしれませんが、ユーザーの手元の通信環境を酷使するのと比べれば、格段に早く100件のデータを揃えられそうです。 loader() でコメントデータの取得を行えるよう、リスト5のように loader() を改修します。

[リスト5]app/routes/top20/$id.jsx
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() から返された itemkidsuseLoaderData() から取り出せるようになったので、UIに反映します(リスト6)。

[リスト6]app/routes/top20/$id.jsx
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)。

図6:コメントを表示できた
図6:コメントを表示できた

 $id.jsxloader() で取得したデータが画面に表示されました。

 最終的に、 $id.jsx はリスト7のようになっています。

[リスト7]app/routes/top20/$id.jsx
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() にデータ加工のロジックを記述できます。データ取得をスッキリと書ける上に、ブラウザに渡すデータを最小限にできるので、ぜひ使ってみたい機能です。

次のページ
Nested Routes内の画面遷移時にはSSRは行わない

この記事は参考になりましたか?

Remixを通じてWebを学ぶ連載記事一覧

もっと読む

この記事の著者

WINGSプロジェクト 中川 幸哉(ナカガワ ユキヤ)

WINGSプロジェクトについて>有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティ(代表 山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手がける。 2026年時点での登録メンバは約50名で、現在も執筆メンバを募集中。興味のある方は、どしどし応募頂きたい。著書記事多数。 RSS X: @WingsPro_info(公式)、@WingsPro_info/wings(メンバーリスト) Facebook

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

山田 祥寛(ヤマダ ヨシヒロ)

静岡県榛原町生まれ。一橋大学経済学部卒業後、NECにてシステム企画業務に携わるが、2003年4月に念願かなってフリーライターに転身。Microsoft MVP for Visual Studio and Development Technologies。執筆コミュニティ「WINGSプロジェクト」代表。主な著書に「独習シリーズ(Java・C#・Python・PHP・Ruby・JSP&サーブレットなど)」「速習シリーズ(ASP.NET Core・Vue.js・React・TypeScript・ECMAScript、Laravelなど)」「改訂3版JavaScript本格入門」「これからはじめるLaravel実践入門」「はじめてのAndroidアプリ開発 Kotlin編 」他、著書多数

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

CodeZine(コードジン)
https://codezine.jp/article/detail/17289 2023/02/27 11:00

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー