React Router v7(フレームワーク利用) 実践ガイド:ブログサイトを作りながら学ぶサーバーサイド、クライアントサイドのレンダリング

2025/01/23に公開

はじめに

React Router v7のReactフレームワークとしての機能を活かしてブログサイトを作りながらルーティングの機能を前回の記事でご紹介しました。

https://shinagawa-web.com/blogs/react-router-v7-framework-guide-blog-site-routing-example

今回はその記事の続きとなります。

サーバサイド、クライアントサイドでのレンダリングなどフレームワークを最大限活用した機能のご紹介をします。

React Router とは

React Routerは、Reactアプリケーション向けのルーティングライブラリです。ルーティングとは、ユーザーが特定のURLにアクセスした際に、どのコンポーネントを表示するかを制御する仕組みのことです。

https://reactrouter.com/home

React Routerはv7よりReactフレームワークとして最大限に使用することも、独自のアーキテクチャを持つライブラリとして最小限に使用することもできます。

今回の記事のゴール

JSON Placeholderというサンプルのjsonを返すサーバーを用いて投稿データをサーバサイド、クライアントサイドで取得する方法をご紹介します。

レスポンスが遅い場合は考慮してスピナーなどを処理中に表示するなども併せて実装し解説しています。

Image from Gyazo

セットアップ

React、Vite、React Router v7などの基本的なライブラリのセットアップについては前回の記事をご確認ください。

https://shinagawa-web.com/blogs/react-router-v7-framework-guide-blog-site-routing-example

サーバーサイドレンダリング

ここではサーバーサイドレンダリングを行うための設定を行います。
と言いつつも既に設定はされております。

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,
} satisfies Config

プロジェクト作成段階でssr: trueとなっておりサーバーサイドレンダリングが活用できるようになっております。

JSON Placeholder

前回の記事ではブログデータをファイルに保存してアクセスしていました。

今回は少しだけ本物?らしくJSON PlaceholderというサイトからAPIでブログデータのサンプルを取得してテストを実施していきます。

https://jsonplaceholder.typicode.com/

トップページに記載されていますが下記のコマンドでデータを取得することができます。(ユーザー登録や認証は不要です。)

fetch('https://jsonplaceholder.typicode.com/todos/1')
      .then(response => response.json())
      .then(json => console.log(json))

レスポンス結果

{
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}

こちらの例ではtodosからid: 1のデータのみ取得しています。

サーバーサイドレンダリング

それではJSON Placeholderでデータ取得をサーバーサイドで行い、レンダリングまでサーバーサイドで済ませた上で画面に表示させます。

今回はトップページに投稿一覧を表示させてみようと思います。

app/lib/data.ts
type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
}

export const fetchPosts = async (): Promise<Post[]> => {
  console.log('fetch')
  const response = await fetch('https://jsonplaceholder.typicode.com/posts')
  return await response.json()
}
const response = await fetch('https://jsonplaceholder.typicode.com/posts')

JSON Placeholderから投稿一覧のデータを取得する処理となります。

type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
}

データの中身を取り出しやすいように型を定義しておきます。

なお型をどうやって定義しているかというと、実際にデータを取得して確認しました。。。

Image from Gyazo

  console.log('fetch')

この関数がどこで動いているかをわかりやすくするために実行時に毎回fetchという文字を出力します。

トップページに投稿一覧を表示させるためトップページを修正します。

routes/home.tsx
- import { posts } from '~/const/posts'
import type { Route } from './+types/home'
import { Link } from 'react-router'
import { HomeNavigation } from '~/components/home-navigation'
+ import { fetchPosts } from '~/lib/data'

export function meta({}: Route.MetaArgs) {
  return [
    { title: 'New React Router App' },
    { name: 'description', content: 'Welcome to React Router!' },
  ]
}

+ export async function loader() {
+   const posts = await fetchPosts()
+   return posts
+ }

export default function Home({
+   loaderData,
+ }: Route.ComponentProps) {
+   const posts = loaderData;
  return (
    <>
      <HomeNavigation />
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/post/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </>
  )
}

ルートローダーは、ルートコンポーネントがレンダリングされる前に、ルートコンポーネントにデータを提供します。サーバーレンダリング時、サーバー上で呼び出されます。

export async function loader() {
  const posts = await fetchPosts()
  return posts
}

取得したデータをコンポーネントで受け取るときに使用するのがloaderDataとなります。

export default function Home({
  loaderData,
}: Route.ComponentProps) {
  const posts = loaderData;
  return (
    <>
      <HomeNavigation />
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/post/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </>
  )
}

型を定義していますのでloader()の戻り値がPromise<Post[]>となっています。

Image from Gyazo

その後のloaderDataPost[]となっていますので後はそのままレンダリングできます。

Image from Gyazo

実際に画面で表示されるか確認します。

Image from Gyazo

npm run devで起動したコンソールを確認すると、fetchの表示があるかと思います。サーバーサイドでfetch処理が実行されたことが確認できました。

Image from Gyazo

データの取得をサーバーサイドで行うことは確認できましたがレンダリングまでサーバーサイドで実行しているかを確認してみたいと思います。

データ取得処理にタイマーをセットします。

fetchPostsが呼び出されると3秒間待ってから実行されます。

app/lib/data.ts
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export const fetchPosts = async (): Promise<Post[]> => {
+ await sleep(3000)
  console.log('fetch')
  const response = await fetch('https://jsonplaceholder.typicode.com/posts')
  return await response.json()
}

実行してみると「トップ」をクリックしてからしばらく動きがありません。

サーバーサイドで3秒間待機し、その後データローディングとレンダリングを行い完了したらレスポンスを返してブラウザで表示されるという挙動になります。

Image from Gyazo

(この動画だとクリックしたタイミングがわかりにくい。。。)

今回は意図的に3秒間待ちましたが実際の開発プロジェクトでもレスポンスの遅い処理が出てくるかと思います。

何も表示しないのはUI的によろしくないのでサーバーサイドでレンダリングしている間にスピナーを表示しようと思います。

まずはスピナーコンポーネントの作成。

app/components/spinner.tsx
export const Spinner = () => {
  return (
    <div
      className="flex h-screen items-center justify-center"
      aria-label="読み込み中"
    >
      <div className="h-10 w-10 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
    </div>
  )
}

次はデータ取得処理を修正します。

app/lib/data.ts
export const fetchPosts = (): Promise<Post[]> => {
  console.log('fetch')
  return new Promise((resolve) =>
    setTimeout(() => {
      fetch('https://jsonplaceholder.typicode.com/posts')
        .then((response) => response.json())
        .then((data) => resolve(data))
    }, 3000),
  )
}

fetchPosts自体Promiseを返すだけの関数にしました。

その上でトップページのコンポーネントで実際の処理を行うよう修正します。

routes/home.tsx
+ import { Suspense } from 'react'
import type { Route } from './+types/home'
- import { Link } from 'react-router'
+ import { Await, Link } from 'react-router'
import { HomeNavigation } from '~/components/home-navigation'
import { fetchPosts } from '~/lib/data'
+ import { Spinner } from '~/components/spinner'

export function meta({}: Route.MetaArgs) {
  return [
    { title: 'New React Router App' },
    { name: 'description', content: 'Welcome to React Router!' },
  ]
}

export async function loader() {
- const posts = await fetchPosts()
+ const data = fetchPosts()
  return { data }
}

export default function Home({ loaderData }: Route.ComponentProps) {
  const { data } = loaderData

  return (
    <>
      <HomeNavigation />
+      <Suspense fallback={<Spinner />}>
+       <Await resolve={data}>
+         {(value) => {
+           return (
              <ul>
+               {value.map((post) => (
                  <li key={post.id}>
                    <Link to={`/post/${post.id}`}>{post.title}</Link>
                  </li>
                ))}
              </ul>
            )
+         }}
+       </Await>
+     </Suspense>
    </>
  )
}

ルートローダーからawaitを外します。引き続きPromiseをそのまま返します。

export async function loader() {
  const data = fetchPosts()
  return { data }
}

そうするとloaderDataの型がPromiseになっていることがわかります。

Image from Gyazo

      <Suspense fallback={<Spinner />}>
        <Await resolve={data}>
          {(value) => {
            return (
              <ul>
                {value.map((post) => (
                  <li key={post.id}>
                    <Link to={`/post/${post.id}`}>{post.title}</Link>
                  </li>
                ))}
              </ul>
            )
          }}
        </Await>
      </Suspense>

ReactのSuspenseを使ってデータが取得できるまでの間はSpinnerコンポーネントをレンダリングします。

またdataの実行をここで行い結果が返ってきたらレンダリングを行います。

データ取得処理が若干難しく感じるかもしれませんがこのように設定することでサーバーからのレンダリング結果が来るまでの間スピナーを表示させることができます。

Image from Gyazo

クライアントサイドレンダリング(CSR)

サーバサイドで初期表示に関わるデータロードとレンダリングを行ったほうが安定したレスポンスを提供できるかと思いますが、様々な条件によりクライアントサイドでレンダリングを行うケースもあるかと思います。

下記の設定を行うことでサーバーサイドレンダリングを止めることが可能です。

既存のSPAから移行するなどで必ずCSRを行う必要がある場合は設定しておくといいかと思います。

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

export default {
  ssr: false,
} satisfies Config;

クライアントデータローディング

クライアントでJSON Placeholderからのデータをフェッチする処理を実装します。

routes/home.tsx
- export async function loader() {
+ export async function clientLoader() {
  const data = fetchPosts()
  return { data }
}

基本的にはclientLoaderを使うことでクライアントサイドでのデータフェッチとなります。

ブラウザでコンソールログを確認するとfetchという文字が表示されブラウザ上でフェッチされていることが確認できます。

Image from Gyazo

HydrateFallback

先ほどのコンソールログにもメッセージが出ていましたがclientLoaderを使う際にはHydrateFallbackを使うことを推奨されています。

このコンポーネントはクライアントサイドでのHydrationプロセス中に代替コンポーネントを表示するための仕組みです。

具体的には、サーバーサイドでプリレンダリングされたコンテンツ(HTML)がクライアントサイドでReactに「引き継がれる」過程(Hydration)があります。このHydrationが完了するまでの間、UIの一部が動かない(インタラクティブでない)状態になる場合があります。この間にユーザーに何か視覚的なフィードバックを提供するためにHydrateFallbackを使用します。

Homeコンポーネントと並列の位置関係でHydrateFallbackを設定します。

routes/home.tsx
+ export function HydrateFallback() {
+   return <div>Loading...</div>;
+ }

export default function Home({ loaderData }: Route.ComponentProps) {
  const { data } = loaderData

  return (
    <>
      <HomeNavigation />
      <Suspense fallback={<Spinner />}>
        <Await resolve={data}>
          {(value) => {
            return (
              <ul>

画面をリロードするとLoading...の文字が表示されているのがほんの一瞬ですが確認できます。

Image from Gyazo

またclientLoaderについてはhydrateプロパティを設定することもできます。

このプロパティをtrueに設定するとハイドレーション中およびページがレンダリングされる前にクライアントローダーを強制的に実行することもできます。

routes/home.tsx
+ clientLoader.hydrate = true as const

ただ今回の検証環境ではハイドレーションが非常に短い時間で行われるため設定してもパフォーマンスに大きな違いは見られません。

さいごに

React Router v7のサーバーサイドレンダリングとクライアントサイドレンダリングの実装方法について解説しました。

特に以下のポイントを押さえることで、より良いユーザー体験を提供できることがわかりました:

サーバーサイドレンダリングによる安定したレスポンス
Suspenseとスピナーを活用した読み込み中の表示
HydrateFallbackによるスムーズな画面遷移
必要に応じたクライアントサイドレンダリングの選択
React Router v7は柔軟な実装オプションを提供しており、プロジェクトの要件に応じて最適なレンダリング方式を選択できます。

今回の記事で紹介した実装パターンを参考に、みなさんのプロジェクトに最適なレンダリング方式を検討してみてください。

次回は、React Router v7のその他の機能について詳しく解説する予定です。引き続きよろしくお願いします。

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する弊社の支援サービス