· yuta

React Contextでコンポーネント間のデータ共有を理解する

#react#typescript#frontend

Reactでアプリを作っていると、離れたコンポーネント同士でデータを共有したい場面が必ず出てきます。そのための仕組みが Context です。名前は聞いたことがあっても「結局なにを解決しているのか」が曖昧なままになりがちなので、課題から仕組み、実装パターンまでを整理しておきます。

この記事では架空のオンライン料理教室アプリ CookMate を例にします。/lessons/:lessonId/ というURL配下に、レッスン詳細・講師紹介・予約フォームなど複数のページがぶら下がっている、という想定です。

そもそも何が問題なのか

Reactのコンポーネントは「親 → 子 → 孫……」と入れ子になったツリー構造をしています。データは基本的に、親から子へ props(引数) で渡します。

App
 └─ LessonLayout        ← ここで lessonId を持っている
     └─ LessonPage
         └─ Header
             └─ CoverImage   ← ここで lessonId を使いたい!

もし LessonLayout が持っている lessonId を、一番下の CoverImage で使いたい場合、間にいる全員がバケツリレーで渡さなければいけません。

<LessonPage lessonId={lessonId}>       {/* 使わないのに受け取って */}
  <Header lessonId={lessonId}>         {/* 使わないのに渡して */}
    <CoverImage lessonId={lessonId} /> {/* やっと使う */}
  </Header>
</LessonPage>

途中の LessonPage や Header は lessonId を自分では使わないのに、下に渡すためだけに受け取ることになります。これは prop drilling(プロップの穴掘り) と呼ばれ、次のような問題を生みます。

Contextの発想

Contextは、この問題を 「ツリー全体から見られる共有の掲示板」 を用意することで解決します。

バケツリレーをやめて、「みんなが見られる掲示板」に貼る。読みたい人が直接見に行く。

登場人物は2種類だけです。

間にいる LessonPage や Header は一切関与しません。CoverImage が掲示板を直接見に行けます。

Contextを構成する3つの部品

Contextの実装は、次の3つの部品に分けて考えると理解しやすいです。CookMateで「レッスンID」と「カバー画像URL」を共有する LessonContext を作ってみます。

1. Context本体を作る

まず掲示板そのものを定義します。共有したい値の型もここで決めます。

// LessonContext.tsx
import { createContext, useContext, useState } from 'react';
import defaultCover from '~/assets/default-cover.png';

type LessonContextValue = {
  id?: string;
  setId: (id?: string) => void;
  coverSrc: string;
  setCoverSrc: (src: string) => void;
};

const LessonContext = createContext<LessonContextValue | undefined>(undefined);

初期値を undefined にしているのには理由があります。これは「Providerの外では使わせない」という設計を後で効かせるためです(詳しくは後述)。

2. Provider(値を供給する側)を作る

掲示板を設置し、そこに貼る内容を管理する人です。共有したいデータは useState で保持します。

export const LessonProvider = ({ children }: { children: React.ReactNode }) => {
  const [id, setId] = useState<string | undefined>(undefined);
  const [coverSrc, setCoverSrc] = useState(defaultCover);

  return (
    <LessonContext.Provider value={{ id, setId, coverSrc, setCoverSrc }}>
      {children}
    </LessonContext.Provider>
  );
};

ポイントは、useState の状態そのもの(id, coverSrc)と、それを更新する関数(setId, setCoverSrc)の両方を value に載せていることです。こうすることで、掲示板を「読む」ことも「書き換える」こともできるようになります。

この LessonProvider は、共有したい範囲の一番外側に置きます。アプリ全体で共有したいならルートに置きます。

// App側
<LessonProvider>
  {/* この中のコンポーネントは全員 LessonContext を見られる */}
  <Routes />
</LessonProvider>

3. 読み書きするためのカスタムフックを作る

useContext(LessonContext) を毎回書くのは面倒ですし、「Providerの外で使ってしまう」事故も防ぎたいところです。そこで、掲示板を見る動作をまとめたカスタムフックを用意するのが定番です。

export const useLesson = () => {
  const context = useContext(LessonContext);
  if (!context) {
    throw new Error('useLesson must be used within a LessonProvider');
  }
  return context;
};

初期値を undefined にしておいたおかげで、Providerの外で呼ぶと context が undefined になり、わかりやすいエラーで即座に気づけます。これがないと「なぜか値が空」という原因のわかりにくいバグになりがちです。

全体の流れ:書く人と読む人

部品がそろったので、実際の使われ方を見てみます。CookMateでは「レイアウトが書き込み、各ページが読み込む」という一方向の流れになります。

書き込む側(レイアウト)

URLからレッスンIDを取り出し、APIで取得したカバー画像を掲示板に貼ります。

// LessonLayout.tsx
export default function LessonLayout() {
  const { lessonId } = useParams();
  const { setId, setCoverSrc } = useLesson(); // 「書き込みペン」を取得

  useEffect(() => {
    if (!lessonId) return;

    fetchLesson(lessonId).then((lesson) => {
      setId(lessonId);                        // IDを掲示板に貼る
      setCoverSrc(lesson.coverUrl ?? defaultCover); // 画像URLを貼る
    });
  }, [lessonId, setId, setCoverSrc]);

  return <Outlet />; // 配下の各ページを描画
}

読み込む側(各ページ)

貼られた内容を、間を経由せず直接読みます。

// CoverImage.tsx
export function CoverImage() {
  const { coverSrc } = useLesson(); // 掲示板を見るだけ
  return <img src={coverSrc} alt="レッスンのカバー画像" />;
}

CoverImage は LessonLayout の孫やひ孫であっても、useLesson() を呼ぶだけで値を取得できます。間の LessonPage や Header はまったく関与しません。これが prop drilling を解消したかたちです。

図にすると次のようになります。

┌──────────── LessonProvider(掲示板)────────────┐
│  [id] [coverSrc] を保持                          │
│                                                  │
│   LessonLayout ──setId / setCoverSrc──▶ 書く     │
│                                                  │
│   CoverImage ──useLesson()──▶ coverSrc を読む    │
│                                                  │
└──────────────────────────────────────────────────┘

用語の整理

用語 役割 たとえ
Context 共有データの入れ物の定義 掲示板そのもの
Provider データを供給し、共有範囲を決める 掲示板を設置する人
useContext(カスタムフック) データを読み書きする 掲示板を見に行く人

Contextが解決すること = 「親から遠い子へ、間を飛ばして直接データを渡す」。まずはこの一点を押さえれば十分です。

使うときの注意点

便利なContextですが、万能ではありません。実際に使う前に知っておきたい点を挙げておきます。

1. Providerが再マウントされると状態は消える

今回の例のようにProviderの中で useState を使う場合、状態はメモリ上にしかありません。ブラウザをリロードするとリセットされます。そのため、リロード後も値が必要なら、レイアウトのマウント時にAPIやストレージから再取得して埋め直す設計にします(CookMateの LessonLayout が毎回 fetchLesson しているのはこのためです)。

永続化したい場合は localStorage などと併用します。

2. 不要な再レンダリングに注意

Providerの value が更新されると、そのContextを購読しているコンポーネントはすべて再レンダリングされます。更新頻度が高い値や、巨大なオブジェクトを1つのContextに詰め込みすぎると、パフォーマンスに影響することがあります。関心事ごとにContextを分割する、value を useMemo で安定させる、といった対策があります。

3. なんでもContextにしない

「離れた場所で使う共有状態」にはContextが向いていますが、1〜2階層下に渡すだけならpropsで十分です。なんでもContextに入れると、かえってデータの流れが追いにくくなります。また、サーバーから取得するデータのキャッシュ管理まで踏み込むなら、TanStack Queryのような専用ライブラリのほうが適していることも多いです。

Contextはあくまで「ツリーをまたいだ受け渡しの配管」であり、状態管理の万能ツールではない、と捉えておくとよいです。

まとめ

一度この仕組みが腹落ちすると、テーマ設定・ログイン情報・ダイアログの開閉状態など、あらゆる共有状態を同じ考え方で読めるようになります。プロジェクトに複数のContextがあっても、構造はどれも同じです。