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

![React Router v7とは？ (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/9d1aa369-3971-0f63-839d-add5709a847a.png)

# はじめに

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

　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/cf68c0d0-bc16-045b-e85c-d0c472bbac63.png)

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/c3b0cce4-0e05-b362-83f6-fa1562ad85b0.png)

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

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

<font color="red">**React19 + React Routerでよくない？？**</font>
　

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

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

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

<iframe width="560" height="315" src="https://www.youtube.com/embed/5KXd3_2-UNo?si=DY6Cmoe_8E-Uekki" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

https://github.com/jinwatanabe/router-article-app

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

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

# 1. React Routerの進化

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/3f9aedea-a1d2-ec49-6a10-872cbb922e15.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/aa173af7-ba5a-317c-74fc-ef27d630536d.png)
　

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

　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/103c2e51-bb66-f9a0-d1af-b6c7abb4e32d.png)
　

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

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


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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d8108024-c461-97af-c4e0-79b48e5b2d1f.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/2547293b-ae5f-b4c1-94ae-b48307177990.png)
　

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

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


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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d2db7a12-9edd-7291-d862-c24df37b4cf7.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/03128f45-6c8d-b065-ca9d-823cde67ef93.png)

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

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

### 2. 状態管理不要

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/495309cd-3532-17c3-3934-834f13bdebf1.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に移行した事例では、パフォーマンスも向上したという報告があります

https://user-first.ikyu.co.jp/entry/2023/12/15/093427

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

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

# 2. ルーティングの基本

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

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

```tsx
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](http://localhost:5173)にアクセスして以下の画面がでれば成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/14bf6585-163d-0b1a-ddf7-c1817bf33bf6.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/f7e2be45-0d6f-09f0-a0e3-29947367d03c.png)

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

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

先にコンフィグベースのルーティングで出てくる主要な概念について説明します。
- route：特定のURLと表示するコンポーネントを紐付けます
- layout：複数のページで共通して使用するレイアウト（ヘッダーやサイドバーなど）を定義します
- prefix：URLの前に共通の文字列をつけることができます（例：/admin/... のような管理者用ページ）
- Outlet：layoutで定義した共通部分の中に、各ページの内容を表示する場所を指定します

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

```ts: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](http://localhost:5173)にアクセスしてみましょう。(npm run devでサーバーを起動しましょう)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d2142a3f-9086-7a13-3a31-452255580142.png)

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

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

```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
```

```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](http://localhost:5173/popular)にアクセスします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/1177464a-e8be-47e2-fa68-55f6dd26e06b.png)

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

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

```ts: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
```

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

```

```tsx:/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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/241983f9-d3ac-f102-2709-6e767e4ddad4.png)

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

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

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

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

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

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

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

```ts: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
```

```tsx:app/routes/ping.tsx
export default function Ping() {
  return (
    <div>
      <h1>Ping</h1>
    </div>
  );
}
```

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/fcece325-4970-3739-f335-8d14050449cc.png)

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

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

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

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

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

![Videotogif (3).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/7d4c1639-6b51-a964-18cd-b148c16d5e6b.gif)

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

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

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

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

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

```
$ mkdir app/domain
$ touch app/domain/Article.ts
```

```tsx: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の形を表現したものです。

https://qiita.com/api/v2/docs#%E6%8A%95%E7%A8%BF

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

```tsx
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](https://qiita.com/)を開いて「設定」→「アプリケーション」から「個人用アクセストークン」の「新しくトークンを発行する」をクリックします。
　

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/7e289e96-1006-5c2c-3b82-a7df67ce2188.png)

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

　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/93ef6d74-3021-9a2c-8119-3020007ae132.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/059e1990-4b6d-08c0-30d3-49d616172fff.png)

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

```tsx: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)の<font color="red">**Data Loader**</font>を使ってデータ取得を事前に行っています。

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

Data Loaderを利用することは多くのメリットがあります。
- ページ表示前にデータ取得を行える
- Loading状態を自動で管理してくれる
- エラーハンドリングが簡単
- 型の安全性がある

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

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

　

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

```tsx
  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)にしてあげて返しています。

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

  return { articles };
  ```

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

```tsx
  const { articles } = loaderData;
```

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/61e7a63b-27f5-c530-ab05-2b8e2c1da905.png)

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/f50438f5-fd18-196d-d7ef-ed5c9a48b7b0.png)
　

記事が表示されました！<font color="red">**しかしとある問題があります！**</font>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/77774725-9a56-28e7-c5bf-3c47130ef9e3.png)

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

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

```tsx: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に変えるのはものすごく簡単です。

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ddb10cb7-d00a-e5e7-92b1-19d1cedb89e1.png)

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

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

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

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

```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ではクエリが色々ありますのでそれぞれでカスタマイズしていただいても大丈夫です。

https://qiita.com/yonex/items/d3ade7c1ae9897536b28

```tsx: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`の以下の部分を書き直してみてください。

```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](http://localhost:3000/popular)にアクセスします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/33aabca6-0e9d-e157-430c-e6b56094946c.png)

SSGで表示することができました。SSGなのでリロードしても高速で表示されるので試してみてください。
　
## 3-3. 記事検索ページの実装

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

```tsx: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>
  );
}
```

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

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

```tsx
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 };
}
```

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

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

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

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

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


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


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

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

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

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


```tsx
  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を使用することでフォーム処理やデータ更新の実装をシンプルにかけてより管理しやすいコードにすることができるのです。

```tsx
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には「検索フォームに入力されている値」と「アクションの種類」を送っています。

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

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

- _actionがsearchなら検索処理
- _actionがlikeならお気に入り処理

```tsx
  const { _action } = Object.fromEntries(formData);

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

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

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

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

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/2643333a-a44d-c59c-e234-5ab310039726.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/68d2bb00-6ae4-3036-65b6-5a2ec35e04a0.png)



　

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

```tsx
        {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>
        ))}
```

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

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

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

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/2a15a5f5-3551-9fa0-b917-54150601c9fd.png)


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

```tsx
  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
```

```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`で整形して表示するようにしています。

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

また記事それぞれに画像をつけるために[unsplash](https://unsplash.com/ja)を利用しています。

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

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

```tsx: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>
  );
}
```

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

```tsx: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>
  );
}
```

```tsx: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: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>
  );
}
```

```tsx: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/373d89d4-ba60-9121-8031-aefbc243aea9.png)

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

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

お疲れ様でした！

# 5. 今回の課題

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

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

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

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

https://www.themoviedb.org/

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

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

https://zenn.dev/gemcook/articles/80ae56b79f5825

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

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

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

<iframe width="560" height="315" src="https://www.youtube.com/embed/5KXd3_2-UNo?si=DY6Cmoe_8E-Uekki" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

https://github.com/jinwatanabe/router-article-app

# JISOUのメンバー募集中！
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか？
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください！
▼▼▼

https://projisou.jp

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

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

次回のハンズオンのレビュアーは[X](https://x.com/Sicut_study)にて募集します。

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

https://qiita.com/Sicut_study/items/13c9f51c1f9683225e2e

https://qiita.com/Sicut_study/items/a27fb53468e14216d6fb

https://qiita.com/Sicut_study/items/0318cc136c189b179b7f