Reactは状態管理のために2つの主要なフックを提供しています:useStateとuseReducer。useStateの方が一般的ですが、useReducerは複雑な状態ロジックに対して利点があります。それぞれをいつ使うべきか探っていきましょう。
簡単な比較
// useState: シンプルで直接的な状態更新
const [count, setCount] = useState(0);
setCount(count + 1);
// useReducer: アクションベースの状態更新
const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: 'INCREMENT' });
flowchart LR
subgraph useState
A[コンポーネント] -->|setCount| B[新しい状態]
end
subgraph useReducer
C[コンポーネント] -->|アクションをdispatch| D[Reducer]
D -->|返す| E[新しい状態]
end
style D fill:#10b981,color:#fff
useStateを使うべき時
useStateは以下に最適です:
1. シンプルで独立した値
function Form() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
<input type="number" value={age} onChange={e => setAge(Number(e.target.value))} />
</form>
);
}
2. ブールトグル
function Modal() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>開く</button>
{isOpen && <Dialog onClose={() => setIsOpen(false)} />}
</>
);
}
3. シンプルなカウンター
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}回クリック
</button>
);
}
useReducerを使うべき時
useReducerが輝くのは:
1. 複数の関連する状態値
状態値が互いに依存する場合:
// useStateの場合:同期が取れなくなりやすい
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [selectedId, setSelectedId] = useState(null);
// 問題:関連するすべての状態を更新することを覚えておく必要がある
const addItem = (item) => {
setItems([...items, item]);
setTotal(total + item.price); // 忘れやすい!
};
// useReducerの場合:状態変更が調整される
const initialState = { items: [], total: 0, selectedId: null };
function reducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.item],
total: state.total + action.item.price,
};
case 'SELECT_ITEM':
return { ...state, selectedId: action.id };
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, initialState);
2. 複雑な状態遷移
次の状態が前の状態に複雑な方法で依存する場合:
function reducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, data: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.error };
case 'RESET':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function DataFetcher({ url }) {
const [state, dispatch] = useReducer(reducer, {
data: null,
loading: false,
error: null,
});
useEffect(() => {
dispatch({ type: 'FETCH_START' });
fetch(url)
.then(res => res.json())
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(error => dispatch({ type: 'FETCH_ERROR', error }));
}, [url]);
// ...
}
3. 状態マシン / 有限状態
状態遷移が厳格なルールに従う場合:
const initialState = { status: 'idle' }; // idle | loading | success | error
function reducer(state, action) {
switch (state.status) {
case 'idle':
if (action.type === 'FETCH') {
return { status: 'loading' };
}
return state;
case 'loading':
if (action.type === 'SUCCESS') {
return { status: 'success', data: action.data };
}
if (action.type === 'ERROR') {
return { status: 'error', error: action.error };
}
return state;
case 'success':
case 'error':
if (action.type === 'RESET') {
return { status: 'idle' };
}
return state;
default:
return state;
}
}
4. 簡単なテスト
Reducerは純粋関数なので、テストが簡単です:
// reducer.test.js
test('ADD_ITEMはアイテムを追加し合計を更新する', () => {
const state = { items: [], total: 0 };
const action = { type: 'ADD_ITEM', item: { id: 1, price: 10 } };
const newState = reducer(state, action);
expect(newState.items).toHaveLength(1);
expect(newState.total).toBe(10);
});
test('未知のアクションは現在の状態を返す', () => {
const state = { items: [], total: 0 };
const newState = reducer(state, { type: 'UNKNOWN' });
expect(newState).toBe(state);
});
5. 子にdispatchを渡す
dispatchはsetter関数とは異なり、安定したアイデンティティを持ちます:
function Parent() {
const [state, dispatch] = useReducer(reducer, initialState);
// dispatchは変わらないので、Childは不必要に再レンダリングされない
return <Child dispatch={dispatch} />;
}
// vs useStateではuseCallbackが必要
function Parent() {
const [state, setState] = useState(initialState);
// 子の再レンダリングを防ぐためにメモ化が必要
const handleAction = useCallback((action) => {
setState(prev => /* 複雑なロジック */);
}, []);
return <Child onAction={handleAction} />;
}
判断ガイド
flowchart TD
A{状態の質問} --> B{単一の値?}
B -->|はい| C{シンプルな更新?}
C -->|はい| D[useState]
C -->|いいえ| E[useReducerを検討]
B -->|いいえ| F{値が関連している?}
F -->|はい| G[useReducer]
F -->|いいえ| H[複数のuseState]
style D fill:#3b82f6,color:#fff
style G fill:#10b981,color:#fff
style H fill:#3b82f6,color:#fff
useStateを使う場合:
- 単一のプリミティブ値を管理
- 状態更新がシンプル(新しい値に設定)
- 状態値が互いに独立
- 最小限のボイラープレートが欲しい
useReducerを使う場合:
- 一緒に更新される複数の状態値
- 次の状態が前の状態に複雑に依存
- 予測可能な状態遷移が欲しい
- 更新ロジックを子に渡す必要がある
- テストを簡単にしたい
実践例:ショッピングカート
useStateの場合(乱雑になる)
function ShoppingCart() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [shipping, setShipping] = useState(0);
const addItem = (item) => {
setItems([...items, item]);
setTotal(total + item.price);
// 合計に基づいて配送料を更新するのを忘れた!
};
const removeItem = (id) => {
const item = items.find(i => i.id === id);
setItems(items.filter(i => i.id !== id));
setTotal(total - item.price);
// 割引も再計算する必要がある...
};
const applyDiscount = (code) => {
// 合計とアイテムを含む複雑なロジック...
};
}
useReducerの場合(クリーンで予測可能)
const initialState = {
items: [],
total: 0,
discount: 0,
shipping: 0,
};
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const newItems = [...state.items, action.item];
const newTotal = state.total + action.item.price;
return {
...state,
items: newItems,
total: newTotal,
shipping: calculateShipping(newTotal),
};
}
case 'REMOVE_ITEM': {
const item = state.items.find(i => i.id === action.id);
const newItems = state.items.filter(i => i.id !== action.id);
const newTotal = state.total - item.price;
return {
...state,
items: newItems,
total: newTotal,
discount: recalculateDiscount(newItems, state.discount),
shipping: calculateShipping(newTotal),
};
}
case 'APPLY_DISCOUNT':
return {
...state,
discount: calculateDiscount(action.code, state.items, state.total),
};
case 'CLEAR_CART':
return initialState;
default:
return state;
}
}
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<div>
{state.items.map(item => (
<CartItem
key={item.id}
item={item}
onRemove={() => dispatch({ type: 'REMOVE_ITEM', id: item.id })}
/>
))}
<CartSummary
total={state.total}
discount={state.discount}
shipping={state.shipping}
/>
</div>
);
}
TypeScriptでの使用
TypeScriptは型安全性でreducerをさらに良くします:
type State = {
count: number;
step: number;
};
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_STEP'; payload: number }
| { type: 'RESET' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + state.step };
case 'DECREMENT':
return { ...state, count: state.count - state.step };
case 'SET_STEP':
return { ...state, step: action.payload };
case 'RESET':
return { count: 0, step: 1 };
}
}
まとめ
| 基準 | useState | useReducer |
|---|---|---|
| シンプルな値 | ✓ | |
| 関連する状態値 | ✓ | |
| 複雑な遷移 | ✓ | |
| テスト | 難しい | 簡単 |
| ボイラープレート | 少ない | 多い |
| 安定性 | useCallbackが必要 | dispatchは安定 |
両方のフックにはそれぞれの場所があります。シンプルさのためにuseStateから始め、状態ロジックが複雑になったりエラーが発生しやすくなったりしたらuseReducerにリファクタリングしましょう。目標は理解しやすく保守しやすいコードです。
参考文献
- React Documentation: useReducer
- React Documentation: Extracting State Logic into a Reducer
- Kumar, Tejas. Fluent React. O'Reilly Media, 2024.