Skip to content

第7章 Context

コンポーネントツリーを超えたデータ共有

前章では、関数コンポーネントで状態管理や副作用を扱うための「Hooks」を実装しました。この章では、コンポーネントツリーを超えたデータ共有を実現する「Context」を実装していきます。

Contextとは何か

Contextは、Reactコンポーネントツリーを通じてデータを明示的にpropsとして渡さずに、コンポーネント間でデータを共有するための仕組みです。これにより、「props drilling」(多くの中間コンポーネントを経由してデータを渡す)の問題を解決できます。

Contextの基本概念

Contextシステムは主に以下の3つの要素で構成されています:

  1. Context オブジェクト: createContext関数で作成される、データを保持するオブジェクト
  2. Provider: Contextの値を子孫コンポーネントに提供するコンポーネント
  3. Consumer: Contextの値を利用するコンポーネント(useContextフックまたはContext.Consumer

Contextの実装

1. 型定義

まず、Contextに必要な型を定義します:

typescript
// context/types.ts

// Contextの型
export interface Context<T> {
  Provider: Provider<T>;
  Consumer: Consumer<T>;
  _currentValue: T;
  _defaultValue: T;
}

// Providerの型
export interface Provider<T> {
  (props: { value: T; children: any }): any;
}

// Consumerの型
export interface Consumer<T> {
  (props: { children: (value: T) => any }): any;
}

2. createContextの実装

次に、Contextオブジェクトを作成するcreateContext関数を実装します:

typescript
// context/createContext.ts

import { createElement } from '../createElement';
import { Context, Provider, Consumer } from './types';

// Contextオブジェクトを作成する関数
export function createContext<T>(defaultValue: T): Context<T> {
  // Contextオブジェクト
  const context: Context<T> = {
    Provider: null as unknown as Provider<T>,
    Consumer: null as unknown as Consumer<T>,
    _currentValue: defaultValue,
    _defaultValue: defaultValue
  };
  
  // Providerの実装
  context.Provider = (props) => {
    // 現在の値を更新
    context._currentValue = props.value !== undefined ? props.value : context._defaultValue;
    
    // 子要素を返す
    return props.children;
  };
  
  // Consumerの実装
  context.Consumer = (props) => {
    // 子要素が関数であることを確認
    if (typeof props.children !== 'function') {
      throw new Error('Context.Consumer expects a function as a child');
    }
    
    // 現在の値を子関数に渡す
    return props.children(context._currentValue);
  };
  
  return context;
}

3. useContextの実装

次に、関数コンポーネント内でContextを利用するためのuseContextフックを実装します:

typescript
// context/useContext.ts

import { Context } from './types';

// useContext Hook
export function useContext<T>(context: Context<T>): T {
  // 現在のContextの値を返す
  return context._currentValue;
}

4. Fiber統合

Contextを正しく動作させるためには、Fiberシステムと統合する必要があります。以下は、Fiberシステムに追加する変更の概要です:

typescript
// fiber/types.ts(拡張)

import { Context } from '../context/types';

// Fiberの型を拡張
export interface Fiber {
  // ... 既存のプロパティ
  
  // Context関連
  contextDependencies: Set<Context<any>> | null;
}

// fiber/core.ts(拡張)

// コンテキスト依存関係を追跡
export const trackContextDependencies = (fiber: Fiber, context: Context<any>): void => {
  if (!fiber.contextDependencies) {
    fiber.contextDependencies = new Set();
  }
  
  fiber.contextDependencies.add(context);
};

// コンテキスト変更を検出
export const checkContextChanged = (fiber: Fiber): boolean => {
  if (!fiber.contextDependencies || fiber.contextDependencies.size === 0) {
    return false;
  }
  
  // 依存するコンテキストが変更されたかチェック
  for (const context of fiber.contextDependencies) {
    const oldValue = fiber.alternate?.memoizedState?.contextValues?.[context];
    if (oldValue !== context._currentValue) {
      return true;
    }
  }
  
  return false;
};

テストの作成

テスト駆動開発のアプローチに従い、Contextのテストを作成します:

typescript
import { createElement } from '../src/createElement';
import { createFiberRoot } from '../src/fiber/core';
import { scheduleWork } from '../src/fiber/update';
import { createContext } from '../src/context/createContext';
import { useContext } from '../src/context/useContext';
import { useState } from '../src/hooks/useState';

describe('context', () => {
  beforeEach(() => {
    // テスト用のDOM環境をリセット
    document.body.innerHTML = '';
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('createContext と Provider が正しく動作すること', () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    
    // テーマコンテキストの作成
    const ThemeContext = createContext('light');
    
    // コンシューマーコンポーネント
    const ThemedButton = () => {
      const theme = useContext(ThemeContext);
      return createElement('button', { className: theme }, `Theme: ${theme}`);
    };
    
    // プロバイダーコンポーネント
    const App = () => {
      return createElement(
        ThemeContext.Provider,
        { value: 'dark' },
        createElement(ThemedButton)
      );
    };
    
    // レンダリング
    const fiberRoot = createFiberRoot(container);
    scheduleWork(fiberRoot, createElement(App));
    jest.runAllTimers();
    
    // コンテキスト値が正しく伝播していることを確認
    const button = container.querySelector('button');
    expect(button?.className).toBe('dark');
    expect(button?.textContent).toBe('Theme: dark');
  });

  it('デフォルト値が正しく使用されること', () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    
    // デフォルト値を持つコンテキスト
    const UserContext = createContext({ name: 'Guest', role: 'visitor' });
    
    // コンシューマーコンポーネント
    const UserInfo = () => {
      const user = useContext(UserContext);
      return createElement('div', null, `${user.name} (${user.role})`);
    };
    
    // プロバイダーなしで使用
    const fiberRoot = createFiberRoot(container);
    scheduleWork(fiberRoot, createElement(UserInfo));
    jest.runAllTimers();
    
    // デフォルト値が使用されていることを確認
    expect(container.textContent).toBe('Guest (visitor)');
  });

  it('ネストしたコンテキストが正しく動作すること', () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    
    // 複数のコンテキスト
    const ThemeContext = createContext('light');
    const LanguageContext = createContext('en');
    
    // 複数のコンテキストを消費するコンポーネント
    const LocalizedButton = () => {
      const theme = useContext(ThemeContext);
      const lang = useContext(LanguageContext);
      
      const labels = {
        en: { light: 'Light Mode', dark: 'Dark Mode' },
        ja: { light: 'ライトモード', dark: 'ダークモード' }
      };
      
      return createElement(
        'button',
        { className: theme },
        labels[lang][theme]
      );
    };
    
    // ネストしたプロバイダー
    const App = () => {
      return createElement(
        ThemeContext.Provider,
        { value: 'dark' },
        createElement(
          LanguageContext.Provider,
          { value: 'ja' },
          createElement(LocalizedButton)
        )
      );
    };
    
    // レンダリング
    const fiberRoot = createFiberRoot(container);
    scheduleWork(fiberRoot, createElement(App));
    jest.runAllTimers();
    
    // 両方のコンテキスト値が正しく伝播していることを確認
    const button = container.querySelector('button');
    expect(button?.className).toBe('dark');
    expect(button?.textContent).toBe('ダークモード');
  });

  it('コンテキスト値の更新が正しく伝播すること', () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    
    // テーマコンテキスト
    const ThemeContext = createContext('light');
    
    // コンシューマーコンポーネント
    const ThemedButton = () => {
      const theme = useContext(ThemeContext);
      return createElement('button', { id: 'themed-button', className: theme }, `Theme: ${theme}`);
    };
    
    // 状態を持つプロバイダーコンポーネント
    const ThemeProvider = () => {
      const [theme, setTheme] = useState('light');
      
      return createElement(
        'div',
        null,
        createElement(
          ThemeContext.Provider,
          { value: theme },
          createElement(ThemedButton)
        ),
        createElement(
          'button',
          { 
            id: 'toggle-theme',
            onClick: () => setTheme(theme === 'light' ? 'dark' : 'light')
          },
          'Toggle Theme'
        )
      );
    };
    
    // レンダリング
    const fiberRoot = createFiberRoot(container);
    scheduleWork(fiberRoot, createElement(ThemeProvider));
    jest.runAllTimers();
    
    // 初期状態の確認
    const themedButton = container.querySelector('#themed-button');
    expect(themedButton?.className).toBe('light');
    
    // テーマを切り替え
    const toggleButton = container.querySelector('#toggle-theme') as HTMLButtonElement;
    toggleButton.click();
    jest.runAllTimers();
    
    // 更新後の状態を確認
    expect(themedButton?.className).toBe('dark');
    expect(themedButton?.textContent).toBe('Theme: dark');
  });
});

Contextの動作原理

Contextがどのように動作するかを図示します:

実際の使用例

Contextを使用して、テーマ切り替え機能を持つアプリケーションを実装してみましょう:

typescript
import { createElement } from './createElement';
import { createFiberRoot } from './fiber/core';
import { scheduleWork } from './fiber/update';
import { createContext } from './context/createContext';
import { useContext } from './context/useContext';
import { useState } from './hooks/useState';

// テーマの型定義
interface Theme {
  name: string;
  colors: {
    background: string;
    text: string;
    primary: string;
  };
}

// テーマの定義
const themes: Record<string, Theme> = {
  light: {
    name: 'light',
    colors: {
      background: '#ffffff',
      text: '#333333',
      primary: '#0066cc'
    }
  },
  dark: {
    name: 'dark',
    colors: {
      background: '#333333',
      text: '#ffffff',
      primary: '#66aaff'
    }
  }
};

// テーマコンテキストの作成
const ThemeContext = createContext<Theme>(themes.light);

// テーマプロバイダーコンポーネント
const ThemeProvider = ({ children }: { children: any }) => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  
  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };
  
  // テーマ切り替えボタンとコンテキストプロバイダー
  return createElement(
    ThemeContext.Provider,
    { value: themes[theme] },
    createElement(
      'div',
      null,
      createElement(
        'button',
        { onClick: toggleTheme },
        `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`
      ),
      children
    )
  );
};

// テーマを使用するボタンコンポーネント
const ThemedButton = ({ children }: { children: any }) => {
  const theme = useContext(ThemeContext);
  
  const style = {
    backgroundColor: theme.colors.primary,
    color: '#ffffff',
    padding: '10px 20px',
    border: 'none',
    borderRadius: '4px',
    cursor: 'pointer'
  };
  
  return createElement('button', { style }, children);
};

// テーマを使用するテキストコンポーネント
const ThemedText = ({ children }: { children: any }) => {
  const theme = useContext(ThemeContext);
  
  const style = {
    color: theme.colors.text,
    fontFamily: 'Arial, sans-serif'
  };
  
  return createElement('p', { style }, children);
};

// アプリケーションコンポーネント
const App = () => {
  return createElement(
    ThemeProvider,
    null,
    createElement(
      'div',
      { 
        style: {
          padding: '20px',
          backgroundColor: useContext(ThemeContext).colors.background,
          minHeight: '100vh'
        }
      },
      createElement('h1', null, 'Context Demo'),
      createElement(ThemedText, null, 'This text uses the current theme.'),
      createElement(ThemedButton, null, 'Themed Button')
    )
  );
};

// アプリケーションのレンダリング
const container = document.getElementById('root');
if (container) {
  const fiberRoot = createFiberRoot(container);
  scheduleWork(fiberRoot, createElement(App));
}

次のステップ

Contextの実装により、コンポーネントツリーを超えたデータ共有が可能になりました。次の章では、コンポーネントでエラーが発生した場合にフォールバックUIを表示する「Error Boundary」を実装していきます。

Released under the MIT License.