dev React

React useContextで学ぶ状態共有 — Props drillingからの脱却

Reactでコンポーネントの階層が深くなると、「親から子へ、子から孫へ」とPropsをバケツリレーのように渡し続ける場面に出会います。これが Props drilling と呼ばれる厄介な問題です。useContextを使えば、この煩わしさから解放されます。

この記事では、React Hooksの1つである useContext を基礎から応用まで、ハンズオン形式で学んでいきます。筆者自身がつまずいたポイントや、C#のDI(Dependency Injection)との対比も交えながら、できるだけ噛み砕いて解説していきます。

注記: 記事内のユーザー名・PC名はダミーに差し替えています。

参照元

得られるもの

useContextはReactの状態共有を支える重要なHooksの1つです。この記事を読むと、以下の知識が得られます。

  • Props drillingの問題点と、useContextが解決してくれること
  • useContextの定石パターン(createContext → Provider → useContext)
  • テーマ切り替えハンズオンで体験する基本的な使い方
  • Providerの配置位置が挙動に与える影響
  • 複数Contextのネスト(テーマ+認証の組み合わせ)
  • 再レンダリングの挙動とReact.memoによる最適化
  • memoを使うべき場面・使わなくてよい場面の判断基準

「useContextって何がうれしいの?」という方も、読み終わる頃にはProps drilling を見かけたら「useContextで解決できそう」と自然に思えるようになるはずです。

プロジェクトのフォルダ構成

この記事のハンズオンで作成・編集するファイルは以下の通りです。

react-tailwind/
└── src/
    ├── contexts/
    │   ├── ThemeContext.tsx
    │   └── AuthContext.tsx
    ├── ThemeExample.tsx
    ├── ThemeHeader.tsx
    ├── ThemeContent.tsx
    └── App.tsx

前提条件

  • React + TypeScript + Tailwind CSSの開発環境が構築済みであること
  • useStateの基本(状態管理、set関数、関数型更新)を理解していること
  • Propsの基本(親から子へのデータ受け渡し)を理解していること

基礎編

useContextとは? — Props drillingという課題

まず、useContextが解決する問題を理解しましょう。

Chapter 10で学んだように、Reactでは親コンポーネントから子コンポーネントへデータを渡すときにPropsを使います。しかし、コンポーネントの階層が深くなると困ったことが起きます。

App(テーマ色: "dark" を持っている)
├── Header
│   └── NavMenu
│       └── NavItem  ← ここでテーマ色を使いたい

NavItemでテーマ色を使いたい場合、Propsだけだと App → Header → NavMenu → NavItem と、途中のコンポーネント(HeaderやNavMenu)がテーマ色を使わないのに全員バケツリレーでPropsを渡す必要があります。

C言語で例えるなら「関数Aのローカル変数を関数Dで使いたいのに、関数B→関数Cを経由して引数で渡し続ける」のと同じ面倒さです。これが Props drilling(プロップスの穴掘り) と呼ばれる問題です。

useContextは、このProps drillingを解消するために「コンポーネントツリーの中でデータを直接共有する仕組み」を提供してくれます。

ちなみに「コンポーネントツリー」とは、1画面単位ではなく、アプリ全体のコンポーネントの親子関係の階層構造のことです。最上位のコンポーネント(通常 App)を根として、子→孫→…と入れ子になった木構造全体を指します。

useContextの定石パターン(3ステップ)

useContextのやっていることはシンプルで、たった3ステップです。

  1. 「共有データの箱」を作るcreateContext
  2. ツリーの任意の位置に「ここから下に配る」と宣言するProvider
  3. 必要なコンポーネントが直接取りに行くuseContext

C#のDI(Dependency Injection)に馴染みがあるなら、次の対応関係で理解できます。

useContextC# DI
createContextサービスインターフェース定義
Providerservices.AddScoped<IService>()
useContext / カスタムHookコンストラクタインジェクション

DIコンテナにサービスを登録しておけば、必要なクラスがコンストラクタで直接受け取れる。途中のクラスが中継する必要がない。useContextもまさにこの仕組みのReact版です。

実際のコードでは、この3ステップに加えて「TypeScript型定義」と「安全装置(nullチェック付きカスタムHook)」を組み合わせるのが定石です。以下の ThemeContext.tsx がその定石パターンのテンプレートになります。

// src/contexts/ThemeContext.tsx
import { createContext, useContext, useState } from "react";

// 箱の型定義
interface ThemeContextType {
  theme: "light" | "dark";
  toggleTheme: () => void;
}

// 箱を作る
const ThemeContext = createContext<ThemeContextType | null>(null);

// Providerコンポーネント
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  const toggleTheme = () =>
    setTheme((prev) => (prev === "light" ? "dark" : "light"));

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

// 取り出すためのカスタムHook(安全装置付き)
export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error("useTheme must be used within ThemeProvider");
  return context;
};

ポイントは、定義・Provider・カスタムHookを1ファイルにまとめること。そして useTheme のnullチェックが安全装置として機能します。Provider外で誤って使った場合に、わかりにくいnullエラーではなく明確なメッセージを出してくれます(この効果は応用編で体験します)。

ハンズオン①:テーマ切り替え(基本)

上の ThemeContext.tsxsrc/contexts/ フォルダに作成したら、テーマを使うコンポーネントとAppを実装します。

// src/ThemeExample.tsx
import { useTheme } from "./contexts/ThemeContext";

const ThemeExample = () => {
  const { theme, toggleTheme } = useTheme();
  const isDark = theme === "dark";

  return (
    <div className={`min-h-screen p-8 ${isDark ? "bg-gray-800 text-white" : "bg-white text-gray-800"}`}>
      <h1 className="text-2xl font-bold mb-4">useContext デモ</h1>
      <p className="mb-4">現在のテーマ: {theme}</p>
      <button
        onClick={toggleTheme}
        className={`px-4 py-2 rounded ${isDark ? "bg-white text-gray-800" : "bg-gray-800 text-white"}`}
      >
        テーマ切り替え
      </button>
    </div>
  );
};

export default ThemeExample;
// src/App.tsx
import './App.css'
import { ThemeProvider } from "./contexts/ThemeContext";
import ThemeExample from "./ThemeExample";

function App() {
  return (
    <ThemeProvider>
      <ThemeExample />
    </ThemeProvider>
  );
}

export default App

ブラウザで確認すると、ボタンを押すたびにライトモードとダークモードが切り替わります。useTheme() を呼ぶだけで、Propsを受け取らなくてもテーマ情報にアクセスできています。

React useContext テーマ切り替えデモ ライトモード表示
React useContext テーマ切り替えデモ ダークモード表示

ハンズオン②:子コンポーネントからPropsなしでテーマ取得

ハンズオン①だけでは「Propsで渡しても同じでは?」と思うかもしれません。子コンポーネントを追加して、Propsなしでテーマを取得できることを体験しましょう。

// src/ThemeHeader.tsx
import { useTheme } from "./contexts/ThemeContext";

const ThemeHeader = () => {
  const { theme, toggleTheme } = useTheme();
  const isDark = theme === "dark";

  return (
    <header className={`p-4 mb-4 rounded ${isDark ? "bg-gray-700" : "bg-blue-100"}`}>
      <div className="flex justify-between items-center">
        <h2 className="text-xl font-bold">ヘッダー(テーマ: {theme})</h2>
        <button
          onClick={toggleTheme}
          className={`px-3 py-1 rounded ${isDark ? "bg-white text-gray-800" : "bg-gray-800 text-white"}`}
        >
          テーマ切り替え
        </button>
      </div>
    </header>
  );
};

export default ThemeHeader;
// src/ThemeContent.tsx
import { useTheme } from "./contexts/ThemeContext";

const ThemeContent = () => {
  const { theme } = useTheme();
  const isDark = theme === "dark";

  return (
    <main className={`p-4 rounded ${isDark ? "bg-gray-700" : "bg-green-100"}`}>
      <h2 className="text-xl font-bold mb-2">コンテンツ(テーマ: {theme})</h2>
      <p>このコンポーネントはPropsを受け取っていませんが、テーマを直接取得しています。</p>
    </main>
  );
};

export default ThemeContent;

ThemeExample.tsx を修正して、子コンポーネントを組み込みます。

// src/ThemeExample.tsx
import { useTheme } from "./contexts/ThemeContext";
import ThemeHeader from "./ThemeHeader";
import ThemeContent from "./ThemeContent";

const ThemeExample = () => {
  const { theme } = useTheme();
  const isDark = theme === "dark";

  return (
    <div className={`min-h-screen p-8 ${isDark ? "bg-gray-800 text-white" : "bg-white text-gray-800"}`}>
      <h1 className="text-2xl font-bold mb-4">useContext デモ</h1>
      <ThemeHeader />
      <ThemeContent />
    </div>
  );
};

export default ThemeExample;

ここで注目してほしいのは、ThemeExampleがThemeHeaderやThemeContentに Propsを一切渡していない ことです。それでもヘッダーのテーマ切り替えボタンを押すと、ThemeExample、ThemeHeader、ThemeContentの全てが一斉にテーマを切り替えます。各子コンポーネントが useTheme() で直接テーマを取得しているからです。

React useContext 子コンポーネントがPropsなしでテーマ取得 ライトモード
React useContext 子コンポーネントがPropsなしでテーマ取得 ダークモード

応用編

ハンズオン③:Providerの配置位置による影響

Providerの配置位置が「データを配る範囲」を決めます。この影響を体験してみましょう。

App.tsx からThemeProviderを外し、ThemeExample.tsx 内の一部だけをProviderで囲みます。そしてProviderの外にもThemeHeaderを配置してみます。

// src/App.tsx(実験用)
import './App.css'
import ThemeExample from "./ThemeExample";

function App() {
  return (
    <ThemeExample />
  );
}

export default App
// src/ThemeExample.tsx(実験用)
import { ThemeProvider } from "./contexts/ThemeContext";
import ThemeHeader from "./ThemeHeader";
import ThemeContent from "./ThemeContent";

const ThemeExample = () => {
  return (
    <div className="min-h-screen p-8 bg-white text-gray-800">
      <h1 className="text-2xl font-bold mb-4">useContext デモ(Providerの位置実験)</h1>

      <ThemeProvider>
        <ThemeHeader />
        <ThemeContent />
      </ThemeProvider>

      <div className="mt-4 p-4 bg-red-100 rounded">
        <p>↑ Providerの中(テーマ切り替え可能)</p>
        <p>↓ Providerの外(テーマの影響を受けない)</p>
      </div>

      <ThemeHeader />
    </div>
  );
};

export default ThemeExample;

ブラウザで確認すると、画面が真っ白になりエラーが発生します。

React useContext Provider外でuseTheme使用時のエラー画面
React useContext Provider外使用時のコンソールエラーメッセージ

DevToolsのコンソールには以下のメッセージが表示されます。

Uncaught Error: useTheme must be used within ThemeProvider
    at useTheme (ThemeContext.tsx:23:23)
    at ThemeHeader (ThemeHeader.tsx:4:34)

Providerの外に置かれたThemeHeaderが useTheme() を呼んだとき、ContextがnullだったためにThemeContext.tsxに書いた安全装置(if (!context) throw new Error(...))が発動しました。この安全装置がなければ、nullのまま処理が進んでもっとわかりにくいエラーになります。

C#のDIで言えば、スコープ外でサービスをResolveしようとして例外が飛ぶのと同じ状況です。

学びのポイント:

  • Providerの位置が「データを配る範囲」を決める
  • Provider外でuseContextを使うとnullになる
  • カスタムHookでnullチェック+明確なエラーメッセージを出すのが定石

ハンズオン④:複数Contextのネスト(テーマ+認証)

実際のアプリでは、テーマ、認証、言語設定など複数のContextを同時に使うことが一般的です。テーマに加えて、認証(ログインユーザー情報)のContextを追加してみましょう。

ThemeContext.tsx と同じパターンで AuthContext.tsx を作成します。

// src/contexts/AuthContext.tsx
import { createContext, useContext, useState } from "react";

interface User {
  name: string;
  role: string;
}

interface AuthContextType {
  user: User | null;
  login: () => void;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | null>(null);

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState<User | null>(null);

  const login = () => setUser({ name: "田中太郎", role: "admin" });
  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within AuthProvider");
  return context;
};

App.tsx で2つのProviderをネストします。

// src/App.tsx
import './App.css'
import { ThemeProvider } from "./contexts/ThemeContext";
import { AuthProvider } from "./contexts/AuthContext";
import ThemeExample from "./ThemeExample";

function App() {
  return (
    <ThemeProvider>
      <AuthProvider>
        <ThemeExample />
      </AuthProvider>
    </ThemeProvider>
  );
}

export default App

ThemeExample.tsx から2つのContextを同時に使います。

// src/ThemeExample.tsx
import { useTheme } from "./contexts/ThemeContext";
import { useAuth } from "./contexts/AuthContext";
import ThemeHeader from "./ThemeHeader";
import ThemeContent from "./ThemeContent";

const ThemeExample = () => {
  const { theme } = useTheme();
  const { user, login, logout } = useAuth();
  const isDark = theme === "dark";

  return (
    <div className={`min-h-screen p-8 ${isDark ? "bg-gray-800 text-white" : "bg-white text-gray-800"}`}>
      <h1 className="text-2xl font-bold mb-4">複数Contextのネスト デモ</h1>

      <div className={`p-4 mb-4 rounded ${isDark ? "bg-gray-700" : "bg-yellow-100"}`}>
        <h2 className="text-xl font-bold mb-2">認証情報</h2>
        {user ? (
          <div>
            <p>ユーザー: {user.name}({user.role})</p>
            <button onClick={logout} className="mt-2 px-3 py-1 bg-red-500 text-white rounded">
              ログアウト
            </button>
          </div>
        ) : (
          <div>
            <p>未ログイン</p>
            <button onClick={login} className="mt-2 px-3 py-1 bg-blue-500 text-white rounded">
              ログイン
            </button>
          </div>
        )}
      </div>

      <ThemeHeader />
      <ThemeContent />
    </div>
  );
};

export default ThemeExample;
React useContext 複数Contextネスト 未ログインライトモード
React useContext 複数Contextネスト ログイン後表示
React useContext 複数Contextネスト ダークモードログイン状態

ブラウザで確認すると、ログインボタンとテーマ切り替えボタンがそれぞれ独立して動作します。ログイン状態を保ったままダークモードに切り替えることもできます。

学びのポイント:

  • 目的別にContextを分離し、Providerをネストするのが定石
  • 各Contextは完全に独立して動作する
  • 1つのコンポーネントから複数のContextに同時にアクセスできる
  • C#のDIで AddScoped<IThemeService>()AddScoped<IAuthService>() を両方登録するのと同じ構造

ハンズオン⑤:再レンダリングの挙動とReact.memo

useContextを使う上で知っておくべきパフォーマンスの話です。

まず、各コンポーネントが再レンダリングされるたびにコンソールにログを出すようにします。ThemeExample、ThemeHeader、ThemeContentの各コンポーネントのreturnの前に console.log を追加します。

// 各コンポーネントに以下を追加
console.log("🔄 ThemeExample rendered");
console.log("🔄 ThemeHeader rendered");
console.log("🔄 ThemeContent rendered");

この状態でDevToolsのConsoleを開き、コンソールをクリアしてからログインボタンを押すと、以下のログが出力されます。

🔄 ThemeExample rendered
🔄 ThemeHeader rendered
🔄 ThemeContent rendered
React memo適用前の再レンダリングログ 全コンポーネントがレンダリング

ログインはAuthContextの変更なのに、ThemeContextしか使っていないThemeHeaderとThemeContentまで再レンダリングされています。これは 「親(ThemeExample)が再レンダリングされると、その子コンポーネントも全て再レンダリングされる」 というReactのデフォルト動作が原因です。

制御系で例えると、メインループが1回回ると、変化がないサブモジュールも全部再実行されるようなものです。小規模なら問題ないですが、コンポーネントが増えると無駄が大きくなります。

これを防ぐのが React.memo です。ThemeHeaderとThemeContentを memo で囲みます。

// src/ThemeHeader.tsx
import { memo } from "react";
import { useTheme } from "./contexts/ThemeContext";

const ThemeHeader = memo(() => {
  const { theme, toggleTheme } = useTheme();
  const isDark = theme === "dark";

  console.log("🔄 ThemeHeader rendered");

  return (
    <header className={`p-4 mb-4 rounded ${isDark ? "bg-gray-700" : "bg-blue-100"}`}>
      <div className="flex justify-between items-center">
        <h2 className="text-xl font-bold">ヘッダー(テーマ: {theme})</h2>
        <button
          onClick={toggleTheme}
          className={`px-3 py-1 rounded ${isDark ? "bg-white text-gray-800" : "bg-gray-800 text-white"}`}
        >
          テーマ切り替え
        </button>
      </div>
    </header>
  );
});

export default ThemeHeader;
// src/ThemeContent.tsx
import { memo } from "react";
import { useTheme } from "./contexts/ThemeContext";

const ThemeContent = memo(() => {
  const { theme } = useTheme();
  const isDark = theme === "dark";

  console.log("🔄 ThemeContent rendered");

  return (
    <main className={`p-4 rounded ${isDark ? "bg-gray-700" : "bg-green-100"}`}>
      <h2 className="text-xl font-bold mb-2">コンテンツ(テーマ: {theme})</h2>
      <p>このコンポーネントはPropsを受け取っていませんが、テーマを直接取得しています。</p>
    </main>
  );
});

export default ThemeContent;

変更点は import { memo } from "react" の追加と、コンポーネントを memo() で囲むだけです。

再度コンソールをクリアしてからログインボタンを押すと、今度は以下のログだけが出力されます。

🔄 ThemeExample rendered

ThemeHeaderとThemeContentの再レンダリングがスキップされました。

React memo適用後の再レンダリングログ 不要なレンダリングがスキップ

ログイン操作はAuthContextしか変更しないので、ThemeContextだけを使っているThemeHeaderとThemeContentは再レンダリング不要だとmemoが判断してくれたのです。

memoの定石 — 使うべき場面・不要な場面

「じゃあ全部memoで囲めばいいのでは?」と思うかもしれませんが、それはアンチパターンです。

memo自体にもコストがあります。毎回「前回のPropsと今回のPropsが同じか?」を比較する処理が走るため、この比較処理自体がタダではありません。

制御系で例えると、全センサに変化検出フィルタをかけるようなものです。変化が頻繁なセンサにフィルタをかけても無駄にオーバーヘッドが増えるだけ。変化が少ないセンサにこそ効果があります。

memoを使うべき場面:

  • 親が頻繁に再レンダリングされるが、子のPropsは変わらない
  • 子のレンダリング処理が重い(大きなリストや複雑なUI)
  • 今回のハンズオンのように、関係ないContextの変更で巻き込まれる場合

memoが不要な場面:

  • コンポーネントが小さくて軽い
  • 親の再レンダリングと同じタイミングで自分も必ず変わる
  • Propsが毎回変わる(比較しても常にfalseで無意味)

実務的には「まず普通に書いて、パフォーマンスに問題が出たらProfiler(React DevToolsのパフォーマンス計測機能)で特定してmemoを適用する」のが定石です。最初から全部囲むのではなく、必要になってから使いましょう。

まとめ

この記事では、useContextを基礎から応用まで5つのハンズオンで体験しました。振り返ってみましょう。

基礎編で学んだこと:

  • Props drillingの問題を、useContextが「直接取りに行く仕組み」で解決すること
  • 定石パターンは createContext → Provider → カスタムHook の3ステップ
  • 定義・Provider・カスタムHookは1ファイルにまとめる
  • カスタムHookにnullチェックの安全装置を入れるのが定石

応用編で学んだこと:

  • Providerの配置位置がデータの配信範囲を決める(スコープの概念)
  • 複数Contextはネストして使う。各Contextは独立して動作する
  • 親の再レンダリングは子に伝播する。React.memoで不要な再レンダリングをスキップできる
  • memoは「全部囲む」のではなく「必要になってから使う」のが定石

C#のDIとの対比で理解すると、createContextがインターフェース定義、Providerがサービス登録、useContextがコンストラクタインジェクションに対応します。この対応関係を頭に入れておくと、Reactの状態共有の仕組みが自然に理解できるはずです。

それでは、また別の記事でお会いしましょう。最後まで読んでいただきありがとうございました!

これまでの学習内容

この記事で紹介した内容の他にも学習した内容を記事にしています。是非、ご覧になってみて下さい。

React入門 Vite + React + TypeScriptで開発環境を構築する手順をハンズオン形式で解説
Vite React TypeScript setupでTodoAppの開発環境を構築する

Vite React TypeScript setup の手順を、実際のハンズオンを通じてステップバイステップで解説します。この記事は、筆者がReact学習の第一歩として環境構築を行った際の体験をもと ...

続きを見る

react jsx propsの基本を他言語経験者向けに解説する入門記事のアイキャッチ
React入門:JSX・コンポーネント・Propsを理解する【他言語経験者向け】

Reactの学習を始めて、JSXの書き方、関数コンポーネント、Propsの仕組みまで一通り手を動かしてみました。この記事では、react jsx propsの基本を他言語経験者の視点で整理していきます ...

続きを見る

react event handlingの基本をクリック・入力・フォームイベントのハンズオンで解説する入門記事のアイキャッチ
React Event Handling入門:クリック・入力・フォームイベントをハンズオンで学ぶ

React event handlingの基本を、実際にコードを書いて動かしながら学びました。この記事では、クリックイベント、入力イベント、イベントオブジェクト、デフォルト動作の防止まで、ハンズオンで ...

続きを見る

React useState useEffect 状態管理と副作用処理の基本をハンズオンで学ぶ
React useState と useEffect を理解する — 状態管理と副作用の基本

React の useState と useEffect は、コンポーネントの状態管理と副作用処理を担う最も重要な Hook です。この記事では、実際にカウンターやタイマーを作りながら、これらの仕組み ...

続きを見る

React useContext Props drillingを解消する状態共有の仕組みをハンズオンで学ぶ記事のアイキャッチ画像
React useContextで学ぶ状態共有 — Props drillingからの脱却

Reactでコンポーネントの階層が深くなると、「親から子へ、子から孫へ」とPropsをバケツリレーのように渡し続ける場面に出会います。これが Props drilling と呼ばれる厄介な問題です。u ...

続きを見る

-dev, React