第7章 Context
コンポーネントツリーを超えたデータ共有
前章では、関数コンポーネントで状態管理や副作用を扱うための「Hooks」を実装しました。この章では、コンポーネントツリーを超えたデータ共有を実現する「Context」を実装していきます。
Contextとは何か
Contextは、Reactコンポーネントツリーを通じてデータを明示的にpropsとして渡さずに、コンポーネント間でデータを共有するための仕組みです。これにより、「props drilling」(多くの中間コンポーネントを経由してデータを渡す)の問題を解決できます。
Contextの基本概念
Contextシステムは主に以下の3つの要素で構成されています:
- Context オブジェクト:
createContext関数で作成される、データを保持するオブジェクト - Provider: Contextの値を子孫コンポーネントに提供するコンポーネント
- 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」を実装していきます。