useReducer
この章では、複雑な状態ロジックを管理するためのuseReducerフックについて学びます。
この章で学ぶこと
- useReducerの基本構文と使い方
- useStateとの使い分け
- Reducerの設計原則(純粋関数、不変更新)
- useContextとの組み合わせ
この章では03章で作成したプロジェクトを使用します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。
useReducerとは
useReducerは、useStateの代替となるフックで、複数の値にまたがる複雑な状態ロジックや、次の状態が前の状態に依存する場合に適しています。
Reduxをご存知の方へ: useReducerはReduxのreducerパターンをReactに取り込んだものです。小〜中規模のアプリケーションでは、Reduxを導入せずにuseReducer + useContextで十分な場合が多いです。
基本構文
const [state, dispatch] = useReducer(reducer, initialState)
| 要素 | 説明 |
|---|---|
state | 現在の状態 |
dispatch | アクションを発行する関数 |
reducer | 状態を更新するロジックを持つ関数 |
initialState | 初期状態 |
基本的な使い方
カウンターの例
import { useReducer } from 'react'
// 状態の型
type CounterState = {
count: number
}
// アクションの型(ユニオン型で全アクションを定義)
// 各アクションには type プロパティが必須
// payload は追加データが必要な場合のみ
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'set'; payload: number } // payloadで値を渡す
// 初期状態
const initialState: CounterState = { count: 0 }
// Reducer関数: (現在の状態, アクション) => 新しい状態
// 純粋関数として実装する(副作用なし、状態を直接変更しない)
function counterReducer(state: CounterState, action: CounterAction): CounterState {
// action.type に応じて状態を更新
switch (action.type) {
case 'increment':
return { count: state.count + 1 } // 新しいオブジェクトを返す
case 'decrement':
return { count: state.count - 1 }
case 'reset':
return initialState // 初期状態に戻す
case 'set':
return { count: action.payload } // payloadの値を使用
default:
return state // 未知のアクションは状態を変更しない
}
}
// コンポーネント
function Counter() {
// useReducer: [現在の状態, dispatch関数] を返す
// dispatch でアクションを発行して状態を更新
const [state, dispatch] = useReducer(counterReducer, initialState)
// dispatch({ type: 'アクション名' }) で状態更新をトリガー
const handleIncrement = () => dispatch({ type: 'increment' })
const handleDecrement = () => dispatch({ type: 'decrement' })
const handleReset = () => dispatch({ type: 'reset' })
const handleSetHundred = () => dispatch({ type: 'set', payload: 100 })
return (
<div>
<p>カウント: {state.count}</p>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleDecrement}>-1</button>
<button onClick={handleReset}>リセット</button>
<button onClick={handleSetHundred}>100に設定</button>
</div>
)
}
試してみよう: カウンター改良
increment、decrement、reset、任意の値を設定するアクションを持つカウンターをuseReducerで実装してみましょう。
ヒント
CounterActionユニオン型で4つのアクションを定義switch文で各アクションを処理setアクションにはpayloadプロパティで値を渡す- TypeScriptの型でアクションを安全に扱う
回答と解説
import { useReducer } from 'react'
import './App.css'
type CounterState = {
count: number
}
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'set'; payload: number }
const initialState: CounterState = { count: 0 }
function counterReducer(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
case 'set':
return { count: action.payload }
default:
return state
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, initialState)
const handleIncrement = () => dispatch({ type: 'increment' })
const handleDecrement = () => dispatch({ type: 'decrement' })
const handleReset = () => dispatch({ type: 'reset' })
const handleSetHundred = () => dispatch({ type: 'set', payload: 100 })
return (
<div>
<p>カウント: {state.count}</p>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleDecrement}>-1</button>
<button onClick={handleReset}>リセット</button>
<button onClick={handleSetHundred}>100に設定</button>
</div>
)
}
export default function App() {
return <Counter />
}
useReducerにより、状態更新ロジックがReducer関数に集約され、アクションの意図が明確になります。TypeScriptのユニオン型を使うことで、存在しないアクションタイプを指定するとコンパイルエラーになります。
useReducerとuseStateの比較
いつuseReducerを使うか
| 状況 | useState | useReducer |
|---|---|---|
| 単純な状態(1つの値) | ○ | △ |
| 複数の関連する状態 | △ | ○ |
| 複雑な状態更新ロジック | △ | ○ |
| 前の状態に依存する更新 | △ | ○ |
| 状態更新のテスト | 難しい | 容易 |
同じ機能の比較
// useStateの場合: シンプルな状態に適している
function CounterWithState() {
const [count, setCount] = useState(0)
// 更新ロジックがコンポーネント内に散らばる
const increment = () => setCount(prev => prev + 1)
const decrement = () => setCount(prev => prev - 1)
const reset = () => setCount(0)
return (/* ... */)
}
// useReducerの場合: 複雑な状態ロジックに適している
function CounterWithReducer() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 })
// アクション名で「何をするか」が明確
const handleIncrement = () => dispatch({ type: 'increment' })
return (
<>
<button onClick={handleIncrement}>+1</button>
{/* 更新ロジックはReducer関数に集約されている */}
</>
)
}
実践的な例
フォーム状態の管理
type FormState = {
name: string
email: string
password: string
confirmPassword: string
errors: Record<string, string>
isSubmitting: boolean
}
type FormAction =
| { type: 'SET_FIELD'; field: keyof Omit<FormState, 'errors' | 'isSubmitting'>; value: string }
| { type: 'SET_ERROR'; field: string; message: string }
| { type: 'CLEAR_ERRORS' }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_END' }
| { type: 'RESET' }
const initialFormState: FormState = {
name: '',
email: '',
password: '',
confirmPassword: '',
errors: {},
isSubmitting: false
}
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return {
...state, // 既存の状態をコピー(スプレッド構文)
[action.field]: action.value, // 動的プロパティ名で特定フィールドを更新
errors: { ...state.errors, [action.field]: '' } // フィールド変更時にエラーをクリア
}
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.message }
}
case 'CLEAR_ERRORS':
return { ...state, errors: {} } // エラーを空オブジェクトに
case 'SUBMIT_START':
return { ...state, isSubmitting: true }
case 'SUBMIT_END':
return { ...state, isSubmitting: false }
case 'RESET':
return initialFormState // 初期状態に戻す
default:
return state
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState)
const handleChange = (field: keyof Omit<FormState, 'errors' | 'isSubmitting'>) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'SET_FIELD', field, value: e.target.value })
}
const validate = (): boolean => {
let isValid = true
if (!state.name.trim()) {
dispatch({ type: 'SET_ERROR', field: 'name', message: '名前は必須です' })
isValid = false
}
if (!state.email.includes('@')) {
dispatch({ type: 'SET_ERROR', field: 'email', message: '有効なメールアドレスを入力してください' })
isValid = false
}
if (state.password.length < 8) {
dispatch({ type: 'SET_ERROR', field: 'password', message: 'パスワードは8文字以上必要です' })
isValid = false
}
if (state.password !== state.confirmPassword) {
dispatch({ type: 'SET_ERROR', field: 'confirmPassword', message: 'パスワードが一致しません' })
isValid = false
}
return isValid
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
dispatch({ type: 'CLEAR_ERRORS' })
if (!validate()) return
dispatch({ type: 'SUBMIT_START' })
try {
await registerUser(state)
dispatch({ type: 'RESET' })
} finally {
dispatch({ type: 'SUBMIT_END' })
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<input
value={state.name}
onChange={handleChange('name')}
placeholder="名前"
/>
{state.errors.name && <span className="error">{state.errors.name}</span>}
</div>
<div>
<input
type="email"
value={state.email}
onChange={handleChange('email')}
placeholder="メールアドレス"
/>
{state.errors.email && <span className="error">{state.errors.email}</span>}
</div>
<div>
<input
type="password"
value={state.password}
onChange={handleChange('password')}
placeholder="パスワード"
/>
{state.errors.password && <span className="error">{state.errors.password}</span>}
</div>
<div>
<input
type="password"
value={state.confirmPassword}
onChange={handleChange('confirmPassword')}
placeholder="パスワード(確認)"
/>
{state.errors.confirmPassword && <span className="error">{state.errors.confirmPassword}</span>}
</div>
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? '送信中...' : '登録'}
</button>
</form>
)
}
試してみよう: フォーム状態管理
複数のフィールド(名前、メールアドレス)とバリデーションエラー、送信状態を管理するフォームをuseReducerで実装してみましょう。
ヒント
FormState型にフィールド値、errors(Record型)、isSubmittingを含めるSET_FIELDでフィールド更新時にエラーをクリアSET_ERROR、CLEAR_ERRORS、SUBMIT_START、SUBMIT_END、RESETアクションを実装- バリデーション関数で複数のエラーを設定
回答と解説
import { useReducer } from 'react'
import './App.css'
type FormState = {
name: string
email: string
errors: Record<string, string>
isSubmitting: boolean
}
type FormAction =
| { type: 'SET_FIELD'; field: 'name' | 'email'; value: string }
| { type: 'SET_ERROR'; field: string; message: string }
| { type: 'CLEAR_ERRORS' }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_END' }
| { type: 'RESET' }
const initialFormState: FormState = {
name: '',
email: '',
errors: {},
isSubmitting: false
}
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
[action.field]: action.value,
errors: { ...state.errors, [action.field]: '' }
}
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.message }
}
case 'CLEAR_ERRORS':
return { ...state, errors: {} }
case 'SUBMIT_START':
return { ...state, isSubmitting: true }
case 'SUBMIT_END':
return { ...state, isSubmitting: false }
case 'RESET':
return initialFormState
default:
return state
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState)
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'SET_FIELD', field: 'name', value: e.target.value })
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'SET_FIELD', field: 'email', value: e.target.value })
}
const validate = (): boolean => {
let isValid = true
if (!state.name.trim()) {
dispatch({ type: 'SET_ERROR', field: 'name', message: '名前は必須です' })
isValid = false
}
if (!state.email.includes('@')) {
dispatch({ type: 'SET_ERROR', field: 'email', message: '有効なメールアドレスを入力してください' })
isValid = false
}
return isValid
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
dispatch({ type: 'CLEAR_ERRORS' })
if (!validate()) return
dispatch({ type: 'SUBMIT_START' })
// API呼び出しをシミュレート
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('送信:', { name: state.name, email: state.email })
dispatch({ type: 'SUBMIT_END' })
dispatch({ type: 'RESET' })
}
return (
<form onSubmit={handleSubmit}>
<div>
<input
value={state.name}
onChange={handleNameChange}
placeholder="名前"
/>
{state.errors.name && <span style={{ color: 'red' }}>{state.errors.name}</span>}
</div>
<div>
<input
type="email"
value={state.email}
onChange={handleEmailChange}
placeholder="メールアドレス"
/>
{state.errors.email && <span style={{ color: 'red' }}>{state.errors.email}</span>}
</div>
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? '送信中...' : '登録'}
</button>
</form>
)
}
export default function App() {
return <RegistrationForm />
}
フォームの複雑な状態(フィールド値、エラー、送信状態)を1つのReducerで管理することで、状態の流れが追いやすくなります。フィールド変更時に自動的にエラーをクリアするなど、関連する処理をまとめて記述できます。
データフェッチング状態の管理
type FetchState<T> = {
data: T | null
loading: boolean
error: string | null
}
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'RESET' }
function createFetchReducer<T>() {
return function fetchReducer(
state: FetchState<T>,
action: FetchAction<T>
): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null }
case 'FETCH_SUCCESS':
return { data: action.payload, loading: false, error: null }
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload }
case 'RESET':
return { data: null, loading: false, error: null }
default:
return state
}
}
}
// カスタムフックとして抽出
function useFetch<T>(fetchFn: () => Promise<T>) {
const reducer = createFetchReducer<T>()
const [state, dispatch] = useReducer(reducer, {
data: null,
loading: false,
error: null
})
const execute = useCallback(async () => {
dispatch({ type: 'FETCH_START' })
try {
const data = await fetchFn()
dispatch({ type: 'FETCH_SUCCESS', payload: data })
} catch (e) {
dispatch({ type: 'FETCH_ERROR', payload: e instanceof Error ? e.message : '不明なエラー' })
}
}, [fetchFn])
const reset = useCallback(() => {
dispatch({ type: 'RESET' })
}, [])
return { ...state, execute, reset }
}
// 使用例
function UserList() {
const { data: users, loading, error, execute } = useFetch(fetchUsers)
useEffect(() => {
execute()
}, [execute])
if (loading) return <p>読み込み中...</p>
if (error) return <p>エラー: {error}</p>
if (!users) return null
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
買い物カートの管理
type CartItem = {
id: string
name: string
price: number
quantity: number
}
type CartState = {
items: CartItem[]
totalItems: number
totalPrice: number
}
type CartAction =
| { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' }
const initialCartState: CartState = {
items: [],
totalItems: 0,
totalPrice: 0
}
function calculateTotals(items: CartItem[]): Pick<CartState, 'totalItems' | 'totalPrice'> {
return {
totalItems: items.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
}
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
// 同じ商品がすでにカートにあるか検索
const existingIndex = state.items.findIndex(item => item.id === action.payload.id)
let newItems: CartItem[]
if (existingIndex >= 0) {
// 既存アイテム: map で該当アイテムの数量だけ更新
newItems = state.items.map((item, index) =>
index === existingIndex
? { ...item, quantity: item.quantity + 1 } // 数量+1
: item // 他のアイテムはそのまま
)
} else {
// 新規アイテム: スプレッドで配列に追加
newItems = [...state.items, { ...action.payload, quantity: 1 }]
}
// 合計を再計算して返す
return { items: newItems, ...calculateTotals(newItems) }
}
case 'REMOVE_ITEM': {
// filter で該当アイテムを除外
const newItems = state.items.filter(item => item.id !== action.payload)
return { items: newItems, ...calculateTotals(newItems) }
}
case 'UPDATE_QUANTITY': {
const { id, quantity } = action.payload
// 数量が0以下なら削除
if (quantity <= 0) {
const newItems = state.items.filter(item => item.id !== id)
return { items: newItems, ...calculateTotals(newItems) }
}
// map で該当アイテムの数量を更新
const newItems = state.items.map(item =>
item.id === id ? { ...item, quantity } : item
)
return { items: newItems, ...calculateTotals(newItems) }
}
case 'CLEAR_CART':
return initialCartState // 初期状態(空のカート)に戻す
default:
return state
}
}
function ShoppingCart() {
const [cart, dispatch] = useReducer(cartReducer, initialCartState)
const createDecrementHandler = (id: string, quantity: number) => () => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity: quantity - 1 } })
}
const createIncrementHandler = (id: string, quantity: number) => () => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity: quantity + 1 } })
}
const createRemoveHandler = (id: string) => () => {
dispatch({ type: 'REMOVE_ITEM', payload: id })
}
const handleClearCart = () => {
dispatch({ type: 'CLEAR_CART' })
}
return (
<div>
<h2>カート ({cart.totalItems}点)</h2>
{cart.items.map(item => (
<div key={item.id} className="cart-item">
<span>{item.name}</span>
<span>¥{item.price}</span>
<div className="quantity-controls">
<button onClick={createDecrementHandler(item.id, item.quantity)}>
-
</button>
<span>{item.quantity}</span>
<button onClick={createIncrementHandler(item.id, item.quantity)}>
+
</button>
</div>
<button onClick={createRemoveHandler(item.id)}>
削除
</button>
</div>
))}
<div className="cart-summary">
<p>合計: ¥{cart.totalPrice.toLocaleString()}</p>
<button onClick={handleClearCart}>
カートをクリア
</button>
</div>
</div>
)
}
試してみよう: Todoリスト
ADD、REMOVE、TOGGLEアクションを持つTodoリストをuseReducerで実装してみましょう。
ヒント
Todo型(id, text, completed)を定義ADDで新規追加(idはDate.now()で生成)REMOVEでfilterを使って削除TOGGLEでmapを使ってcompletedを反転
回答と解説
import { useReducer, useState } from 'react'
import './App.css'
type Todo = {
id: number
text: string
completed: boolean
}
type TodoState = {
todos: Todo[]
}
type TodoAction =
| { type: 'ADD'; payload: string }
| { type: 'REMOVE'; payload: number }
| { type: 'TOGGLE'; payload: number }
const initialTodoState: TodoState = { todos: [] }
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD':
return {
todos: [
...state.todos,
{ id: Date.now(), text: action.payload, completed: false }
]
}
case 'REMOVE':
return {
todos: state.todos.filter(todo => todo.id !== action.payload)
}
case 'TOGGLE':
return {
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
}
default:
return state
}
}
function TodoList() {
const [state, dispatch] = useReducer(todoReducer, initialTodoState)
const [input, setInput] = useState('')
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value)
}
const handleAdd = () => {
if (input.trim()) {
dispatch({ type: 'ADD', payload: input })
setInput('')
}
}
const createToggleHandler = (id: number) => () => {
dispatch({ type: 'TOGGLE', payload: id })
}
const createRemoveHandler = (id: number) => () => {
dispatch({ type: 'REMOVE', payload: id })
}
return (
<div>
<div>
<input
value={input}
onChange={handleInputChange}
placeholder="新しいタスク"
/>
<button onClick={handleAdd}>追加</button>
</div>
<ul>
{state.todos.map(todo => (
<li key={todo.id}>
<span
onClick={createToggleHandler(todo.id)}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
cursor: 'pointer'
}}
>
{todo.text}
</span>
<button onClick={createRemoveHandler(todo.id)}>
削除
</button>
</li>
))}
</ul>
</div>
)
}
export default function App() {
return <TodoList />
}
Reducerで状態更新ロジックを管理することで、どのようなアクションが可能かが型で明示されます。テストも容易で、Reducer関数だけを単体テストできます。
Reducerの設計原則
1. 純粋関数であること
Reducerは同じ入力に対して常に同じ出力を返す純粋関数である必要があります。
// NG: 副作用を含む(Reducer内でやってはいけないこと)
function badReducer(state, action) {
switch (action.type) {
case 'ADD':
localStorage.setItem('count', state.count + 1) // NG: 副作用(外部への書き込み)
return { count: state.count + 1 }
}
}
// OK: 純粋関数(同じ入力には常に同じ出力)
function goodReducer(state, action) {
switch (action.type) {
case 'ADD':
return { count: state.count + 1 } // 新しい状態を返すだけ
}
}
// 副作用(localStorage保存など)はuseEffectで行う
2. 状態を直接変更しない
// NG: 状態を直接変更(ミューテーション)
function badReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
state.items.push(action.payload) // NG: 元の配列を直接変更
return state // 同じ参照を返しても再レンダリングされない
}
}
// OK: 新しいオブジェクトを返す(イミュータブル更新)
function goodReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
// スプレッド構文で新しいオブジェクト・配列を作成
return { ...state, items: [...state.items, action.payload] }
}
}
3. アクションタイプは明確に
// NG: 曖昧なアクションタイプ
{ type: 'UPDATE' }
// OK: 意図が明確なアクションタイプ
{ type: 'UPDATE_USER_NAME' }
{ type: 'INCREMENT_QUANTITY' }
{ type: 'TOGGLE_VISIBILITY' }
Reducerのテスト
Reducerは純粋関数なので、テストが容易です。以下のテストコードは、買い物カート例のcartReducerとinitialCartStateをcartReducer.tsに切り出し、それぞれexportを付けた前提です。
// cartReducer.test.ts
import { describe, it, expect } from 'vitest'
import { cartReducer, initialCartState } from './cartReducer'
describe('cartReducer', () => {
it('should add item to empty cart', () => {
const action = {
type: 'ADD_ITEM' as const,
payload: { id: '1', name: 'りんご', price: 100 }
}
const result = cartReducer(initialCartState, action)
expect(result.items).toHaveLength(1)
expect(result.items[0].quantity).toBe(1)
expect(result.totalPrice).toBe(100)
})
it('should increase quantity when adding existing item', () => {
const stateWithItem = {
items: [{ id: '1', name: 'りんご', price: 100, quantity: 1 }],
totalItems: 1,
totalPrice: 100
}
const action = {
type: 'ADD_ITEM' as const,
payload: { id: '1', name: 'りんご', price: 100 }
}
const result = cartReducer(stateWithItem, action)
expect(result.items).toHaveLength(1)
expect(result.items[0].quantity).toBe(2)
expect(result.totalPrice).toBe(200)
})
})
まとめ
useReducerは複雑な状態ロジックを管理するためのフック- 状態更新ロジックをReducer関数に集約
- アクションを
dispatchして状態を更新 - 純粋関数であり、状態を直接変更しない
- TypeScriptの型定義でアクションを安全に扱える
- テストが容易
useContextと組み合わせてグローバル状態管理にも使用可能
次の章では、カスタムフックについて学びます。