はじめに
みなさんこんにちは、Watanabe Jin(@Sicut_study)です。
Reactが大好きな私にとってReactの歴史が大きく変わる瞬間に出くわしました。
ついにReact 19が安定版としてリリースされたのです。
React19になってSSRなどサーバーコンポーネントが完全にサポートされます。
そんな中で真っ先に動いたのがRemixです。
Reactでサーバーでの処理ができるとなると「React + React Router」と「React + Remix + React Router」で実現できることの差がなくなりました。
そこでReact RouterとRemixが統合されて新たなフレームワークが生まれます。
それが「React Router v7」なのです!!!
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の進化
新しくなった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)から見ていきましょう。
クライアントサイドレンダリングはReactのコードをクライアント(あなたのブラウザ)で処理してHTMLのファイルを生成するレンダリング方式のことを指します。
Reactはフレームワークを利用しない場合は基本クライアントサイドでレンダリングが行われていました。
サーバーサイドレンダリング(SSR)は、Reactをサーバー側で処理をしてHTMLを生成して完成したものをクライアントに返して表示するレンダリング方法になります。
Next.jsを利用するのはこのサーバー側での処理を行えるというのが大きいです。
例えば1つの画面の中でも「記事一覧」の部分は記事取得のためにAPIを叩く必要があるので、その部分のコンポーネントだけをサーバーコンポーネントとしてサーバー側で処理することも可能です。(クライアントで処理されるコンポーネントはクライアントコンポーネントといいます)
巷ではNext.jsが一強ですが、それはSSRができることが大きいです。しかし、Remixの登場でNext.jsを使う必要ないよね?という人が現れました。
レンダリング方式のもう一つがSSG(Static Site Generation)です。
SSRに似ているのですが、ビルド時に1度だけしかHTMLを生成しないという特徴があります。アクセスするたびにサーバー側でAPIを叩く必要がなくなるので、素早くHTMLを返すことができます。(必要なタイミングでSSRをするISRがありますがこれはまだ対応してなさそう)
メリットデメリットはそれぞれありますが、SSRができるようにReactにフレームワークを追加しておくことは大切なのです。
それぞれのユースケースを簡単に紹介すると
CSR : ダッシュボード系アプリ、オンラインエディタなどインタラクティブなもの
SSR : SNSプラットフォーム、ニュースサイト
SSG : 公式ドキュメント、会社のホームページなど更新が少ないもの
初心者の中にはReactのみのクライアント側だけでアプリを作っている人がいる思いますが、クライアントだけでやることには以下のような問題が起きることもあります。
クライアント側だけでアプリを作る場合、例えばChatGPTを叩くときに使うシークレットキーを使っていたとしたらブラウザから簡単に見ることができてしまいます。これを使われてしまうと莫大な請求につながるかもしれません。
Next.jsやReactでなくRemixを採用することにはこのようなメリットがあります。
1. Web標準である
Web標準に従っているためキャッチアップのコストが少なく、学んだ知識が時代によって使えなくなるリスクが少ないです。フロントエンドはセキュリティ面でもサーバーサイドで処理をすることは大切です。移り変わりが激しいからこそ長期的に考えてRemix(ReactRouter)を選択することが今後は多くなると考えています。
2. 状態管理不要
RemixはReactで使われるuseState
などのクライアントステートを最小限に抑えられるメリットがあります。
例えば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にアクセスして以下の画面がでれば成功です。
それではVSCodeでrouter-article-app
を開きましょう
ディレクトリをみるとtailwind.config.ts
があることわかります。
TailwindCSSもデフォルトで使える状態になっています。
2-2. ルーティングを設定する
先にコンフィグベースのルーティングで出てくる主要な概念について説明します。
- route:特定のURLと表示するコンポーネントを紐付けます
- layout:複数のページで共通して使用するレイアウト(ヘッダーやサイドバーなど)を定義します
- prefix:URLの前に共通の文字列をつけることができます(例:/admin/... のような管理者用ページ)
- Outlet:layoutで定義した共通部分の中に、各ページの内容を表示する場所を指定します
それでは実際にルーティングを設定してみましょう
app/routes
にルーティングの設定が書かれています。
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でサーバーを起動しましょう)
export default function Home() {
return (
<div>
<div className="flex-1 sm:ml-64">
<h1>記事一覧</h1>
</div>
</div>
);
}
問題なくルーティングされています。
index
に書くことで/
に紐付けることができます。CSSはこのあとサイドメニューを入れる関係で事前に当てています。
では次に以下のように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
export default function Popular() {
return (
<div>
<div className="flex-1 sm:ml-64">
<h1>人気記事</h1>
</div>
</div>
);
}
それではlocalhost:5173/popularにアクセスします。
route("popular", "routes/popular.tsx")
とすることで/popular
にpopular.tsx
を紐付けました。
続いて以下のようなルーティングを追加します。
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
export default function Search() {
return (
<div>
<div className="flex-1 sm:ml-64">
<h1>記事検索</h1>
</div>
</div>
);
}
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
を開くと新しいページができています。
左のメニューをクリックすると画面が切り替わります。
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
を使ってみます。
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
export default function Ping() {
return (
<div>
<h1>Ping</h1>
</div>
);
}
そしてlocalhost:5173/v1/systems/pingを開くと以下になります。
...prefix("v1", [...prefix("systems", [route("ping", "routes/ping.tsx")])])
...prefix
を書くことでパスをネストすることができます。
今回はv1
/systems
とネストしてから先程説明したroute
を使いました。
ここまででReact Routerのルーティング機能については理解できたので、次はRemixが持っていた機能を使っていきましょう
3. 技術記事アプリを作る
今回はこのようなアプリを作成していきます。
記事一覧 : SSRで実装
人気記事一覧 : SSGで実装
記事検索 : CSRで実装
ページを作りながらそれぞれの特徴を活かしてレンダリングを学んでいきます。
3-1. 記事一覧ページの実装
記事一覧ページはSSRで実装していきます。
今回はQiitaのAPIを活用していきます。Qiita APIは認証情報を利用するためクライアント側で処理すると認証情報が公開されてしまうと悪用のリスクがあります。
必要なファイルを作成します。
$ mkdir app/domain
$ touch app/domain/Article.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を開いて「設定」→「アプリケーション」から「個人用アクセストークン」の「新しくトークンを発行する」をクリックします。
「アクセストークンの説明」にreact-router-app
と入力して「発行する」をクリック
アクセストークンが表示されるのでメモしておきましょう。
それでは実際に記事一覧ページを作成します。
まずはCSRとSSRの違いを体感するためにCSRで実装をしてみます。
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[])をしっかりと推論までしてくれます。
あとはHTMLでarticlesをmapを使ってそれぞれHTMLで描画しています。
{articles.map((article) => (
<p key={article.url}>{article.title}</p>
))}
それでは実際に画面を見ていきましょう!
記事が表示されました!しかしとある問題があります!
開発者ツールで「Network」から「home.tsx」をみるとアクセストークンをクライアント側で見ることができていしまいます。
アクセストークンなどがあるケースではセキュリティ面でSSRを選択する必要があります。
またSSRにしておくことでパフォーマンスやSEOなど色々とメリットがあるので、Loaderの設定をかえてSSRでデータ取得をできるようにしてみましょう。
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
と変わっただけです。
それでは実際に画面でアクセストークンが表示されないかを確認します。
今回はアクセストークンを検索しても見つかりませんでした!無事SSRができています。
3-2. 人気記事一覧ページの実装
それでは次に人気記事一覧ページを作成していきます。人気記事のようなものは頻繁の更新が不要なのでSSGをするのには向いているページです。実際にSSGを使ってページを作成してみましょう。
まずはSSGのページであることを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ではクエリが色々ありますのでそれぞれでカスタマイズしていただいても大丈夫です。
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
の以下の部分を書き直してみてください。
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にアクセスします。
SSGで表示することができました。SSGなのでリロードしても高速で表示されるので試してみてください。
3-3. 記事検索ページの実装
次に記事検索ページを作成します。こちらのページは検索をインタラクティブにするために、(トークンは漏れてしまいますが)CSRで実装をしていきます。
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はユーザーの操作に対して発火してサーバーサイドやクライアントサイドで何かしらの処理を行う際に利用される機能です。
今回のアプリの場合は
- 検索ボタンを押したらインプットフォームのキーワードで記事を検索する
- お気に入りボタンをクリックしたら記事をお気に入りに追加する
という機能で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」をクリックすると検索結果が表示されます。
次に「お気に入り機能」についても処理を見ていきましょう
{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 };
}
お気に入り機能に関してはコンソールで表示するだけに今回はしました。
お気に入りが終わったら再度記事一覧を取得して返してページの更新をしています。
検索をしたときには検索フォームをリセットするような処理も追加してみました。
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
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"
/>
画像はクライアント側でレンダリング時に切り替わるようになっています。(あえて固定はしませんでした)
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>
);
}
次にそれぞれのページで作成したコンポーネントを利用するように修正します。
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>
);
}
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>
);
}
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>
);
}
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>
);
}
これで基本的な機能を備えた技術記事アプリケーションが完成しました。
このアプリケーションでは:
- ルーティング設定
- SSR/SSG/CSRの適切な使い分け
- コンポーネントの再利用
- インタラクティブなUI
を実装することができました。
お疲れ様でした!
5. 今回の課題
ハンズオンお疲れ様でした。
手を動かしてアプリを作っていただきましたが、これはインプットに過ぎません。
ここでアウトプットをすることによって初めて学んだことが身になります。
そこで課題をいくつか用意しましたのでぜひここまでの内容を振り返ってチャレンジしてみてください
- 映画一覧アプリを作成してみる
以下のAPIを用いて映画情報の一覧サイトを作成してください
- Data LoaderとActionを別ファイルにする
Data LoaderやActionを使うと1ファイルの記述が多くなる問題があります。
以下の方法を試してサーバーでの処理を別ファイルに切り出しファイルの記述を減らしてください。
おわりに
いかがでしたでしょうか?
ステート管理をほとんどせずに動かせるのがとても魅力的です。Next.jsから今後シンプルなReactRouterに移り変わる時代もくるんじゃないでしょうか!
ぜひとも今回学んだことを生かしてアプリを開発してください!
テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください。
本記事のレビュアーの皆様
- tokec様
- 吉田侑平様
- Coby様
- 上嶋晃太様
- たけしよしき様
次回のハンズオンのレビュアーはXにて募集します。
図解ハンズオンたくさん投稿しています!