Day 8: Context APIと状態管理
今日学ぶこと
- Propsドリリングの問題
- Context APIの基本
- useContextフック
- Contextの設計パターン
- useReducerとの組み合わせ
Propsドリリングの問題
深くネストしたコンポーネントにデータを渡すとき、中間のコンポーネントを経由する必要があります。これをPropsドリリングと呼びます。
flowchart TB
subgraph Drilling["Propsドリリング"]
App["App<br/>user={user}"]
Layout["Layout<br/>user={user}"]
Main["Main<br/>user={user}"]
Sidebar["Sidebar<br/>user={user}"]
UserProfile["UserProfile<br/>user={user}を使用"]
end
App --> Layout --> Main --> Sidebar --> UserProfile
style App fill:#ef4444,color:#fff
style Layout fill:#f59e0b,color:#fff
style Main fill:#f59e0b,color:#fff
style Sidebar fill:#f59e0b,color:#fff
style UserProfile fill:#22c55e,color:#fff
問題点
// ❌ Propsドリリング: 中間コンポーネントがuserを使わないのに渡している
function App() {
const [user, setUser] = useState({ name: '太郎', role: 'admin' });
return <Layout user={user} />;
}
function Layout({ user }) {
return (
<div>
<Header />
<Main user={user} /> {/* 渡すだけ */}
<Footer />
</div>
);
}
function Main({ user }) {
return <Sidebar user={user} />; {/* 渡すだけ */}
}
function Sidebar({ user }) {
return <UserProfile user={user} />; {/* 渡すだけ */}
}
function UserProfile({ user }) {
return <p>ようこそ、{user.name}さん</p>; {/* 実際に使用 */}
}
TypeScript版
interface User {
name: string;
role: string;
}
interface UserProps {
user: User;
}
// ❌ Propsドリリング: 中間コンポーネントがuserを使わないのに渡している
function App(): React.JSX.Element {
const [user, setUser] = useState<User>({ name: '太郎', role: 'admin' });
return <Layout user={user} />;
}
function Layout({ user }: UserProps): React.JSX.Element {
return (
<div>
<Header />
<Main user={user} /> {/* 渡すだけ */}
<Footer />
</div>
);
}
function Main({ user }: UserProps): React.JSX.Element {
return <Sidebar user={user} />; {/* 渡すだけ */}
}
function Sidebar({ user }: UserProps): React.JSX.Element {
return <UserProfile user={user} />; {/* 渡すだけ */}
}
function UserProfile({ user }: UserProps): React.JSX.Element {
return <p>ようこそ、{user.name}さん</p>; {/* 実際に使用 */}
}
Context APIとは
Context APIは、コンポーネントツリー全体にデータを「トンネル」で渡す仕組みです。
flowchart TB
subgraph Context["Context API"]
Provider["Provider<br/>value={user}"]
App["App"]
Layout["Layout"]
Main["Main"]
Consumer["UserProfile<br/>useContext(UserContext)"]
end
Provider -.->|"Context"| Consumer
Provider --> App --> Layout --> Main --> Consumer
style Provider fill:#3b82f6,color:#fff
style Consumer fill:#22c55e,color:#fff
Contextの基本的な使い方
Step 1: Contextの作成
import { createContext } from 'react';
// デフォルト値を指定してContextを作成
const UserContext = createContext(null);
export default UserContext;
Step 2: Providerでラップ
import { useState } from 'react';
import UserContext from './UserContext';
function App() {
const [user, setUser] = useState({ name: '太郎', role: 'admin' });
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
Step 3: useContextで使用
import { useContext } from 'react';
import UserContext from './UserContext';
function UserProfile() {
const user = useContext(UserContext);
return <p>ようこそ、{user.name}さん</p>;
}
完成したコード
import { createContext, useContext, useState } from 'react';
// Context作成
const UserContext = createContext(null);
// 最上位コンポーネント
function App() {
const [user, setUser] = useState({ name: '太郎', role: 'admin' });
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
// 中間コンポーネント(userを意識しない)
function Layout() {
return (
<div>
<Header />
<Main />
<Footer />
</div>
);
}
function Main() {
return <Sidebar />;
}
function Sidebar() {
return <UserProfile />;
}
// Contextを使用するコンポーネント
function UserProfile() {
const user = useContext(UserContext);
return <p>ようこそ、{user.name}さん</p>;
}
TypeScript版
import { createContext, useContext, useState } from 'react';
interface User {
name: string;
role: string;
}
// Context作成
const UserContext = createContext<User | null>(null);
// 最上位コンポーネント
function App(): React.JSX.Element {
const [user, setUser] = useState<User>({ name: '太郎', role: 'admin' });
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
// 中間コンポーネント(userを意識しない)
function Layout(): React.JSX.Element {
return (
<div>
<Header />
<Main />
<Footer />
</div>
);
}
function Main(): React.JSX.Element {
return <Sidebar />;
}
function Sidebar(): React.JSX.Element {
return <UserProfile />;
}
// Contextを使用するコンポーネント
function UserProfile(): React.JSX.Element {
const user = useContext(UserContext);
return <p>ようこそ、{user?.name}さん</p>;
}
更新可能なContext
Stateと更新関数をContextで提供します。
import { createContext, useContext, useState } from 'react';
// Context作成
const ThemeContext = createContext(null);
// Providerコンポーネント
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
function toggleTheme() {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}
const value = {
theme,
toggleTheme
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// カスタムフック
function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// 使用例
function App() {
return (
<ThemeProvider>
<Header />
<Main />
</ThemeProvider>
);
}
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header style={{ background: theme === 'light' ? '#fff' : '#333' }}>
<button onClick={toggleTheme}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
</header>
);
}
TypeScript版
import { createContext, useContext, useState, ReactNode } from 'react';
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// Context作成
const ThemeContext = createContext<ThemeContextType | null>(null);
// Providerコンポーネント
interface ThemeProviderProps {
children: ReactNode;
}
function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Element {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
function toggleTheme(): void {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}
const value: ThemeContextType = {
theme,
toggleTheme
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// カスタムフック
function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// 使用例
function App(): React.JSX.Element {
return (
<ThemeProvider>
<Header />
<Main />
</ThemeProvider>
);
}
function Header(): React.JSX.Element {
const { theme, toggleTheme } = useTheme();
return (
<header style={{ background: theme === 'light' ? '#fff' : '#333' }}>
<button onClick={toggleTheme}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
</header>
);
}
複数のContextを組み合わせる
// 認証Context
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = (userData) => setUser(userData);
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// テーマContext
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 複数のProviderを組み合わせる
function App() {
return (
<AuthProvider>
<ThemeProvider>
<MainApp />
</ThemeProvider>
</AuthProvider>
);
}
TypeScript版
import { createContext, useState, ReactNode } from 'react';
// 認証Context
interface User {
name: string;
role: string;
}
interface AuthContextType {
user: User | null;
login: (userData: User) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [user, setUser] = useState<User | null>(null);
const login = (userData: User): void => setUser(userData);
const logout = (): void => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// テーマContext
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
function ThemeProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = (): void => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 複数のProviderを組み合わせる
function App(): React.JSX.Element {
return (
<AuthProvider>
<ThemeProvider>
<MainApp />
</ThemeProvider>
</AuthProvider>
);
}
flowchart TB
subgraph Providers["複数のProvider"]
Auth["AuthProvider"]
Theme["ThemeProvider"]
App["App Components"]
end
Auth --> Theme --> App
style Auth fill:#3b82f6,color:#fff
style Theme fill:#8b5cf6,color:#fff
useReducerとの組み合わせ
複雑な状態管理にはuseReducerを使用します。
useReducerの基本
import { useReducer } from 'react';
// 初期状態
const initialState = { count: 0 };
// Reducer関数
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
TypeScript版
import { useReducer } from 'react';
interface CounterState {
count: number;
}
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' };
// 初期状態
const initialState: CounterState = { count: 0 };
// Reducer関数
function reducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return initialState;
default:
throw new Error(`Unknown action: ${(action as { type: string }).type}`);
}
}
function Counter(): React.JSX.Element {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
ContextとuseReducerの組み合わせ
import { createContext, useContext, useReducer } from 'react';
// Todoの型定義(コメントで説明)
// { id: number, text: string, completed: boolean }
// 初期状態
const initialState = {
todos: [],
filter: 'all' // 'all' | 'active' | 'completed'
};
// Reducer
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{ id: Date.now(), text: action.payload, completed: false }
]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
case 'SET_FILTER':
return {
...state,
filter: action.payload
};
default:
return state;
}
}
// Context
const TodoContext = createContext(null);
// Provider
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
// フィルタリングされたTodo
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true;
});
const value = {
todos: filteredTodos,
allTodos: state.todos,
filter: state.filter,
dispatch
};
return (
<TodoContext.Provider value={value}>
{children}
</TodoContext.Provider>
);
}
// カスタムフック
function useTodo() {
const context = useContext(TodoContext);
if (!context) {
throw new Error('useTodo must be used within TodoProvider');
}
return context;
}
// コンポーネント
function TodoApp() {
return (
<TodoProvider>
<h1>Todo App</h1>
<AddTodo />
<FilterButtons />
<TodoList />
<TodoStats />
</TodoProvider>
);
}
function AddTodo() {
const { dispatch } = useTodo();
const [text, setText] = useState('');
function handleSubmit(e) {
e.preventDefault();
if (text.trim()) {
dispatch({ type: 'ADD_TODO', payload: text });
setText('');
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="新しいタスク"
/>
<button type="submit">追加</button>
</form>
);
}
function FilterButtons() {
const { filter, dispatch } = useTodo();
return (
<div>
{['all', 'active', 'completed'].map(f => (
<button
key={f}
onClick={() => dispatch({ type: 'SET_FILTER', payload: f })}
style={{ fontWeight: filter === f ? 'bold' : 'normal' }}
>
{f}
</button>
))}
</div>
);
}
function TodoList() {
const { todos, dispatch } = useTodo();
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}>
削除
</button>
</li>
))}
</ul>
);
}
function TodoStats() {
const { allTodos } = useTodo();
const completed = allTodos.filter(t => t.completed).length;
return (
<p>
完了: {completed} / {allTodos.length}
</p>
);
}
TypeScript版
import { createContext, useContext, useReducer, useState, ReactNode, Dispatch } from 'react';
// 型定義
interface Todo {
id: number;
text: string;
completed: boolean;
}
type Filter = 'all' | 'active' | 'completed';
interface TodoState {
todos: Todo[];
filter: Filter;
}
type TodoAction =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'DELETE_TODO'; payload: number }
| { type: 'SET_FILTER'; payload: Filter };
// 初期状態
const initialState: TodoState = {
todos: [],
filter: 'all'
};
// Reducer
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{ id: Date.now(), text: action.payload, completed: false }
]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
case 'SET_FILTER':
return {
...state,
filter: action.payload
};
default:
return state;
}
}
// Context
interface TodoContextType {
todos: Todo[];
allTodos: Todo[];
filter: Filter;
dispatch: Dispatch<TodoAction>;
}
const TodoContext = createContext<TodoContextType | null>(null);
// Provider
function TodoProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [state, dispatch] = useReducer(todoReducer, initialState);
// フィルタリングされたTodo
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true;
});
const value: TodoContextType = {
todos: filteredTodos,
allTodos: state.todos,
filter: state.filter,
dispatch
};
return (
<TodoContext.Provider value={value}>
{children}
</TodoContext.Provider>
);
}
// カスタムフック
function useTodo(): TodoContextType {
const context = useContext(TodoContext);
if (!context) {
throw new Error('useTodo must be used within TodoProvider');
}
return context;
}
// コンポーネント
function TodoApp(): React.JSX.Element {
return (
<TodoProvider>
<h1>Todo App</h1>
<AddTodo />
<FilterButtons />
<TodoList />
<TodoStats />
</TodoProvider>
);
}
function AddTodo(): React.JSX.Element {
const { dispatch } = useTodo();
const [text, setText] = useState<string>('');
function handleSubmit(e: React.FormEvent<HTMLFormElement>): void {
e.preventDefault();
if (text.trim()) {
dispatch({ type: 'ADD_TODO', payload: text });
setText('');
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setText(e.target.value)}
placeholder="新しいタスク"
/>
<button type="submit">追加</button>
</form>
);
}
function FilterButtons(): React.JSX.Element {
const { filter, dispatch } = useTodo();
const filters: Filter[] = ['all', 'active', 'completed'];
return (
<div>
{filters.map(f => (
<button
key={f}
onClick={() => dispatch({ type: 'SET_FILTER', payload: f })}
style={{ fontWeight: filter === f ? 'bold' : 'normal' }}
>
{f}
</button>
))}
</div>
);
}
function TodoList(): React.JSX.Element {
const { todos, dispatch } = useTodo();
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}>
削除
</button>
</li>
))}
</ul>
);
}
function TodoStats(): React.JSX.Element {
const { allTodos } = useTodo();
const completed = allTodos.filter(t => t.completed).length;
return (
<p>
完了: {completed} / {allTodos.length}
</p>
);
}
Contextのベストプラクティス
適切な分割
// ❌ 1つのContextに詰め込みすぎ
const AppContext = createContext({
user: null,
theme: 'light',
language: 'ja',
notifications: [],
cart: [],
// ...
});
// ✅ 関心事ごとに分割
const AuthContext = createContext(null);
const ThemeContext = createContext(null);
const LanguageContext = createContext(null);
const NotificationContext = createContext(null);
const CartContext = createContext(null);
パフォーマンスの考慮
// ❌ オブジェクトを毎回作成(不必要な再レンダリング)
function BadProvider({ children }) {
const [count, setCount] = useState(0);
return (
<MyContext.Provider value={{ count, setCount }}>
{children}
</MyContext.Provider>
);
}
// ✅ useMemoでメモ化
function GoodProvider({ children }) {
const [count, setCount] = useState(0);
const value = useMemo(() => ({ count, setCount }), [count]);
return (
<MyContext.Provider value={value}>
{children}
</MyContext.Provider>
);
}
使い分けの指針
| シナリオ | 推奨アプローチ |
|---|---|
| 2-3階層のProps渡し | Propsで十分 |
| テーマ、認証、言語設定 | Context |
| グローバルな状態管理 | Context + useReducer |
| 複雑なアプリ全体の状態 | 外部ライブラリ検討 |
flowchart TB
A["状態管理の選択"] --> B{"階層は深い?"}
B -->|No| C["Propsで渡す"]
B -->|Yes| D{"状態は複雑?"}
D -->|No| E["Context + useState"]
D -->|Yes| F["Context + useReducer"]
style C fill:#22c55e,color:#fff
style E fill:#3b82f6,color:#fff
style F fill:#8b5cf6,color:#fff
まとめ
| 概念 | 説明 |
|---|---|
| Propsドリリング | 深い階層へのProps渡しの問題 |
| Context | コンポーネントツリー全体にデータを共有 |
| Provider | Contextの値を提供するコンポーネント |
| useContext | Contextの値を取得するフック |
| useReducer | 複雑な状態更新ロジックを管理 |
重要ポイント
- Contextはグローバルな状態に適している
- カスタムフックでContext使用をカプセル化
- 関心事ごとにContextを分割
- 複雑な状態にはuseReducerを組み合わせる
- パフォーマンスにはuseMemoを活用
練習問題
問題1: 基本
言語設定(日本語/英語)を管理するContextを作成してください。ボタンで言語を切り替え、表示テキストが変わるようにしてください。
問題2: 応用
ショッピングカートのContextを作成してください:
- 商品の追加/削除
- 数量の変更
- 合計金額の計算
チャレンジ問題
認証システムのContext(useReducer使用)を作成してください:
- ログイン/ログアウト機能
- ローディング状態の管理
- エラーメッセージの管理
- 認証状態に基づくルーティング
参考リンク
次回予告: Day 9では「パフォーマンス最適化」について学びます。Reactアプリを高速に保つためのテクニックを理解しましょう。