⚛️ React Boundary
Hooks — 内部構造

🌐 Context APIの仕組み

Context APIはなぜprop drillingを回避できるのか。 ProviderとConsumerの内部実装、value変更時の再レンダリング範囲、 パフォーマンス問題の原因と対策を深く理解します。

🤔 問題提起:prop drillingとは何か

コンポーネントが深くネストしているとき、深い階層のコンポーネントにデータを渡すために 途中のコンポーネントがそのデータを「素通し」でpropsとして渡し続ける問題を prop drilling(バケツリレー)と呼びます。

// ❌ prop drilling: 途中のコンポーネントが使わないデータを渡し続ける
function App() {
  const [user, setUser] = useState({ name: 'Alice', theme: 'dark' });
  return <Layout user={user} />; // Layoutはuserを使わないが渡す必要がある
}

function Layout({ user }) {
  return <Sidebar user={user} />; // Sidebarもuserを使わないが渡す
}

function Sidebar({ user }) {
  return <UserMenu user={user} />; // Sidebarもuserを使わないが渡す
}

function UserMenu({ user }) {
  // ← ここでやっとuserを使う!
  return <div>こんにちは、{user.name}さん</div>;
}

// 問題:
// 1. Layout・Sidebarは user を使わないのにpropsに追加しなければならない
// 2. userの型が変わると、全ての中間コンポーネントを修正する必要がある
// 3. コードが読みにくくなる

🎯 結論から言う

Contextは「コンポーネントツリーをまたいで値を配布する仕組み」です。

ProviderはFiberツリーに値を「注入」し、useContextはそれを「購読」します。 valueが変わると、そのContextを購読している全コンポーネントが再レンダリングされます。

🗺️ 構造図:ContextのProvider/Consumer

// Context の基本構造
// 1. Contextを作成
const ThemeContext = React.createContext('light'); // デフォルト値

// 2. Providerで値を配布
function App() {
  const [theme, setTheme] = useState('dark');
  return (
    // value が変わると、このProvider以下の購読者が再レンダリング
    <ThemeContext.Provider value={theme}>
      <Layout />           {/* themeを props で受け取らなくていい */}
    </ThemeContext.Provider>
  );
}

// 3. 中間コンポーネントはContextを意識しなくていい
function Layout() {
  return <Sidebar />; // userを渡さなくていい!
}
function Sidebar() {
  return <UserMenu />;
}

// 4. 購読したいコンポーネントでuseContextを使う
function UserMenu() {
  const theme = useContext(ThemeContext); // ← Contextから値を取得
  return <div className={theme === 'dark' ? 'bg-black' : 'bg-white'}>...</div>;
}
Context値の伝播イメージ
App(Provider value="dark")
└─
Layout(Contextを使わない)
└─
Sidebar(Contextを使わない)
└─
UserMenu(useContext → "dark")

Contextは親から子へのpropsのバケツリレーを省略し、Provider → Consumer に直接届く

📚 概念説明:Provider内部の実装

Reactの内部では、ContextはFiberツリーを通じて「スタック」として管理されています。

// React内部のContextスタック(概念的な実装)

// createContextが作るオブジェクト
function createContext(defaultValue) {
  const context = {
    $$typeof: REACT_CONTEXT_TYPE,
    _currentValue: defaultValue, // 現在の値(スタックで管理)
    Provider: null,
    Consumer: null,
  };

  context.Provider = {
    $$typeof: REACT_PROVIDER_TYPE,
    _context: context,
  };

  return context;
}

// Providerがレンダリングされるとき(render phase)
function updateContextProvider(workInProgress, context, newValue) {
  const oldValue = context._currentValue;

  // 値が変わったか Object.is() で確認
  if (Object.is(oldValue, newValue)) {
    // 変わっていない → 購読者の再レンダリングは不要
  } else {
    // 変わった! → 購読者を探してレンダリングをスケジュール
    propagateContextChange(workInProgress, context, renderLanes);
  }

  // スタックに新しい値をプッシュ
  pushProvider(workInProgress, context, newValue);
}

// コンポーネントがアンマウント(またはProviderの外に出る)とき
// スタックから値をポップする
function popProvider(context) {
  context._currentValue = previousValue; // 上位のProviderの値に戻す
}

ネストしたProviderの動作

// Providerはネストできる(スタック構造で管理)
function App() {
  return (
    <ThemeContext.Provider value="light">
      <div>
        {/* この中では theme = "light" */}
        <ThemeContext.Provider value="dark">
          {/* この中では theme = "dark" (上書き)*/}
          <InnerComponent />
        </ThemeContext.Provider>
        <OuterComponent /> {/* theme = "light" に戻る */}
      </div>
    </ThemeContext.Provider>
  );
}

// useContext は「最も近い上位のProvider」の値を取得する

デフォルト値が使われるケース

createContext(defaultValue)のデフォルト値は、 対応するProviderが存在しないときにのみ使われます。 <Provider value={undefined}>のように 明示的にundefinedを渡した場合は デフォルト値ではなくundefinedが使われます。

💻 コード例:value変更時の再レンダリング範囲

Contextの最も重要(そして誤解されやすい)特性: valueが変わると、そのContextを購読しているコンポーネントが全て再レンダリングされます。 中間コンポーネントがReact.memoでラップされていても無効化されます。

const UserContext = React.createContext(null);

function App() {
  const [user, setUser] = useState({ name: 'Alice', theme: 'dark' });

  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  );
}

// React.memoでラップしていても、Contextの変更は伝わる
const Layout = React.memo(function Layout() {
  console.log('Layout レンダリング'); // Contextと無関係なら呼ばれない
  return <Sidebar />;
});

const Sidebar = React.memo(function Sidebar() {
  console.log('Sidebar レンダリング'); // Contextと無関係なら呼ばれない
  return <UserDisplay />;
});

function UserDisplay() {
  const user = useContext(UserContext);
  console.log('UserDisplay レンダリング'); // userが変わると必ず呼ばれる
  return <div>{user.name}</div>;
}

// user.theme だけ変わったとき:
// - Layout: React.memoがpropsを確認 → 変わってない → スキップ ✓
// - Sidebar: 同様にスキップ ✓
// - UserDisplay: UserContextを購読しているので再レンダリング
//   (nameは同じなのにthemeが変わったので再レンダリングされてしまう!)

重要:React.memoはContextの再レンダリングを止められない

React.memopropsの変更による 再レンダリングを防ぎますが、Contextの変更による再レンダリングは防げません。 ContextをuseContextで購読している限り、 valueが変わると必ず再レンダリングが発生します。

🔧 仕組み分解:Object.isによるvalue比較

Providerのvalueもstateのupdateと同様にObject.is()で比較されます。 これがContextの「よくあるパフォーマンス問題」の原因になります。

// ❌ よくあるパフォーマンスの落とし穴
function App() {
  const [user, setUser] = useState({ name: 'Alice' });

  return (
    // レンダリングのたびに新しいオブジェクトが作られる!
    <UserContext.Provider value={{ user, setUser }}>
      {/* ↑ { user, setUser } は毎回新しい参照 */}
      {/* → Object.is() で比較すると常に「変わった」と判断 */}
      {/* → 全ての購読者が毎回再レンダリングされる! */}
      <App />
    </UserContext.Provider>
  );
}

// ✓ useMemoでvalueをキャッシュ
function App() {
  const [user, setUser] = useState({ name: 'Alice' });

  const contextValue = useMemo(
    () => ({ user, setUser }),
    [user] // userが変わったときだけ新しいオブジェクトを作る
  );

  return (
    <UserContext.Provider value={contextValue}>
      <Main />
    </UserContext.Provider>
  );
}

useContextの内部実装(概念)

// useContextの内部(概念的な実装)
function useContext(context) {
  const fiber = getCurrentFiber(); // 現在実行中のコンポーネント

  // このFiberがContextを購読していることを登録
  // (後でContextが変わったときに通知するため)
  readContext(fiber, context);

  // 現在のProvider valueを返す
  return context._currentValue;
}

// propagateContextChange(Context変更の伝播)
function propagateContextChange(workInProgress, context, renderLanes) {
  // Fiberツリーを走査して購読者を探す
  let fiber = workInProgress.child;

  while (fiber !== null) {
    // このFiberのdependenciesを確認
    const dependencies = fiber.dependencies;

    if (dependencies !== null) {
      // このFiberが変更されたContextを購読しているか確認
      let dependency = dependencies.firstContext;
      while (dependency !== null) {
        if (dependency.context === context) {
          // 購読している! → 再レンダリングをスケジュール
          scheduleContextWorkOnParentPath(fiber.return, renderLanes, workInProgress);
          break;
        }
        dependency = dependency.next;
      }
    }
    // 子・兄弟を再帰的に確認...
    fiber = fiber.child || fiber.sibling;
  }
}

⚡ パフォーマンス問題とContext splitting

1つのContextに多くのデータを詰め込むと、一部のデータしか使わないコンポーネントも 全体の変更のたびに再レンダリングされます。 Context splitting(分割)がその解決策です。

Context splittigの実装パターン

// ❌ 1つのContextに全部詰め込む
const AppContext = React.createContext(null);

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [locale, setLocale] = useState('ja');

  return (
    // userが変わるとtheme/localeを使うコンポーネントも再レンダリング!
    <AppContext.Provider value={{ user, setUser, theme, setTheme, locale, setLocale }}>
      <Main />
    </AppContext.Provider>
  );
}

// ✓ 変化の頻度や用途でContextを分割する
const UserContext = React.createContext(null);       // 頻繁に変わる
const ThemeContext = React.createContext('light');   // たまに変わる
const LocaleContext = React.createContext('ja');     // ほとんど変わらない

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [locale, setLocale] = useState('ja');

  return (
    <LocaleContext.Provider value={locale}>
      <ThemeContext.Provider value={theme}>
        <UserContext.Provider value={{ user, setUser }}>
          <Main />
        </UserContext.Provider>
      </ThemeContext.Provider>
    </LocaleContext.Provider>
  );
}

// userが変わっても、ThemeContextやLocaleContextの購読者は再レンダリングされない

読み取りと書き込みを分離するパターン

// より高度なパターン: 読み取りと更新を別のContextに分ける
const UserStateContext = React.createContext(null);   // 状態(頻繁に変わる)
const UserDispatchContext = React.createContext(null); // dispatch(変わらない)

function UserProvider({ children }) {
  const [user, dispatch] = useReducer(userReducer, null);

  return (
    <UserDispatchContext.Provider value={dispatch}>
      {/* dispatchは不変(useReducerが保証)なので再レンダリングが起きない */}
      <UserStateContext.Provider value={user}>
        {children}
      </UserStateContext.Provider>
    </UserDispatchContext.Provider>
  );
}

// 状態だけ必要なコンポーネント
function UserProfile() {
  const user = useContext(UserStateContext); // userが変わると再レンダリング
  return <div>{user?.name}</div>;
}

// dispatchだけ必要なコンポーネント
function LogoutButton() {
  const dispatch = useContext(UserDispatchContext); // 再レンダリングされない!
  return <button onClick={() => dispatch({ type: 'LOGOUT' })}>ログアウト</button>;
}

🔗 useContextとContext.Consumerの違い

// 古いAPI: Context.Consumer(render props)
function UserMenu() {
  return (
    <UserContext.Consumer>
      {user => (  {/* render prop パターン */}
        <div>{user?.name}</div>
      )}
    </UserContext.Consumer>
  );
}

// 現代的なAPI: useContext(Hooks)
function UserMenu() {
  const user = useContext(UserContext);
  return <div>{user?.name}</div>;
}

// どちらも同じContextを購読する
// useContextの方がシンプルで読みやすい

// 重要な違い:
// Context.Consumerは「このコンポーネントの中の特定部分だけ」を更新できた(レンダープロップ)
// useContextは「コンポーネント全体」を再レンダリングする

// ほとんどの場合はuseContextで十分
// Context.Consumerは後方互換性のためにRemain in React

useContextとZustand等との比較

Context API + useReducer
  • React組み込み(ライブラリ不要)
  • 低頻度の更新(テーマ、ロケール、認証情報)に適している
  • 頻繁な更新は注意が必要(全購読者が再レンダリング)
  • Context splittingで対応可能だが設計が複雑になる
Zustand / Jotai / Valtio(外部ライブラリ)
  • セレクター(部分購読)が使える
  • 「このフィールドだけ変わったとき」のみ再レンダリング
  • 高頻度な更新や複雑な状態管理に向いている
  • 追加ライブラリが必要

使い分けの目安

テーマ・ロケール・認証情報のような「アプリ全体で使うが変化頻度が低い」データはContext APIが適しています。 ユーザーの操作に伴って頻繁に変わるデータ(カートの中身・フォームの状態など)は、 ZustandやJotaiなどのセレクターを持つライブラリの方が再レンダリングを効率的に制御できます。

📌 まとめ

  • ✓ Contextはprop drillingを解決するための「ツリーをまたいだ値の配布機構」
  • ✓ Providerのvalueが変わると、そのContextを購読している全コンポーネントが再レンダリング
  • ✓ value比較はObject.is()で行われる——毎回新しいオブジェクトを渡すと無限に再レンダリング
  • ✓ React.memoはContextの再レンダリングを防げない(props変更のみ防ぐ)
  • ✓ useMemoでContextのvalueをキャッシュすることで不要な再レンダリングを防ぐ
  • ✓ Context splittingで変化頻度が異なるデータを分けると再レンダリング範囲を絞れる
  • ✓ 読み取り/書き込みContextの分離パターンで、dispatchだけ使うコンポーネントを最適化できる

関連記事