React・ページを増やして、共有する

ページを分ける(React Router)

Reactのアプリは何もしないと1ページのままです。React Routerを入れて、一覧と詳細をURLで分けます。Link・Routes・useParams・404ページ、そして公開したときだけ404になる有名なつまずきまで扱います。

いまのことりずかんは、どれだけ機能を足してもページが1枚です。鳥の名前を押しても、詳しい説明を出す場所がありません。

この回で、URLでページを分けます

Reactは何もしないと1ページ

index.htmlは1つだけで、その中身をReactが書きかえている——最初の回で見たとおりです。だからURLは変わらないままです。

「ページを分けたように見せる」には、URLとコンポーネントを対応づける道具が要ります。それがルーティングのライブラリで、定番がReact Routerです。

npm install --save-exact react-router-dom

3つの部品でできている

React Routerで覚えるのは、まず3つだけです。

部品役目
<BrowserRouter>アプリ全体を包む。「ここからURLを見ます」の宣言
<Routes> / <Route>URLとコンポーネントの対応表
<Link>ページ内リンク。<a>の代わりに使う

①全体を包む

import { BrowserRouter } from 'react-router-dom'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
)

②対応表を書く

App.jsx対応表だけになります。中身はページごとのコンポーネントに移します。

import { Route, Routes } from 'react-router-dom'
import BirdListPage from './pages/BirdListPage.jsx'
import BirdDetailPage from './pages/BirdDetailPage.jsx'
import NotFoundPage from './pages/NotFoundPage.jsx'

function App() {
  return (
    <Routes>
      <Route path="/" element={<BirdListPage />} />
      <Route path="/birds/:id" element={<BirdDetailPage />} />
      <Route path="*" element={<NotFoundPage />} />
    </Routes>
  )
}

読み方はこうです。

  • path="/"——トップ。一覧を出す
  • path="/birds/:id"——:idの部分は何が来てもいい/birds/1でも/birds/8でも当たる)
  • path="*"——どれにも当たらなかったとき。404ページ

elementにはJSXを渡します<BirdListPage />のようにタグの形)。関数名だけを渡すのではない点に注意してください。

<Link>でつなぐ

import { Link } from 'react-router-dom'

<Link to={`/birds/${bird.id}`}>{name}</Link>

hrefではなくtoです。

URLの一部を受け取る:useParams

/birds/11を受け取るのがuseParamsです。

import { useParams } from 'react-router-dom'

function BirdDetailPage() {
  const { id } = useParams()   // "1"(文字列)

}

{ id }の名前は、<Route path="/birds/:id">:idと同じ名前にします。ここがそろっていないとundefinedになります。

404ページを必ず置く

path="*"はどのルートにも当たらなかったときの受け皿です。置かないと真っ白になり、ユーザーは壊れたと思います。

import { Link } from 'react-router-dom'

function NotFoundPage() {
  return (
    <div className="min-h-screen bg-sky-50 p-8">
      <h1 className="text-2xl font-bold text-sky-800">ページが見つかりません</h1>
      <p className="mt-2 text-sm text-slate-600">
        URLがまちがっているか、その鳥はまだ図鑑にいないようです。
      </p>
      <Link to="/" className="mt-6 inline-block text-sm font-bold text-sky-600">
        ← 一覧にもどる
      </Link>
    </div>
  )
}

export default NotFoundPage

戻り道(一覧へのリンク)を必ず付けるのがコツです。

公開したときだけ404になる問題

手元では動くのに、公開したら/birds/1を直接開いたときだけ404——これはルーティングを入れた人がほぼ全員通るつまずきです。

原因は単純で、サーバーに/birds/1というファイルが無いからです。ページを分けているのはブラウザの中のReactで、サーバーから見ればファイルはindex.html1枚しかありません。

対処は「どのURLでもindex.htmlを返す」設定を1つ足すことです。

/*    /index.html   200

Cloudflare PagesとNetlifyはこの1行(public/_redirectsに置く)で解決します。ホスティングによって書き方は違いますが、探す言葉は「SPAフォールバック」か「rewrite index.html」です。

いま起きている「気持ち悪さ」

詳細ページは、いまこうしています。

useEffect(() => {
  fetch('/birds.json')…   // 一覧ページでも同じことをしている
}, [id])

同じデータを2つのページがそれぞれ取りに行っています。 動きますが、無駄ですし、片方で鳥を足しても片方に反映されません。

これは「データを1か所に置いて、どのページからでも読めるようにしたい」という要求です。次回のContextがその答えです。

公式ドキュメントも見てみる

reactrouter.com外部サイトRouting | React RouterReact Router公式。この回で使った書き方(宣言的ルーティング)の手順がまとまっています。英語ですが、コードを追うだけでも十分読めます

このレッスンのまとめ

  1. Reactは何もしないと1ページ。分けるにはReact Routerのような道具が要る
  2. <BrowserRouter>で包み、<Routes><Route>で対応表を書く
  3. path="/birds/:id":idは何が来てもいい部分path="*"は404の受け皿
  4. アプリ内の移動は<Link to="…"><a>だと状態が全部消える
  5. useParamsが返すのは文字列——数値のidと比べるときは型をそろえる
  6. 公開時だけ404になったら、/* /index.html 200(SPAフォールバック)
  7. ページが増えると同じデータを2か所で持つ問題が出る → 次回のContextへ

やってみよう:詳細ページを作る

一覧と詳細をURLで分けます。ファイルの移動があるので、順番どおりに進めてください。

  1. npm install --save-exact react-router-dom
  2. src/main.jsx<App /><BrowserRouter>で包む
  3. src/pages/フォルダを作り、いまのApp.jsxの中身をまるごとBirdListPage.jsxへ移す(関数名もBirdListPageに。importのパスは../components/…へ直す)
  4. App.jsxを対応表だけの形に書きかえる
  5. BirdDetailPage.jsxNotFoundPage.jsxを作る
  6. BirdCard.jsxの名前(<h2>の中)を<Link>で包み、行き先を/birds/+その鳥のidにする
import { Link } from 'react-router-dom'

<h2 className="font-bold text-slate-800">
  <Link to={`/birds/${bird.id}`} className="hover:text-sky-600">
    {name}
  </Link>
</h2>
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useFavorites } from '../hooks/useFavorites.js'

function BirdDetailPage() {
  const { id } = useParams()
  const [bird, setBird] = useState(null)
  const [isLoading, setIsLoading] = useState(true)
  const { favoriteIds, toggleFavorite } = useFavorites()

  useEffect(() => {
    fetch('/birds.json')
      .then((response) => response.json())
      .then((data) => {
        setBird(data.find((item) => String(item.id) === id) ?? null)
        setIsLoading(false)
      })
  }, [id])

  if (isLoading) return <p className="p-8 text-slate-500">よみこみ中…</p>
  if (!bird) {
    return (
      <div className="min-h-screen bg-sky-50 p-8">
        <p className="text-slate-600">その鳥は見つかりませんでした。</p>
        <Link to="/" className="mt-4 inline-block text-sm font-bold text-sky-600">
          ← 一覧にもどる
        </Link>
      </div>
    )
  }

  const isFavorite = favoriteIds.includes(bird.id)

  return (
    <div className="min-h-screen bg-sky-50 p-8">
      <Link to="/" className="text-sm font-bold text-sky-600">
        ← 一覧にもどる
      </Link>

      <div className="mt-4 max-w-xl rounded-2xl bg-white p-8 shadow-sm">
        <div className="flex items-center gap-4">
          <span className="text-6xl">{bird.emoji}</span>
          <div>
            <h1 className="text-2xl font-bold text-slate-800">{bird.name}</h1>
            <div className="mt-2 flex items-center gap-2">
              <span className="rounded-full bg-sky-100 px-2 py-0.5 text-xs text-sky-700">
                {bird.area}
              </span>
              {bird.rare >= 3 && (
                <span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-bold text-amber-700">
                  レア
                </span>
              )}
            </div>
          </div>
        </div>

        <p className="mt-6 text-slate-600">{bird.memo || 'せつめいはじゅんび中'}</p>

        <button
          type="button"
          onClick={() => toggleFavorite(bird.id)}
          className="mt-6 rounded-full border border-amber-300 px-4 py-2 text-sm font-bold text-amber-600"
        >
          {isFavorite ? '★ お気に入り' : '☆ お気に入りにする'}
        </button>
      </div>
    </div>
  )
}

export default BirdDetailPage
シマエナガの詳細ページ。大きな絵文字と名前、北海道とレアのチップ、説明文、お気に入りボタン、上に「一覧にもどる」リンクがある
名前を押すと詳細へ。アドレスバーが/birds/1に変わっているのを確認してください
「ページが見つかりません」という見出しと説明、一覧にもどるリンクが表示されている
存在しないURLを開いたとき。path="*"の受け皿が効いています

ブラウザの「戻る」ボタンでもちゃんと一覧に戻ることを確認してください。URLで分けると、戻るボタンが自然に効く——これがルーティングを入れるいちばんの効きめです。

よくある質問

Reactでページを分けるにはどうすればいいですか?
React Routerのようなルーティングのライブラリを入れます。URLごとにどのコンポーネントを表示するかを対応づけると、ページを分けたように見せられます。HTMLファイルは1つのままです。
aタグではなくLinkを使うのはなぜですか?
aタグはページ全体を読み直すので、Reactが持っている状態が全部消えて表示も一瞬止まります。Linkは読み直さずに表示だけ切りかえるので速く、状態も残ります。
useParamsで取れる値は数値ですか?
文字列です。URLの一部なので、数値のidと比べるときはString()やNumber()で型をそろえる必要があります。ここを忘れると一致せず「見つかりません」になります。
公開したら詳細ページのURLで404になります
静的ホスティングでよくあるつまずきです。サーバーには/birds/1というファイルが無いためで、どのURLでもindex.htmlを返す設定(SPAフォールバック)を1行入れると直ります。