207
182

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【図解解説/初心者OK】Next.js不要?進化したReact Routerで技術記事アプリを作るチュートリアル【TypeScript/TailwindCSS】

Last updated at Posted at 2025-01-05

React Router v7とは? (2).png

はじめに

みなさんこんにちは、Watanabe Jin(@Sicut_study)です。
Reactが大好きな私にとってReactの歴史が大きく変わる瞬間に出くわしました。

 
image.png

 
ついにReact 19が安定版としてリリースされたのです。
React19になってSSRなどサーバーコンポーネントが完全にサポートされます。

そんな中で真っ先に動いたのがRemixです。
Reactでサーバーでの処理ができるとなると「React + React Router」と「React + Remix + React Router」で実現できることの差がなくなりました。

そこでReact RouterとRemixが統合されて新たなフレームワークが生まれます。
それが「React Router v7」なのです!!!
 

image.png

React Router v7はいままで通りライブラリとして「ReactRouter」を利用することもできますし、Remixを含むフルスタックフレームワークとしても利用ができます。

そんな話題のフルスタックフレームワークとなったReact Router v7を使って技術記事アプリを作っていきます。
 

React19 + React Routerでよくない??
 

そんな声も聞こえてきそうですが、React Routerを使うメリットはRemixのメリットを享受できることです。今回はRemixの機能を中心にReact Routerを使ってアプリを開発してきます。

動画教材もご用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材ではわからない細かい箇所があれば動画も活用ください。

対象者

  • Reactを始めてみたい人
  • 新しいReact Routerを知りたい人
  • Remixの思想を体験したい人
  • SSRがよくわからない人
  • Next.jsと比較をしたい人
  • JavaScriptからステップアップしたい人

本ハンズオンはHTMLと基本的なJavaScriptがわかれば2時間程度で、最後まで行うことができます。

1. React Routerの進化

image.png

新しくなったReactRouterv7は「ReactRouterv6」に「Remix」を統合して誕生しました。(Remixの90%以上は同じコードを利用しているようで将来予定していたことなのかなと思います)

ReactRouterv7は今までどおり「ルーティングライブラリ」としても利用することもできます。

$ npm i react-router

そしてフレームワークとしても使うこともできます。

$ npx create-react-router@latest my-react-router-app

ReactRouterv7のフレームワークは以下の点で進化を遂げました。

  • コンフィグベースのルーティング
  • 型安全の向上
  • CSR/SSR/SSGのレンダリング
  • データローディング

など(このあとのハンズオンで実際に行います)
 

ここでCSRやSSRが理解できない人もいるかと思いますので解説していきます。
まずはクライアントサイドレンダリング(CSR)から見ていきましょう。
 

image.png
 

クライアントサイドレンダリングはReactのコードをクライアント(あなたのブラウザ)で処理してHTMLのファイルを生成するレンダリング方式のことを指します。
Reactはフレームワークを利用しない場合は基本クライアントサイドでレンダリングが行われていました。

 
image.png
 

サーバーサイドレンダリング(SSR)は、Reactをサーバー側で処理をしてHTMLを生成して完成したものをクライアントに返して表示するレンダリング方法になります。
Next.jsを利用するのはこのサーバー側での処理を行えるというのが大きいです。

例えば1つの画面の中でも「記事一覧」の部分は記事取得のためにAPIを叩く必要があるので、その部分のコンポーネントだけをサーバーコンポーネントとしてサーバー側で処理することも可能です。(クライアントで処理されるコンポーネントはクライアントコンポーネントといいます)

巷ではNext.jsが一強ですが、それはSSRができることが大きいです。しかし、Remixの登場でNext.jsを使う必要ないよね?という人が現れました。
 

image.png

レンダリング方式のもう一つがSSG(Static Site Generation)です。
SSRに似ているのですが、ビルド時に1度だけしかHTMLを生成しないという特徴があります。アクセスするたびにサーバー側でAPIを叩く必要がなくなるので、素早くHTMLを返すことができます。(必要なタイミングでSSRをするISRがありますがこれはまだ対応してなさそう)
 

image.png
 

メリットデメリットはそれぞれありますが、SSRができるようにReactにフレームワークを追加しておくことは大切なのです。

それぞれのユースケースを簡単に紹介すると
CSR : ダッシュボード系アプリ、オンラインエディタなどインタラクティブなもの
SSR : SNSプラットフォーム、ニュースサイト
SSG : 公式ドキュメント、会社のホームページなど更新が少ないもの

初心者の中にはReactのみのクライアント側だけでアプリを作っている人がいる思いますが、クライアントだけでやることには以下のような問題が起きることもあります。
 

image.png

クライアント側だけでアプリを作る場合、例えばChatGPTを叩くときに使うシークレットキーを使っていたとしたらブラウザから簡単に見ることができてしまいます。これを使われてしまうと莫大な請求につながるかもしれません。
 

image.png

Next.jsやReactでなくRemixを採用することにはこのようなメリットがあります。

1. Web標準である

Web標準に従っているためキャッチアップのコストが少なく、学んだ知識が時代によって使えなくなるリスクが少ないです。フロントエンドはセキュリティ面でもサーバーサイドで処理をすることは大切です。移り変わりが激しいからこそ長期的に考えてRemix(ReactRouter)を選択することが今後は多くなると考えています。

2. 状態管理不要

RemixはReactで使われるuseStateなどのクライアントステートを最小限に抑えられるメリットがあります。

image.png

例えばTODOのタイトルを更新したとします。Remixでは更新をしたらサーバー側で更新処理を行います。更新処理ではDBにある該当TODOのタイトルを実際に更新しています。その後、最新データの取得をRemixは行います。そしてサーバー側で最新状態のHTMLをレンダリングしてクライアントに返し画面を反映させます。

このようにRemixを利用することでクライアント側とサーバー側の両方でデータ管理をする必要がないです。故にデータがずれたりするバグもなくすことが可能です。

3. React Routerとの統合

Remixの欠点として挙げられていたのが「型が弱い」「ルーティングが大変」ということでした。しかし、ReactRouterとの統合によって大幅に改善しています。
 
 

Next.jsとRemixを比較すると、それぞれに異なる特徴があり、プロジェクトの要件に応じて選択を検討する必要があります。

Next.jsは多機能で強力なフレームワークですが、それゆえに以下のような課題があります。

設定の複雑:App RouterやPage Router、様々なレンダリング戦略(SSG、SSR、ISR)など、多くの選択肢と設定項目があり、適切な選択と設定に時間がかかることがある

バンドルサイズの増大:組み込まれている多くの機能により、必要としない機能もバンドルに含まれる可能性があり、初期ロード時間に影響を与えることがある

学習コストが高い:チーム全体が習得するまでに時間がかかる場合がある

実際に、一休.comがNext.jsからRemixに移行した事例では、パフォーマンスも向上したという報告があります

フレームワークの選択は、プロジェクトの具体的な要件や開発チームの特性を考慮して行うことが重要です。

私はシンプルなRemixがとても気に入っているのもあり、ReactRouterが今後は徐々に選択されていくのではないかと考えております!

2. ルーティングの基本

まずはReactRouterv7で導入されたコンフィグベースのルーティングについて紹介していきます。これは設定ファイルでルーティングを一括管理できる方式です。

👇以前のようなルーティングも利用できますが、ここでは新しいものを使っていきます。

import { Routes, Route } from "react-router";

function Wizard() {
  return (
    <div>
      <h1>Some Wizard with Steps</h1>
      <Routes>
        <Route index element={<StepOne />} />
        <Route path="step-2" element={<StepTwo />} />
        <Route path="step-3" element={<StepThree />}>
      </Routes>
    </div>
  );
}

2-1. 環境構築

React Routerの環境構築からしていきましょう。
ここでの注意点はライブラリとしてのReactRouter(ルーティングのみ)を使うか、フレームワークとしてのReactRouter(Remixあり)を使うかで方法が変わってきます。

今回はフレームワークとして利用するので以下のコマンドを実行して下さい。

$ npx create-react-router@latest router-article-app
# すべてYesを選択

         create-react-router v7.0.2
      ◼  Directory: Using router-article-app as project directory

      ◼  Using default template See https://github.com/remix-run/react-router-templates for more
      ✔  Template copied

   git   Initialize a new git repository?
         Yes

  deps   Install dependencies with npm?
         Yes


      ✔  Dependencies installed

      ✔  Git initialized

  done   That's it!

         Enter your project directory using cd ./router-article-app
         Check out README.md for development and deploy instructions.

         Join the community at https://rmx.as/discord

環境ができたら起動してきます。

$ cd router-article-app
$ npm run dev

localhost:5173にアクセスして以下の画面がでれば成功です。

image.png

それではVSCodeでrouter-article-appを開きましょう

image.png

ディレクトリをみるとtailwind.config.tsがあることわかります。
TailwindCSSもデフォルトで使える状態になっています。

2-2. ルーティングを設定する

先にコンフィグベースのルーティングで出てくる主要な概念について説明します。

  • route:特定のURLと表示するコンポーネントを紐付けます
  • layout:複数のページで共通して使用するレイアウト(ヘッダーやサイドバーなど)を定義します
  • prefix:URLの前に共通の文字列をつけることができます(例:/admin/... のような管理者用ページ)
  • Outlet:layoutで定義した共通部分の中に、各ページの内容を表示する場所を指定します

それでは実際にルーティングを設定してみましょう
app/routesにルーティングの設定が書かれています。

app/routes.ts
import { type RouteConfig, index } from "@react-router/dev/routes";

export default [index("routes/home.tsx")] satisfies RouteConfig;

index("route/home.tsx")とあります。これはlocalhost:5173を開くとroutes/home.tsxが表示されることを表しています。

ためしにhome.tsxを以下に修正してlocalhost:5173にアクセスしてみましょう。(npm run devでサーバーを起動しましょう)

app/routes/home.tsx
export default function Home() {
  return (
    <div>
      <div className="flex-1 sm:ml-64">
        <h1>記事一覧</h1>
      </div>
    </div>
  );
}

image.png

問題なくルーティングされています。
indexに書くことで/に紐付けることができます。CSSはこのあとサイドメニューを入れる関係で事前に当てています。

では次に以下のようにroutes.tsを修正してください。

app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("popular", "routes/popular.tsx"),
] satisfies RouteConfig;

次にapp/routes/popular.tsxを作成します。

$ touch app/routes/popular.tsx
app/routes/popular.tsx
export default function Popular() {
  return (
    <div>
      <div className="flex-1 sm:ml-64">
        <h1>人気記事</h1>
      </div>
    </div>
  );
}

それではlocalhost:5173/popularにアクセスします。

image.png

route("popular", "routes/popular.tsx")とすることで/popularpopular.tsxを紐付けました。

続いて以下のようなルーティングを追加します。

app/routes.ts
import {
  type RouteConfig,
  index,
  layout,
  route,
} from "@react-router/dev/routes";

export default [
  layout("layouts/sidemenu.tsx", [
    index("routes/home.tsx"),
    route("popular", "routes/popular.tsx"),
    route("search", "routes/search.tsx"),
  ]),
] satisfies RouteConfig;

必要なコンポーネントを作成しましょう

$ touch app/routes/search.tsx
$ mkdir app/layouts
$ touch app/layouts/sidemenu.tsx
app/routes/search.tsx
export default function Search() {
  return (
    <div>
      <div className="flex-1 sm:ml-64">
        <h1>記事検索</h1>
      </div>
    </div>
  );
}

/app/layouts/sidemenu.tsx
import { Link, Outlet } from "react-router";

export default function Sidemenu() {
  const menuItems = [
    { name: "記事一覧", path: "/" },
    { name: "人気記事", path: "/popular" },
    { name: "検索", path: "/search" },
  ];

  return (
    <div>
      <aside
        className={`fixed left-0 top-0 z-40 h-screen w-64 bg-white shadow-lg`}
      >
        <div>
          <div>
            <nav>
              {menuItems.map((item) => (
                <Link
                  key={item.name}
                  to={item.path}
                  className="flex items-center px-4 py-3 text-gray-700 hover:bg-gray-100"
                >
                  {item.name}
                </Link>
              ))}
            </nav>
          </div>
        </div>
      </aside>
      <Outlet />
    </div>
  );
}

画面を開いて/searchを開くと新しいページができています。

image.png

 
左のメニューをクリックすると画面が切り替わります。

 return (
    <div>
      <aside
        className={`fixed left-0 top-0 z-40 h-screen w-64 bg-white shadow-lg`}
      >
        (省略)
      </aside>
      <Outlet />
    </div>
  );

ここではOutletを利用しています。この部分にルーティングされたコンポーネントが埋め込まれます。

  layout("layouts/sidemenu.tsx", [
    index("routes/home.tsx"),
    route("popular", "routes/popular.tsx"),
    route("search", "routes/search.tsx"),
  ]),

先程のルーティングはこのような設定になっており、文字通りレイアウトを利用することができます。
sidemenu.tsx<Outlet />の部分に埋め込まれます。

例えば/searchであればの部分にroutes/search.tsxが埋め込まれています。

最後にPrefixを使ってみます。

app/routes.ts
import {
  type RouteConfig,
  index,
  layout,
  prefix,
  route,
} from "@react-router/dev/routes";

export default [
  layout("layouts/sidemenu.tsx", [
    index("routes/home.tsx"),
    route("popular", "routes/popular.tsx"),
    route("search", "routes/search.tsx"),
  ]),
  ...prefix("v1", [...prefix("systems", [route("ping", "routes/ping.tsx")])]),
] satisfies RouteConfig;
$ touch app/routes/ping.tsx
app/routes/ping.tsx
export default function Ping() {
  return (
    <div>
      <h1>Ping</h1>
    </div>
  );
}

そしてlocalhost:5173/v1/systems/pingを開くと以下になります。

image.png

...prefix("v1", [...prefix("systems", [route("ping", "routes/ping.tsx")])])

...prefixを書くことでパスをネストすることができます。
今回はv1/systemsとネストしてから先程説明したrouteを使いました。

ここまででReact Routerのルーティング機能については理解できたので、次はRemixが持っていた機能を使っていきましょう

3. 技術記事アプリを作る

今回はこのようなアプリを作成していきます。

Videotogif (3).gif

記事一覧 : SSRで実装
人気記事一覧 : SSGで実装
記事検索 : CSRで実装

ページを作りながらそれぞれの特徴を活かしてレンダリングを学んでいきます。

3-1. 記事一覧ページの実装

記事一覧ページはSSRで実装していきます。
今回はQiitaのAPIを活用していきます。Qiita APIは認証情報を利用するためクライアント側で処理すると認証情報が公開されてしまうと悪用のリスクがあります。

必要なファイルを作成します。

$ mkdir app/domain
$ touch app/domain/Article.ts
Atricle.ts
export class Article {
  constructor(
    public title: string,
    public url: string,
    public like_count: number,
    public stocks_count: number,
    public published_at: string
  ) {}
}

type Tag = {
  name: string;
  versions: string[];
};

type User = {
  description: string;
  facebook_id: string;
  followees_count: number;
  followers_count: number;
  github_login_name: string;
  id: string;
  items_count: number;
  linkedin_id: string;
  location: string;
  name: string;
  organization: string;
  permanent_id: number;
  profile_image_url: string;
  team_only: boolean;
  twitter_screen_name: string;
  website_url: string;
};

type Group = {
  created_at: string;
  description: string;
  name: string;
  private: boolean;
  updated_at: string;
  url_name: string;
};

type TeamMembership = {
  name: string;
};

export type ArticleJson = {
  rendered_body: string;
  body: string;
  coediting: boolean;
  comments_count: number;
  created_at: string;
  group: Group;
  id: string;
  likes_count: number;
  private: boolean;
  reactions_count: number;
  stocks_count: number;
  tags: Tag[];
  title: string;
  updated_at: string;
  url: string;
  user: User;
  page_views_count: number;
  team_membership: TeamMembership;
  organization_url_name: string;
  slide: boolean;
};

まずはTypeScriptを書きやすくするためにドメインを用意しました。
このように型を定義しておくことでVSCodeで強力な補完が利用できたり、存在しない項目を使おうとするとエラーになったりと間違いを防ぐことができます。

typeArticleJson, TeamMembership, Group, User, Tagは今回利用するAPIから返却されるJSONの形を表現したものです。

Articleは実際に今回利用するドメインでページ表示に必要な項目だけを設定しています。

export class Article {
  constructor(
    public title: string,
    public url: string,
    public like_count: number,
    public stocks_count: number,
    public published_at: string
  ) {}
}

次にQiitaからAPI利用するための認証情報を取得しましょう!
Qiitaを開いて「設定」→「アプリケーション」から「個人用アクセストークン」の「新しくトークンを発行する」をクリックします。
 

image.png

 
「アクセストークンの説明」にreact-router-appと入力して「発行する」をクリック

 
image.png

アクセストークンが表示されるのでメモしておきましょう。
 

image.png

 
それでは実際に記事一覧ページを作成します。
まずはCSRとSSRの違いを体感するためにCSRで実装をしてみます。

app/routes/home.tsx
import type { Route } from "./+types/home";
import { Article, type ArticleJson } from "~/domain/Article";

export async function clientLoader({ params }: Route.ClientLoaderArgs) {
  const res = await fetch(`https://qiita.com/api/v2/authenticated_user/items`, {
    headers: {
      Authorization: `Bearer [あなたのアクセストークンを入れる]`,
    },
  });
  const articlesJson: ArticleJson[] = await res.json();
  const articles = articlesJson.map(
    (articleJson) =>
      new Article(
        articleJson.title,
        articleJson.url,
        articleJson.likes_count,
        articleJson.stocks_count,
        articleJson.created_at
      )
  );

  return { articles };
}

export default function Home({ loaderData }: Route.ComponentProps) {
  const { articles } = loaderData;
  return (
    <div className="flex-1 sm:ml-64">
      <h1>記事一覧</h1>
      <div className="container mx-auto px-4 py-8">
        {articles.map((article) => (
          <p key={article.url}>{article.title}</p>
        ))}
      </div>
    </div>
  );
}

まず最初にページにアクセスしたらQiitaから記事を取得する処理です。
ここではReact Router(Remix)のData Loaderを使ってデータ取得を事前に行っています。

export async function clientLoader({ params }: Route.ClientLoaderArgs) {
  (省略)
}

Data Loaderを利用することは多くのメリットがあります。

  • ページ表示前にデータ取得を行える
  • Loading状態を自動で管理してくれる
  • エラーハンドリングが簡単
  • 型の安全性がある

今回は事前にデータを取得するために利用しています。
clientLoaderに処理を書くことでHTMLに必要な記事のデータをクライアントサイドで事前にロードしています。つまりCSRの動きをしています。Reactをやったことがある方であれば、事前にデータ取得をする動きはuseEffectの代わりとイメージするとわかりやすいかと思います。

paramsは今回利用していませんが、例えば/users/1みたいなパスでUserIdを取るときに利用できます。

 

実際にfetchをしてQiitaの記事を取得しているのが以下の部分です。

  const res = await fetch(`https://qiita.com/api/v2/authenticated_user/items`, {
    headers: {
      Authorization: `Bearer [あなたのアクセストークンを入れる]`,
    },
  });
  const articlesJson: ArticleJson[] = await res.json();

今回はhttps://qiita.com/api/v2/authenticated_user/itemsを叩いています。このエンドポイントはアクセストークンのユーザーつまりあなたの記事を取得することができます。(もし記事が1つもない場合は何か限定公開記事を投稿してください)
 

そのあとに取得したデータを私達が今回利用するドメイン(Article)にしてあげて返しています。

  const articles = articlesJson.map(
    (articleJson) =>
      new Article(
        articleJson.title,
        articleJson.url,
        articleJson.likes_count,
        articleJson.stocks_count,
        articleJson.created_at
      )
  );

  return { articles };

Data Loaderで返した値は簡単に受け取ることができます。

  const { articles } = loaderData;

なんとartcilesはData Loaderで返した型(Article[])をしっかりと推論までしてくれます。

image.png

あとはHTMLでarticlesをmapを使ってそれぞれHTMLで描画しています。

        {articles.map((article) => (
          <p key={article.url}>{article.title}</p>
        ))}

それでは実際に画面を見ていきましょう!

image.png
 

記事が表示されました!しかしとある問題があります!

image.png

開発者ツールで「Network」から「home.tsx」をみるとアクセストークンをクライアント側で見ることができていしまいます。
 

アクセストークンなどがあるケースではセキュリティ面でSSRを選択する必要があります。
またSSRにしておくことでパフォーマンスやSEOなど色々とメリットがあるので、Loaderの設定をかえてSSRでデータ取得をできるようにしてみましょう。

app/routes.home.tsx
import type { Route } from "./+types/home";
import { Article, type ArticleJson } from "~/domain/Article";

export async function loader({ params }: Route.LoaderArgs) {
  const res = await fetch(`https://qiita.com/api/v2/authenticated_user/items`, {
    headers: {
      Authorization: `Bearer [あなたのアクセストークンを入れる]`,
    },
  });
  const articlesJson: ArticleJson[] = await res.json();
  const articles = articlesJson.map(
    (articleJson) =>
      new Article(
        articleJson.title,
        articleJson.url,
        articleJson.likes_count,
        articleJson.stocks_count,
        articleJson.created_at
      )
  );

  return { articles };
}

export default function Home({ loaderData }: Route.ComponentProps) {
  const { articles } = loaderData;
  return (
    <div className="flex-1 sm:ml-64">
      <h1>記事一覧</h1>
      <div className="container mx-auto px-4 py-8">
        {articles.map((article) => (
          <p key={article.url}>{article.title}</p>
        ))}
      </div>
    </div>
  );
}

CSRからSSRに変えるのはものすごく簡単です。

export async function loader({ params }: Route.LoaderArgs) {
  (省略)
}

clientLoader -> loader
Route.ClientLoaderArgs -> Route.LoaderArgs
と変わっただけです。

それでは実際に画面でアクセストークンが表示されないかを確認します。

image.png

今回はアクセストークンを検索しても見つかりませんでした!無事SSRができています。
 

3-2. 人気記事一覧ページの実装

それでは次に人気記事一覧ページを作成していきます。人気記事のようなものは頻繁の更新が不要なのでSSGをするのには向いているページです。実際にSSGを使ってページを作成してみましょう。

まずはSSGのページであることをreact-router.config.tsに設定します。

react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  // Config options...
  // Server-side render by default, to enable SPA mode set this to `false`
  ssr: true,
  async prerender() {
    return ["/popular"];
  },
} satisfies Config;

async prerenderの中にページのパスを指定することでSSG設定は完了です。
実際のページはSSRのときとほとんど実装は変わりません。

ただし今回の記事取得はhttps://qiita.com/api/v2/items?page=1&per_page=20&query=user%3ASicut_studyを叩いています。

これは@Sicut_study(私)の記事を1ページ目から20件取得するクエリとなっています。
APIではクエリが色々ありますのでそれぞれでカスタマイズしていただいても大丈夫です。

app/routes/popular.tsx
import type { Route } from "./+types/popular";
import { Article, type ArticleJson } from "~/domain/Article";

export async function loader({ params }: Route.LoaderArgs) {
  const res = await fetch(
    `https://qiita.com/api/v2/items?page=1&per_page=20&query=user%3ASicut_study`,
    {
      headers: {
        Authorization: `Bearer [あなたのアクセストークンを入れる]`,
      },
    }
  );
  const articlesJson: ArticleJson[] = await res.json();
  const articles = articlesJson.map(
    (articleJson) =>
      new Article(
        articleJson.title,
        articleJson.url,
        articleJson.likes_count,
        articleJson.stocks_count,
        articleJson.created_at
      )
  );

  return { articles };
}

export default function Popular({ loaderData }: Route.ComponentProps) {
  const { articles } = loaderData;
  return (
    <div className="flex-1 sm:ml-64">
      <h1>人気記事一覧</h1>
      <div className="container mx-auto px-4 py-8">
        {articles.map((article) => (
          <p key={article.url}>{article.title}</p>
        ))}
      </div>
    </div>
  );
}

ここでもしarticlesに赤線が出てしまう場合は型推論がうまく言っていない可能性があります。.react-router/routes/+types/popular.tsの以下の部分を書き直してみてください。

.react-router/routes/+types/popular.ts
type Module = typeof import("../popular.js")

react-routerは新機能としてDataLoaderの型を推論するためにページの型情報を自動生成する機能がありここがうまくVSCodeで動かないことがあるようです。

SSGをするにはビルドをする必要があります。

$ npm run build

Prerender: Generated build/client/popular.data
Prerender: Generated build/client/popular/index.html
Prerender: Generated build/client/__manifest
✓ built in 1.85s

# SSGが作成されたことが確認できる

$ npm run start

localhost:3000/popularにアクセスします。

image.png

SSGで表示することができました。SSGなのでリロードしても高速で表示されるので試してみてください。
 

3-3. 記事検索ページの実装

次に記事検索ページを作成します。こちらのページは検索をインタラクティブにするために、(トークンは漏れてしまいますが)CSRで実装をしていきます。

app/routes/search.tsx
import { Article, type ArticleJson } from "~/domain/Article";
import type { Route } from "./+types/search";
import { useEffect, useRef } from "react";
import { useFetcher, useLoaderData } from "react-router";

async function fetchArticles(keywords?: string) {
  const query = keywords
    ? `user:Sicut_study+title:${keywords}`
    : "user:Sicut_study";

  const res = await fetch(
    `https://qiita.com/api/v2/items?page=1&per_page=20&query=${query}`,
    {
      headers: {
        Authorization: `Bearer あなたのトークンを入れる`,
      },
    }
  );
  const articlesJson: ArticleJson[] = await res.json();
  const articles = articlesJson.map(
    (articleJson) =>
      new Article(
        articleJson.title,
        articleJson.url,
        articleJson.likes_count,
        articleJson.stocks_count,
        articleJson.created_at
      )
  );

  return { articles };
}

export async function loader({ params }: Route.LoaderArgs) {
  const { articles } = await fetchArticles();
  return { articles };
}

export async function clientAction({ request }: Route.ClientActionArgs) {
  const formData = await request.formData();
  const { _action } = Object.fromEntries(formData);

  switch (_action) {
    case "search": {
      const keywords = formData.get("keywords") as string;
      const { articles } = await fetchArticles(keywords);
      return { articles };
    }

    case "like": {
      const title = formData.get("title");
      console.log(`${title}をお気に入り登録しました`);

      const { articles } = await fetchArticles();
      return { articles };
    }
  }
}

export default function Search() {
  const formRef = useRef<HTMLFormElement>(null);
  const fetcher = useFetcher<{ articles: Article[] }>();
  const loader = useLoaderData<{ articles: Article[] }>();
  const articles = fetcher.data?.articles || loader.articles;
  useEffect(() => {
    if (fetcher.state === "idle") {
      formRef.current?.reset();
    }
  }, [fetcher]);

  return (
    <div className="flex-1 sm:ml-64">
      <div>
        <fetcher.Form method="post" ref={formRef}>
          <input type="text" name="keywords" />
          <button type="submit" name="_action" value="search">
            Submit
          </button>
        </fetcher.Form>
      </div>
      <div>
        {articles.map((article) => (
          <div key={article.url}>
            <p>{article.title}</p>
            <fetcher.Form method="post">
              <input
                type="hidden"
                name="title"
                value={article.title}
                readOnly
              />
              <button type="submit" name="_action" value="like"></button>
            </fetcher.Form>
          </div>
        ))}
      </div>
    </div>
  );
}

長くなっており先ほどと違う記述もありますが、丁寧に見ていけば簡単です!

 
まず最初に記事取得を関数にしました。

async function fetchArticles(keywords?: string) {
  const query = keywords
    ? `user:Sicut_study+title:${keywords}`
    : "user:Sicut_study";

  const res = await fetch(
    `https://qiita.com/api/v2/items?page=1&per_page=20&query=${query}`,
    {
      headers: {
        Authorization: `Bearer [あなたのアクセストークンを入れる]`,
      },
    }
  );
  const articlesJson: ArticleJson[] = await res.json();
  const articles = articlesJson.map(
    (articleJson) =>
      new Article(
        articleJson.title,
        articleJson.url,
        articleJson.likes_count,
        articleJson.stocks_count,
        articleJson.created_at
      )
  );

  return { articles };
}

今回はキーワードから記事を検索できるようにするのでクエリをキーワードが関数に渡されたかどうかで返るように工夫しています。

  const query = keywords
    ? `user:Sicut_study+title:${keywords}`
    : "user:Sicut_study";

次にData Loaderですがこの関数を呼び出すだけでシンプルになりました。
Data LoaderはSSRで行うようにしています。

export async function loader({ params }: Route.LoaderArgs) {
  const { articles } = await fetchArticles();
  return { articles };
}

データの取得はuseDataLoaderを利用して取得しています。(このような書き方をしてもData Loaderの値を取得することができます)

const loader = useLoaderData<{ articles: Article[] }>();

次は初めて登場するActionについて紹介していきます。
Actionはユーザーの操作に対して発火してサーバーサイドやクライアントサイドで何かしらの処理を行う際に利用される機能です。

今回のアプリの場合は

  1. 検索ボタンを押したらインプットフォームのキーワードで記事を検索する
  2. お気に入りボタンをクリックしたら記事をお気に入りに追加する

という機能でActionを利用しています。

例えば、検索の機能は以下のようになっており、fetcherという機能を利用することでActionを発火できるようにしています。

  const fetcher = useFetcher<{ articles: Article[] }>();

  (省略)

        <fetcher.Form method="post" ref={formRef}>
          <input type="text" name="keywords" />
          <button type="submit" name="_action" value="search">
            Submit
          </button>
        </fetcher.Form>

<fecher.Form>の中でサブミットが発火するとData LoaderのようにActionが実行されます。fetcherを利用することでデータ送信からデータ更新、画面の再描画までを流れで自動的に行ってくれます。

fetcherは部分的なデータ更新に使用され、フォーム送信やインタラクティブな更新に適しています。それに対してData Loaderはページ全体のデータ取得に使っています。

fetcherを利用することは多くのメリットがあります。

  • フォームの状態管理が不要
  • Loading状態を自動で管理してくれる
  • エラーハンドリングが簡単

このようにfetcherを使用することでフォーム処理やデータ更新の実装をシンプルにかけてより管理しやすいコードにすることができるのです。

export async function clientAction({ request }: Route.ClientActionArgs) {
  const formData = await request.formData();
  const { _action } = Object.fromEntries(formData);

  switch (_action) {
    case "search": {
      const keywords = formData.get("keywords") as string;
      const { articles } = await fetchArticles(keywords);
      return { articles };
    }

    case "like": {
      const title = formData.get("title");
      console.log(`${title}をお気に入り登録しました`);

      const { articles } = await fetchArticles();
      return { articles };
    }
  }
}

今回はユーザーの検索に対してインタラクティブに検索結果を返したいのでclientAction(CSR)を利用しています。actionを使えばSSRになるのでData Loaderと考え方は同じです。

Actionには「検索フォームに入力されている値」と「アクションの種類」を送っています。

          <input type="text" name="keywords" />
          <button type="submit" name="_action" value="search">
            Submit
          </button>

今回の場合サブミットアクションには「検索」と「お気に入り」があるので、buttonタグにname: _actiion, value: searchと書くことでどのボタンが押されたかを識別できるようにしています。

  • _actionがsearchなら検索処理
  • _actionがlikeならお気に入り処理
  const { _action } = Object.fromEntries(formData);

  switch (_action) {
    case "search": {
        (省略)
    }

    case "like": {
        (省略)
    }
  }

今回は検索なのでフォームに入力されたキーワードを取得して記事取得関数に渡しています

      const keywords = formData.get("keywords") as string;
      const { articles } = await fetchArticles(keywords);
      return { articles };

取得が終わるとデータが返されて、articlesが更新されて画面が再描画されます。
初期データはData Loaderの値を使って以降はActionの値を使うようにしています。

  const articles = fetcher.data?.articles || loader.articles;

 

画面で挙動を確認すると検索ボックスに「図解解説」と入力して「Submit」をクリックすると検索結果が表示されます。

image.png

image.png

 

次に「お気に入り機能」についても処理を見ていきましょう

        {articles.map((article) => (
          <div key={article.url}>
            <p>{article.title}</p>
            <fetcher.Form method="post">
              <input
                type="hidden"
                name="title"
                value={article.title}
                readOnly
              />
              <button type="submit" name="_action" value="like"></button>
            </fetcher.Form>
          </div>
        ))}

まずはそれぞれの記事に対して★マークをつけてで囲っています。

             <input
                type="hidden"
                name="title"
                value={article.title}
                readOnly
              />
              <button type="submit" name="_action" value="like"></button>

今回はを見えないように設置して値に記事のタイトルを入れておきます。
こうすることでボタンをクリックしたときにクリックした記事のタイトルをActionに送ることが可能です。ボタンを識別するためにname: _action value: likeも設定しています。

    case "like": {
      const title = formData.get("title");
      console.log(`${title}をお気に入り登録しました`);

      const { articles } = await fetchArticles();
      return { articles };
    }

お気に入り機能に関してはコンソールで表示するだけに今回はしました。
お気に入りが終わったら再度記事一覧を取得して返してページの更新をしています。

image.png

検索をしたときには検索フォームをリセットするような処理も追加してみました。

  const formRef = useRef<HTMLFormElement>(null);
  (省略)
  useEffect(() => {
    if (fetcher.state === "idle") {
      formRef.current?.reset();
    }
  }, [fetcher]);

fetcherを使うと便利なところは、fetcherのステータスやアクションで条件分岐ができることです。

fetcherの状態を以下のように判断することができます。

  • idle: 処理完了または待機中
  • submitting: フォーム送信中
  • loading: データ読み込み中

今回は「検索のアクションの時、検索終了時にフォームを空にする」ということをしたかったので、

  • fetcher.stateが idle (終了)である

ことを確かめてuseRefで直接DOMを操作してクリアするようにしました。

DOMを直接操作することで余計な再レンダリングを防ぎ、検索処理の実行に影響を与えないようにしています。

4. スタイリングをする

ここまででReactRouterv7の基本的な機能を使って一通り実装したので最後はコンポーネント分割とスタイリングをして仕上げていきます。

まずは必要なライブラリをインストールします。

$ npm i framer-motion lucide-react date-fns

次にBlogCardコンポーネント、BlogCardWithFavoriteコンポーネントを作成します。
2つの違いはお気に入りボタンがあるかないかで、記事検索ページの記事はBlogCardWithFavoriteコンポーネントを利用します。

$ mkdir app/components
$ touch app/components/BlogCard.tsx
$ touch app/components/BlogCardWithFavorite.tsx
app/components/BlogCard.tsx
import { motion } from "framer-motion";
import { Heart, Bookmark } from "lucide-react";
import { Article } from "~/domain/Article";
import { format } from "date-fns";
import { ja } from "date-fns/locale";

interface Props {
  article: Article;
}

export default function BlogCard({ article }: Props) {
  const formattedDate = format(
    new Date(article.published_at),
    "yyyy年MM月dd日",
    { locale: ja }
  );

  const randomId = Math.floor(Math.random() * 1000) + 1;

  return (
    <motion.div
      whileHover={{ scale: 1.03 }}
      className="overflow-hidden rounded-lg bg-white shadow-lg transition-shadow hover:shadow-xl"
    >
      <img
        src={`https://picsum.photos/seed/${randomId}/400/200`}
        alt="Blog post thumbnail"
        className="h-48 w-full object-cover"
      />
      <div className="p-6">
        <h3 className="mb-2 text-xl font-semibold text-gray-800 line-clamp-2">
          {article.title}
        </h3>
        <div className="mb-4 flex items-center justify-between text-sm text-gray-500">
          <span>{formattedDate}</span>
          <div className="flex items-center space-x-4">
            <div className="flex items-center">
              <Heart className="mr-1 h-4 w-4 text-red-500" />
              <span>{article.like_count}</span>
            </div>
            <div className="flex items-center">
              <Bookmark className="mr-1 h-4 w-4 text-blue-500" />
              <span>{article.stocks_count}</span>
            </div>
          </div>
        </div>
        <a
          href={article.url}
          target="_blank"
          rel="noopener noreferrer"
          className="block w-full rounded-full bg-blue-500 px-4 py-2 text-center text-sm font-semibold text-white transition-colors hover:bg-blue-600"
        >
          続きを読む
        </a>
      </div>
    </motion.div>
  );
}

日付はdata-fnsで整形して表示するようにしています。

  const formattedDate = format(
    new Date(article.published_at),
    "yyyy年MM月dd日",
    { locale: ja }
  );

また記事それぞれに画像をつけるためにunsplashを利用しています。

      <img
        src={`https://picsum.photos/seed/${randomId}/400/200`}
        alt="Blog post thumbnail"
        className="h-48 w-full object-cover"
      />

画像はクライアント側でレンダリング時に切り替わるようになっています。(あえて固定はしませんでした)

app/components/BlogCardWithFavorite.tsx
import { motion } from "framer-motion";
import { Heart, Bookmark, Star } from "lucide-react";
import { Article } from "~/domain/Article";
import { format } from "date-fns";
import { ja } from "date-fns/locale";

type Props = {
  article: Article;
};

export default function BlogCardWithFavorite(props: Props) {
  const { article } = props;
  const formattedDate = format(
    new Date(article.published_at),
    "yyyy年MM月dd日",
    { locale: ja }
  );

  const randomId = Math.floor(Math.random() * 1000) + 1;

  return (
    <motion.div
      whileHover={{ scale: 1.03 }}
      className="overflow-hidden rounded-lg bg-white shadow-lg transition-shadow hover:shadow-xl"
    >
      <img
        src={`https://picsum.photos/seed/${randomId}/400/200`}
        alt="Blog post thumbnail"
        className="h-48 w-full object-cover"
      />
      <div className="p-6">
        <h3 className="mb-2 text-xl font-semibold text-gray-800 line-clamp-2">
          {article.title}
        </h3>
        <div className="mb-4 flex items-center justify-between text-sm text-gray-500">
          <span>{formattedDate}</span>
          <div className="flex items-center space-x-4">
            <div className="flex items-center">
              <Heart className="mr-1 h-4 w-4 text-red-500" />
              <span>{article.like_count}</span>
            </div>
            <div className="flex items-center">
              <Bookmark className="mr-1 h-4 w-4 text-blue-500" />
              <span>{article.stocks_count}</span>
            </div>
          </div>
        </div>
        <div className="flex items-center justify-between">
          <a
            href={article.url}
            target="_blank"
            rel="noopener noreferrer"
            className="rounded-full bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-600"
          >
            続きを読む
          </a>
          <button
            type="submit"
            name="_action"
            value="like"
            className="rounded-full p-2 transition-colors bg-gray-200 text-gray-600"
          >
            <input type="hidden" name="title" value={article.title} />
            <Star className="h-5 w-5" />
          </button>
        </div>
      </div>
    </motion.div>
  );
}

次にそれぞれのページで作成したコンポーネントを利用するように修正します。

app/routes/home.tsx
import type { Route } from "./+types/home";
import { Article, type ArticleJson } from "~/domain/Article";
import BlogCard from "~/components/BlogCard";
import { motion } from "framer-motion";

export async function loader({ params }: Route.LoaderArgs) {
  const res = await fetch(`https://qiita.com/api/v2/authenticated_user/items`, {
    headers: {
      Authorization: `Bearer あなたのトークンを入れる`,
    },
  });
  const articlesJson: ArticleJson[] = await res.json();
  const articles = articlesJson.map(
    (articleJson) =>
      new Article(
        articleJson.title,
        articleJson.url,
        articleJson.likes_count,
        articleJson.stocks_count,
        articleJson.created_at
      )
  );

  return { articles };
}

export default function Home({ loaderData }: Route.ComponentProps) {
  const { articles } = loaderData;
  return (
    <div className="flex-1 sm:ml-64">
      <motion.div
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.5 }}
        className="container mx-auto px-4 py-8"
      >
        <h2 className="mb-6 text-3xl font-bold text-gray-800">記事検索</h2>
        <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {articles.map((article) => (
            <BlogCard key={article.url} article={article} />
          ))}
        </div>
      </motion.div>
    </div>
  );
}
app/routes/popular.tsx
import type { Route } from "./+types/popular";
import { Article, type ArticleJson } from "~/domain/Article";
import { motion } from "framer-motion";
import BlogCard from "~/components/BlogCard";

export async function loader({ params }: Route.LoaderArgs) {
  const res = await fetch(
    "https://qiita.com/api/v2/items?page=1&per_page=20&query=user%3ASicut_study",
    {
      headers: {
        Authorization: `Bearer あなたのトークンを入れる`,
      },
    }
  );
  const articlesJson: ArticleJson[] = await res.json();
  const articles = articlesJson.map(
    (articleJson) =>
      new Article(
        articleJson.title,
        articleJson.url,
        articleJson.likes_count,
        articleJson.stocks_count,
        articleJson.created_at
      )
  );

  return { articles };
}

export default function Popular({ loaderData }: Route.ComponentProps) {
  const { articles } = loaderData;
  return (
    <div>
      <div className="flex-1 sm:ml-64">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5 }}
          className="container mx-auto px-4 py-8"
        >
          <h2 className="mb-6 text-3xl font-bold text-gray-800">人気記事</h2>
          <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
            {articles.map((article) => (
              <BlogCard key={article.url} article={article} />
            ))}
          </div>
        </motion.div>
      </div>
    </div>
  );
}
tsx:app/routes/search.tsx
import { Article, type ArticleJson } from "~/domain/Article";
import type { Route } from "./+types/search";
import { useFetcher, useLoaderData } from "react-router";
import { useEffect, useRef } from "react";
import { SearchIcon } from "lucide-react";
import BlogCardWithFavorite from "~/components/BlogCardWithFavorite";
import { motion } from "framer-motion";

async function fetchArticles(keywords?: string) {
  const query = keywords
    ? `user:Sicut_study+title:${keywords}`
    : "user:Sicut_study";

  const res = await fetch(
    `https://qiita.com/api/v2/items?page=1&per_page=20&query=${query}`,
    {
      headers: {
        Authorization: `Bearer あなたのトークンを入れる`,
      },
    }
  );

  const articlesJson: ArticleJson[] = await res.json();
  const articles = articlesJson.map(
    (articleJson) =>
      new Article(
        articleJson.title,
        articleJson.url,
        articleJson.likes_count,
        articleJson.stocks_count,
        articleJson.created_at
      )
  );

  return { articles };
}

export async function clientAction({ request }: Route.ClientActionArgs) {
  const formData = await request.formData();
  const { _action } = Object.fromEntries(formData);

  switch (_action) {
    case "search": {
      const keywords = formData.get("keywords") as string;
      const { articles } = await fetchArticles(keywords);
      return { articles };
    }

    case "like": {
      const title = formData.get("title");
      console.log(`${title}をお気に入りに追加しました`);
      const { articles } = await fetchArticles();
      return { articles };
    }
  }
}

export async function loader({ params }: Route.LoaderArgs) {
  const { articles } = await fetchArticles();
  return { articles };
}
export default function Search() {
  const loader = useLoaderData<{ articles: Article[] }>();
  const fetcher = useFetcher<{ articles: Article[] }>();
  const articles = fetcher.data?.articles || loader.articles;
  const formRef = useRef<HTMLFormElement>(null);

  useEffect(() => {
    if (fetcher.state === "idle") {
      formRef.current?.reset();
    }
  }, [fetcher.state]);

  return (
    <div className="flex sm:ml-64">
      <motion.div
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.5 }}
        className="container mx-auto px-4 py-8"
      >
        <h2 className="mb-6 text-3xl font-bold text-gray-800">記事検索</h2>
        <fetcher.Form
          ref={formRef}
          action="/search"
          method="post"
          className="mb-8 flex"
        >
          <input
            type="text"
            name="keywords"
            placeholder="キーワードを入力..."
            className="flex-grow rounded-l-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:outline-none"
          />
          <button
            type="submit"
            name="_action"
            value="search"
            className="flex items-center rounded-r-lg bg-blue-500 px-4 py-2 text-white transition-colors hover:bg-blue-600"
          >
            <SearchIcon className="mr-2 h-5 w-5" />
            検索
          </button>
        </fetcher.Form>
        <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {articles.map((article) => (
            <BlogCardWithFavorite key={article.url} article={article} />
          ))}
        </div>
      </motion.div>
    </div>
  );
}
app/layouts/sidemenu.tsx
import { List, SearchIcon, TrendingUp } from "lucide-react";
import { Link, Outlet } from "react-router";

export default function Sidemenu() {
  const menuItems = [
    { name: "記事一覧", path: "/", icon: List },
    { name: "人気記事", path: "/popular", icon: TrendingUp },
    { name: "記事検索", path: "/search", icon: SearchIcon },
  ];

  return (
    <div>
      <aside
        className={`fixed left-0 top-0 z-40 h-screen w-64 bg-white shadow-lg`}
      >
        <div>
          <div>
            <nav>
              {menuItems.map((item) => (
                <Link
                  key={item.name}
                  to={item.path}
                  className="flex items-center px-4 py-3 text-gray-700 hover:bg-gray-100"
                >
                  <item.icon className="mr-3 h-5 w-5" />
                  {item.name}
                </Link>
              ))}
            </nav>
          </div>
        </div>
      </aside>
      <Outlet />
    </div>
  );
}

image.png

これで基本的な機能を備えた技術記事アプリケーションが完成しました。

このアプリケーションでは:

  • ルーティング設定
  • SSR/SSG/CSRの適切な使い分け
  • コンポーネントの再利用
  • インタラクティブなUI
    を実装することができました。

お疲れ様でした!

5. 今回の課題

ハンズオンお疲れ様でした。
手を動かしてアプリを作っていただきましたが、これはインプットに過ぎません。
ここでアウトプットをすることによって初めて学んだことが身になります。

そこで課題をいくつか用意しましたのでぜひここまでの内容を振り返ってチャレンジしてみてください

  1. 映画一覧アプリを作成してみる

以下のAPIを用いて映画情報の一覧サイトを作成してください

  1. Data LoaderとActionを別ファイルにする

Data LoaderやActionを使うと1ファイルの記述が多くなる問題があります。
以下の方法を試してサーバーでの処理を別ファイルに切り出しファイルの記述を減らしてください。

おわりに

いかがでしたでしょうか?
ステート管理をほとんどせずに動かせるのがとても魅力的です。Next.jsから今後シンプルなReactRouterに移り変わる時代もくるんじゃないでしょうか!

ぜひとも今回学んだことを生かしてアプリを開発してください!

テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください。

本記事のレビュアーの皆様

  • tokec様
  • 吉田侑平様
  • Coby様
  • 上嶋晃太様
  • たけしよしき様

次回のハンズオンのレビュアーはXにて募集します。

図解ハンズオンたくさん投稿しています!

207
182
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
207
182

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?