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

Contextで配らずに共有する

ページが増えたら、同じデータを2か所で持つようになりました。Contextを使うと、途中の部品を通さずに値を配れます。createContextとProvider、カスタムフックと組み合わせる型、そして使わないほうがいい場面まで扱います。

前回ページを分けたことで、こういう状態になりました。

  • 一覧ページ:fetch('/birds.json')して自分で持っている
  • 詳細ページ:同じデータをもう一度fetchしている

動きますが、同じものを2か所で持っているのは気持ちが悪いですし、実害もあります。フォームで鳥を足しても、詳細ページ側のデータには入りません。

propsで渡せないのか?

「持ち上げ」の回でやったように、親が持って子に配る——それが基本でした。でも今回は難しいのです。

<Route path="/" element={<BirdListPage />} />
<Route path="/birds/:id" element={<BirdDetailPage />} />

ページは<Route>が呼び出すので、親から手渡しする場所がありません。無理に渡そうとすると、Appが全部のデータを持って、各<Route>のelementに書き並べることになります。

そこでContextです。

Contextは「木のどこからでも読める値」

考え方はシンプルです。

  1. 入れ物を作るcreateContext
  2. 上のほうで値を入れて包む(Provider)
  3. 読みたい場所で取り出すuseContext

propsがバケツリレーで手渡しなら、Contextは建物に引いた水道です。途中の部屋を通さずに、蛇口をひねれば出てきます。

作ってみる

図鑑のデータを配るContextを作ります。中身はこれまでのコードの引っ越しで、新しい概念はcreateContextuseContextの2つだけです。

import { createContext, useContext, useEffect, useState } from 'react'

const BirdsContext = createContext(null)

export function BirdsProvider({ children }) {
  const [birds, setBirds] = useState([])
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    fetch('/birds.json')
      .then((response) => response.json())
      .then((data) => {
        setBirds(data)
        setIsLoading(false)
      })
  }, [])

  function addBird(newBird) {
    setBirds((current) => [...current, newBird])
  }

  function removeBird(id) {
    setBirds((current) => current.filter((bird) => bird.id !== id))
  }

  return (
    <BirdsContext value={{ birds, isLoading, addBird, removeBird }}>
      {children}
    </BirdsContext>
  )
}

export function useBirds() {
  const value = useContext(BirdsContext)
  if (!value) {
    throw new Error('useBirds は BirdsProvider の中で使ってください')
  }
  return value
}

3つのことをしています。

  • createContext(null)——入れ物を作る。nullは「まだ何も入っていない」ときの値
  • BirdsProvider——データとその操作関数をvalueに入れて、childrenを包む
  • useBirds——読み出し口。カスタムフックの回の型がそのまま効いています

包んで、読む

包むのはアプリの上のほう。ルーターの内側に置きます。

<BrowserRouter>
  <BirdsProvider>
    <App />
  </BirdsProvider>
</BrowserRouter>

読むのは、必要な場所で1行です。

// 一覧ページ
const { birds, isLoading, addBird } = useBirds()

// 詳細ページ
const { birds, isLoading, removeBird } = useBirds()

両方のページからfetchが消えました。 データは1か所(Provider)にあり、どちらのページもそこを読むだけです。

これで、フォームで足した鳥は詳細ページからも見えますし、詳細ページで消せば一覧からも消えます

削除を足してみる

共有できたので、削除が意味を持つようになります。詳細ページに置きます。

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

const navigate = useNavigate()

function handleRemove() {
  removeBird(bird.id)
  navigate('/')      // 消したページに留まれないので一覧へ
}

useNavigateコードから移動するための道具です。<Link>はユーザーが押すもの、useNavigateは処理の中で移動したいとき——という使い分けになります。

削除そのものはfilterの1行です。「元をこわさず新しい配列を作る」——ここまで何度も出てきた形が、そのまま使えます。

何でもContextに入れない

便利なので入れすぎたくなりますが、注意点があります。

判断の目安はこうです。

状況どうする
親子で受け渡すだけprops(値の流れが目に見えるので、こちらが基本)
3階層を超えて渡し続けているContextを検討
別のページからも同じ値を読むContext(今回がこれ)
ログイン中のユーザー・テーマ・言語Contextの定番
1つの画面の中だけで使う値その画面のstateのまま

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

ja.react.dev外部サイトコンテクストで深くデータを受け渡す – React公式の「コンテクストを使って深くデータを受け渡す」。「コンテクストを使う前に」の節が、この回の判断の目安と同じことを言っています

このレッスンのまとめ

  1. ページを分けると同じデータを2か所で持つ問題が出る——Contextの出番
  2. 手順は入れ物を作る(createContext)→ 値を入れて包む(Provider)→ 取り出す(useContext
  3. Providerの中身は持ち上げのときのコードそのまま。増えたのは配り方だけ
  4. 読み出しはカスタムフック(useBirds)にまとめるとエラーにも気づける
  5. React 19では<Context value={…}>と書ける(古い記事の.Providerと同じ)
  6. コードから移動したいときはuseNavigate
  7. まずprops、つらくなったらContext。何でも入れると再描画が広がる

次は、Contextだけでは足りなくなったときの選択肢——状態管理ライブラリです。

やってみよう:データをContextに移す

2か所にあるfetchを1つにまとめ、削除を足します。

  1. src/context/フォルダを作り、BirdsContext.jsxを上のコードで作る
  2. src/main.jsx<App /><BirdsProvider>で包む(<BrowserRouter>の内側)
  3. BirdListPage.jsxからfetchbirdsisLoadingaddBird消してuseBirds()から受け取る
  4. BirdDetailPage.jsxからもfetchを消し、birds.find(…)で探す形にする
  5. 詳細ページに「図鑑から消す」ボタンを足し、removeBirduseNavigateをつなぐ
const { id } = useParams()
const navigate = useNavigate()
const { birds, isLoading, removeBird } = useBirds()
const { favoriteIds, toggleFavorite } = useFavorites()

if (isLoading) return <p className="p-8 text-slate-500">よみこみ中…</p>

const bird = birds.find((item) => String(item.id) === id)

function handleRemove() {
  removeBird(bird.id)
  navigate('/')
}
シマエナガの詳細ページ。お気に入りボタンの隣に「図鑑から消す」という赤いボタンが並んでいる
詳細ページに削除ボタンが増えました。押すと一覧に戻り、カードも件数も減っています

フォームで鳥を足してから詳細ページを開くところまで確かめてください。前回は見えなかった鳥が、今回はちゃんと開けます。データが1か所になった証拠です。

よくある質問

ReactのContextとは何ですか?
コンポーネントの木のどこからでも読める値を用意する仕組みです。propsのように途中の部品を経由させる必要がないので、深い場所や別ページへ値を渡すときに使います。
Contextとstateの持ち上げはどちらを使えばいいですか?
まずは持ち上げです。propsで渡すほうが値の流れが目に見えます。3階層を超えて渡し続けるのがつらくなったときや、別ページからも同じ値を読みたいときにContextへ移します。
Contextを使うと再描画は増えませんか?
Contextの値が変わると、その値を読んでいる部品はすべて描き直されます。あまり関係のない値まで1つのContextに詰めこむと影響が広がるので、性質の違う値は分けて持つのが安全です。
Providerの外でuseContextを使うとどうなりますか?
createContextに渡した初期値(多くの場合null)が返るだけで、エラーになりません。気づきにくいので、自作のフックの中でnullを見つけたらエラーを投げるようにしておくと安全です。