404 Not Found

404 Not Found


nginx

複合状態:useReducer

1. 学習内容



2. ショッピングカートの状態管理に関する話

(1) 課題:複数の useState インスタンス間の複雑な相互作用

アリスは、複数のインタラクションを処理する必要があるショッピングカートコンポーネントを開発しています:

JSX
function ShoppingCart() {
  const [items, setItems] = useState([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState(null)
  const [coupon, setCoupon] = useState(null)

  // Add Item
  function addItem(product) {
    const existing = items.find(i => i.id === product.id)
    if (existing) {
      // ❌ Several setState The call order depends on the current value
      setItems(prev => prev.map(i =>
        i.id === product.id ? { ...i, qty: i.qty + 1 } : i
      ))
    } else {
      setItems(prev => [...prev, { ...product, qty: 1 }])
    }
  }

  // Apply a Coupon
  function applyCoupon(code) {
    setIsLoading(true)
    fetch('/api/coupon/' + code)
      .then(res => res.json())
      .then(data => {
        if (data.valid) {
          setCoupon(data)
          setError(null)
        } else {
          setError('Coupon is invalid')
        }
      })
      .catch(err => setError(err.message))
      .finally(() => setIsLoading(false))
  }

  // Question:items、isLoading、error、coupon The four states are interrelated
  // One operation (e.g. applyCoupon) triggers 3 setState calls
  // It's easy to miss an update to a particular state,Causes inconsistencies
}

(2) useReducer ソリューション

JSX
// 1. Define the Initial State
const initialState = {
  items: [],
  isLoading: false,
  error: null,
  coupon: null,
  total: 0
}

// 2. Definition reducer(Pure Functions:Receive Previous State + action → Return to the new state)
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.product.id)
      const newItems = existing
        ? state.items.map(i =>
            i.id === action.product.id ? { ...i, qty: i.qty + 1 } : i
          )
        : [...state.items, { ...action.product, qty: 1 }]
      return { ...state, items: newItems, error: null }
    }
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.id)
      }
    case 'UPDATE_QTY':
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.id ? { ...i, qty: Math.max(1, action.qty) } : i
        )
      }
    case 'APPLY_COUPON_REQUEST':
      return { ...state, isLoading: true, error: null }
    case 'APPLY_COUPON_SUCCESS':
      return { ...state, isLoading: false, coupon: action.coupon }
    case 'APPLY_COUPON_FAILURE':
      return { ...state, isLoading: false, error: action.error }
    case 'CLEAR_ERROR':
      return { ...state, error: null }
    case 'RESET_CART':
      return initialState
    default:
      return state
  }
}

// 3. Using in a component
function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState)

  function handleAdd(product) {
    dispatch({ type: 'ADD_ITEM', product })
  }

  function handleApplyCoupon(code) {
    dispatch({ type: 'APPLY_COUPON_REQUEST' })
    fetch('/api/coupon/' + code)
      .then(res => res.json())
      .then(data => {
        if (data.valid) {
          dispatch({ type: 'APPLY_COUPON_SUCCESS', coupon: data })
        } else {
          dispatch({ type: 'APPLY_COUPON_FAILURE', error: 'Coupon is invalid' })
        }
      })
      .catch(err => dispatch({ type: 'APPLY_COUPON_FAILURE', error: err.message }))
  }

  return (
    <div>
      {state.items.map(item => (
        <div key={item.id}>
          {item.name} × {item.qty}
          <button onClick={() => dispatch({ type: 'UPDATE_QTY', id: item.id, qty: item.qty + 1 })}>+</button>
          <button onClick={() => dispatch({ type: 'REMOVE_ITEM', id: item.id })}>Delete</button>
        </div>
      ))}
      {state.isLoading && <p>Processing......</p>}
      {state.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state.coupon && <p>Coupon has been applied:{state.coupon.code}</p>}
    </div>
  )
}

メリット:すべての状態変化は単一の reducer 関数に集約されており、予測可能で、テストしやすく、デバッグも容易です。各アクションは「何が起こったか」の記録となります。



3. useReducerのコアパターン

概念 説明
reducer 純粋関数, (状態, アクション) → 新しい状態 function cartReducer(state, action)
action 「何が起きたか」を記述するオブジェクト { type: 'ADD_ITEM', payload: product }
dispatch アクションを送信する関数 dispatch({ type: 'INCREMENT' })
initialState 初期状態オブジェクト { items: [], total: 0 }
init 関数 遅延初期化(オプションの3番目の引数) useReducer(reducer, initialArg, init)
100%
graph LR
    A[Components] -->|dispatch(action)| B[reducer]
    B -->|(state, action) => newState| C[New Status]
    C -->|Update| A
    D[User Actions] -->|Trigger| A
    
    style B fill:#1890ff,color:#fff
    style C fill:#52c41a,color:#fff

(1) 基本的な構文

JSX
const [state, dispatch] = useReducer(reducer, initialState)

// reducer It is a pure function:
function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 }
    case 'SET_NAME':
      return { ...state, name: action.payload }
    default:
      return state
  }
}

(2) アクション設計の原則

原則 説明 悪い例 良い例
タイプ名には過去形を使う 「すでに起こったこと」を表す ADD ITEM_ADDED
データの持ち込みを最小限に抑える 必要なデータのみを持ち込み、残りはリデューサーで計算する dispatch({ type: 'SET_TOTAL', total: 42 }) dispatch({ type: 'ITEM_ADDED', item })。ここで、totalはリデューサーで計算される
副作用を扱わないこと リデューサーは純粋関数でなければならない(APIを呼び出したり、localStorageの読み書きを行ったりしてはならない) case 'LOGIN': fetch(...) APIの呼び出しはuseEffectまたはイベントハンドラ内で行い、完了後にディスパッチする


4. useReducer 対 useState

ディメンション useState useReducer
状態の数 3以下 3より多い、または状態が相互に関連している
更新ロジック 単純(トグル、+1) 複雑(互いに影響し合う複数のサブ値)
読みやすさ 良好(一目で理解しやすい) 良好(一元管理)
テスト可能性 レンダリングが必要なコンポーネントはテストしなければならない リデューサーは純粋関数であるため、直接テストできる
適した規模 小さなコンポーネント、単体の状態 中規模から大規模のコンポーネント、複雑なフォーム、ショッピングカート

(1) ▶ サンプル:同じ機能の異なる実装の比較

JSX
// ============================================
// Example:Counter——useState vs useReducer
// ============================================

// ---- useState version(Simple Scenarios)----
function CounterWithState() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  )
}

// ---- useReducer version(Complex Scenarios)----
const initialState = { count: 0, lastAction: null, history: [] }

function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return {
        count: state.count + 1,
        lastAction: 'INCREMENT',
        history: [...state.history, 'INCREMENT']
      }
    case 'DECREMENT':
      return {
        count: state.count - 1,
        lastAction: 'DECREMENT',
        history: [...state.history, 'DECREMENT']
      }
    case 'RESET':
      return initialState
    case 'SET':
      return { ...state, count: action.value }
    default:
      return state
  }
}

function CounterWithReducer() {
  const [state, dispatch] = useReducer(counterReducer, initialState)
  return (
    <div>
      <p>Count:{state.count}</p>
      <p>Last Operation:{state.lastAction}</p>
      <p>Number of operations:{state.history.length}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
      <button onClick={() => dispatch({ type: 'SET', value: 100 })}>Set as 100</button>
    </div>
  )
}
▶ 試してみよう

5. useReducer + Context:軽量なグローバル状態

複数のコンポーネントで同じリデューサーの状態を共有する必要がある場合は、Context を使用して dispatch を渡します:

JSX
// ============================================
// Example:Todo Applications(useReducer + Context)
// Features:Global State Management,Any child component can dispatch
// ============================================

// ---- 1. Create Context ----
const TodoContext = React.createContext(null)

// ---- 2. Definition reducer ----
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now(), text: action.text, done: false }]
    case 'TOGGLE_TODO':
      return state.map(t =>
        t.id === action.id ? { ...t, done: !t.done } : t
      )
    case 'DELETE_TODO':
      return state.filter(t => t.id !== action.id)
    case 'CLEAR_DONE':
      return state.filter(t => !t.done)
    default:
      return state
  }
}

// ---- 3. Provider Package ----
function TodoProvider({ children }) {
  const [todos, dispatch] = useReducer(todoReducer, [
    { id: 1, text: 'Study useReducer', done: true },
    { id: 2, text: 'Understanding Context Usage', done: false }
  ])

  return (
    <TodoContext.Provider value={{ todos, dispatch }}>
      {children}
    </TodoContext.Provider>
  )
}

// ---- 4. Reading from Any Subcomponent/Edit Status ----
function AddTodo() {
  const [text, setText] = useState('')
  const { dispatch } = useContext(TodoContext)

  function handleSubmit(e) {
    e.preventDefault()
    if (!text.trim()) return
    dispatch({ type: 'ADD_TODO', text: text.trim() })
    setText('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={e => setText(e.target.value)} placeholder="Add to To-Do List..." />
      <button type="submit">Add</button>
    </form>
  )
}

function TodoList() {
  const { todos, dispatch } = useContext(TodoContext)

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id} style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
          <input type="checkbox" checked={todo.done}
            onChange={() => dispatch({ type: 'TOGGLE_TODO', id: todo.id })} />
          {todo.text}
          <button onClick={() => dispatch({ type: 'DELETE_TODO', id: todo.id })}>✕</button>
        </li>
      ))}
    </ul>
  )
}

function TodoStats() {
  const { todos } = useContext(TodoContext)
  const done = todos.filter(t => t.done).length
  const pending = todos.length - done

  return (
    <p style={{ fontSize: '13px', color: '#666' }}>
      Total {todos.length} | Completed {done} | Pending completion {pending}
      {done > 0 && (
        <button onClick={() => dispatch({ type: 'CLEAR_DONE' })} style={{ marginLeft: '8px' }}>
          Clear Completed
        </button>
      )}
    </p>
  )
}

// ---- 5. Top-level usage ----
function App() {
  return (
    <TodoProvider>
      <div style={{ maxWidth: '500px', margin: '0 auto' }}>
        <h2>📋 Todo(useReducer + Context)</h2>
        <AddTodo />
        <TodoStats />
        <TodoList />
      </div>
    </TodoProvider>
  )
}


6. 完全な例:多段階フォーム(ウィザード)

JSX
// ============================================
// Complete Example:Multi-Step Registration Form(Wizard)
// Features:useReducer Managing the Complex State of Multi-Step Forms
// ============================================

const wizardInitialState = {
  currentStep: 1,
  formData: {
    account: { username: '', email: '', password: '' },
    profile: { name: '', bio: '', avatar: '' },
    preferences: { language: 'zh', theme: 'light', newsletter: false }
  },
  errors: {},
  isSubmitting: false,
  completed: false
}

function wizardReducer(state, action) {
  switch (action.type) {
    case 'NEXT_STEP':
      if (state.currentStep >= 3) return state
      return { ...state, currentStep: state.currentStep + 1, errors: {} }

    case 'PREV_STEP':
      if (state.currentStep <= 1) return state
      return { ...state, currentStep: state.currentStep - 1 }

    case 'UPDATE_FIELD':
      return {
        ...state,
        formData: {
          ...state.formData,
          [action.section]: {
            ...state.formData[action.section],
            [action.field]: action.value
          }
        }
      }

    case 'SET_ERRORS':
      return { ...state, errors: action.errors }

    case 'SUBMIT_START':
      return { ...state, isSubmitting: true, errors: {} }

    case 'SUBMIT_SUCCESS':
      return { ...state, isSubmitting: false, completed: true }

    case 'SUBMIT_FAILURE':
      return { ...state, isSubmitting: false, errors: { submit: action.error } }

    case 'RESET':
      return wizardInitialState

    default:
      return state
  }
}

function WizardForm() {
  const [state, dispatch] = useReducer(wizardReducer, wizardInitialState)

  function validateStep() {
    const errors = {}
    const data = state.formData[getStepKey(state.currentStep)]
    
    if (state.currentStep === 1) {
      if (!data.username) errors.username = 'Required'
      if (!data.email?.includes('@')) errors.email = 'Invalid email address'
      if (!data.password || data.password.length < 6) errors.password = 'Password must be at least 6 chars'
    }
    if (state.currentStep === 2) {
      if (!data.name) errors.name = 'Required'
    }
    
    return errors
  }

  function handleNext() {
    const errors = validateStep()
    if (Object.keys(errors).length > 0) {
      dispatch({ type: 'SET_ERRORS', errors })
      return
    }
    dispatch({ type: 'NEXT_STEP' })
  }

  function handleSubmit() {
    dispatch({ type: 'SUBMIT_START' })
    // Simulation API Request
    setTimeout(() => {
      dispatch({ type: 'SUBMIT_SUCCESS' })
    }, 1500)
  }

  if (state.completed) {
    return (
      <div style={{ textAlign: 'center', padding: '40px' }}>
        <h2>🎉 Registration Successful!</h2>
        <pre style={{ textAlign: 'left', background: '#f5f5f5', padding: '16px', borderRadius: '4px' }}>
          {JSON.stringify(state.formData, null, 2)}
        </pre>
        <button onClick={() => dispatch({ type: 'RESET' })}>Re-register</button>
      </div>
    )
  }

  const stepKey = getStepKey(state.currentStep)

  return (
    <div style={{ maxWidth: '500px', margin: '0 auto' }}>
      <h2>📝 Register(Steps {state.currentStep}/3)</h2>

      {/* Step Indicator */}
      <div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
        {['Account Information', 'Personal Information', 'Preferences'].map((label, i) => (
          <div key={i} style={{
            flex: 1, padding: '8px', textAlign: 'center',
            backgroundColor: state.currentStep >= i + 1 ? '#1890ff' : '#f0f0f0',
            color: state.currentStep >= i + 1 ? 'white' : '#999',
            borderRadius: '4px', fontSize: '13px'
          }}>
            {label}
          </div>
        ))}
      </div>

      {/* Form Content */}
      {state.currentStep === 1 && <AccountForm formData={state.formData.account} errors={state.errors} dispatch={dispatch} />}
      {state.currentStep === 2 && <ProfileForm formData={state.formData.profile} errors={state.errors} dispatch={dispatch} />}
      {state.currentStep === 3 && <PreferenceForm formData={state.formData.preferences} errors={state.errors} dispatch={dispatch} />}

      {/* Error Message */}
      {state.errors.submit && <p style={{ color: '#ff4d4f' }}>{state.errors.submit}</p>}

      {/* Control Buttons */}
      <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '24px' }}>
        <button onClick={() => dispatch({ type: 'PREV_STEP' })} disabled={state.currentStep === 1}
          style={btnStyle(state.currentStep === 1 ? '#d9d9d9' : '#666')}>
          Previous Step
        </button>
        {state.currentStep < 3 ? (
          <button onClick={handleNext} style={btnStyle('#1890ff')}>
            Next Step
          </button>
        ) : (
          <button onClick={handleSubmit} disabled={state.isSubmitting} style={btnStyle(state.isSubmitting ? '#d9d9d9' : '#52c41a')}>
            {state.isSubmitting ? 'Submitting......' : 'Submit Registration'}
          </button>
        )}
      </div>
    </div>
  )
}

function getStepKey(step) {
  return ['account', 'profile', 'preferences'][step - 1]
}

function AccountForm({ formData, errors, dispatch }) {
  return (
    <div>
      <input name="username" value={formData.username} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'account', field: 'username', value: e.target.value })}
        placeholder="Username" style={inputStyle(errors.username)} />
      {errors.username && <p style={{ color: '#ff4d4f', fontSize: '12px' }}>{errors.username}</p>}
      <input name="email" value={formData.email} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'account', field: 'email', value: e.target.value })}
        placeholder="Email" style={inputStyle(errors.email)} />
      {errors.email && <p style={{ color: '#ff4d4f', fontSize: '12px' }}>{errors.email}</p>}
      <input name="password" type="password" value={formData.password} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'account', field: 'password', value: e.target.value })}
        placeholder="Password" style={inputStyle(errors.password)} />
      {errors.password && <p style={{ color: '#ff4d4f', fontSize: '12px' }}>{errors.password}</p>}
    </div>
  )
}

function ProfileForm({ formData, errors, dispatch }) {
  return (
    <div>
      <input name="name" value={formData.name} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'profile', field: 'name', value: e.target.value })}
        placeholder="Name" style={inputStyle(errors.name)} />
      {errors.name && <p style={{ color: '#ff4d4f', fontSize: '12px' }}>{errors.name}</p>}
      <textarea name="bio" value={formData.bio} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'profile', field: 'bio', value: e.target.value })}
        placeholder="Personal Profile" rows={3} style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px', marginBottom: '8px' }} />
    </div>
  )
}

function PreferenceForm({ formData, errors, dispatch }) {
  return (
    <div>
      <p>Interface Language:</p>
      {['zh', 'en', 'ja', 'pt', 'ar'].map(lang => (
        <label key={lang} style={{ marginRight: '12px' }}>
          <input type="radio" checked={formData.language === lang}
            onChange={() => dispatch({ type: 'UPDATE_FIELD', section: 'preferences', field: 'language', value: lang })} />
          {lang.toUpperCase()}
        </label>
      ))}
      <p style={{ marginTop: '16px' }}>Topic:</p>
      <select value={formData.theme} onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'preferences', field: 'theme', value: e.target.value })}
        style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }}>
        <option value="light">Light-colored</option>
        <option value="dark">Dark</option>
      </select>
      <label style={{ display: 'block', marginTop: '12px' }}>
        <input type="checkbox" checked={formData.newsletter}
          onChange={e => dispatch({ type: 'UPDATE_FIELD', section: 'preferences', field: 'newsletter', value: e.target.checked })} />
        Subscribe to the Weekly Tech Report
      </label>
    </div>
  )
}

function inputStyle(hasError) {
  return {
    width: '100%', padding: '8px', marginBottom: '4px',
    border: `1px solid ${hasError ? '#ff4d4f' : '#d9d9d9'}`,
    borderRadius: '4px'
  }
}

function btnStyle(bgColor) {
  return {
    padding: '10px 24px', backgroundColor: bgColor,
    color: 'white', border: 'none', borderRadius: '4px',
    cursor: bgColor === '#d9d9d9' ? 'not-allowed' : 'pointer'
  }
}

期待される出力:3段階の登録フォーム(アカウント → プロフィール → 設定)。各段階でバリデーションが行われ、前後のステップへ移動できる機能を備え、最終的な送信後に送信結果が表示されるもの。


(1) ▶ サンプル:非同期データ読み込みと useReducer

JSX
// ============================================
// Example:Asynchronous Data Loading——useReducer Three States of Management Requests
// Features:Display useReducer How to Handle It Gracefully loading / success / error Three States
// ============================================

import { useReducer, useEffect } from 'react'

// 1. Defining States and action Type
const initialState = {
  data: null,
  loading: true,
  error: null
}

function dataReducer(state, action) {
  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 { data: null, loading: false, error: action.error }
    case 'RESET':
      return initialState
    default:
      return state
  }
}

// 2. Used in components
function UserDataFetcher({ userId }) {
  const [state, dispatch] = useReducer(dataReducer, initialState)

  useEffect(() => {
    if (!userId) return

    dispatch({ type: 'FETCH_START' })

    // Simulation API Request
    const timer = setTimeout(() => {
      if (userId <= 0) {
        dispatch({ type: 'FETCH_ERROR', error: 'Invalid User ID' })
      } else {
        // Simulate a successful response
        const mockUser = {
          id: userId,
          name: `User ${userId}`,
          email: `user${userId}@example.com`,
          role: userId === 1 ? 'Administrator' : 'Regular User',
          joinDate: '2026-01-15'
        }
        dispatch({ type: 'FETCH_SUCCESS', payload: mockUser })
      }
    }, 1000)

    return () => {
      clearTimeout(timer)
      dispatch({ type: 'RESET' })
    }
  }, [userId])

  // 3. Rendering Varies Based on State UI
  if (state.loading) {
    return (
      <div style={{ textAlign: 'center', padding: '20px' }}>
        <div style={{ fontSize: '32px', marginBottom: '8px' }}>⏳</div>
        <p style={{ color: '#666' }}>Loading user data...</p>
      </div>
    )
  }

  if (state.error) {
    return (
      <div style={{ textAlign: 'center', padding: '20px', color: '#ff4d4f' }}>
        <div style={{ fontSize: '32px', marginBottom: '8px' }}>❌</div>
        <p>Failed to load:{state.error}</p>
      </div>
    )
  }

  if (!state.data) {
    return <p style={{ color: '#999', textAlign: 'center' }}>Please select a user</p>
  }

  const { name, email, role, joinDate } = state.data
  return (
    <div style={{
      border: '1px solid #e8e8e8',
      borderRadius: '8px',
      padding: '20px',
      maxWidth: '400px',
      margin: '0 auto'
    }}>
      <h3>👤 User Information</h3>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <tbody>
          {[
            ['Name', name],
            ['Email', email],
            ['Characters', role],
            ['Date of Registration', joinDate]
          ].map(([label, value]) => (
            <tr key={label}>
              <td style={{ padding: '8px', color: '#666', fontWeight: 'bold', width: '80px' }}>{label}</td>
              <td style={{ padding: '8px' }}>{value}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

// Usage
function App() {
  const [userId, setUserId] = useState(1)
  return (
    <div style={{ maxWidth: '500px', margin: '0 auto' }}>
      <h2>📡 Example of Asynchronous Data Loading</h2>
      <div style={{ marginBottom: '12px', display: 'flex', gap: '8px' }}>
        {[1, 2, 3].map(id => (
          <button key={id} onClick={() => setUserId(id)}
            style={{
              padding: '6px 16px',
              backgroundColor: userId === id ? '#1890ff' : '#f0f0f0',
              color: userId === id ? 'white' : '#333',
              border: 'none', borderRadius: '4px', cursor: 'pointer'
            }}>
            User {id}
          </button>
        ))}
        <button onClick={() => setUserId(-1)}
          style={{ padding: '6px 16px', backgroundColor: '#ff4d4f', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
          Triggered an error
        </button>
      </div>
      <UserDataFetcher userId={userId} />
    </div>
  )
}
▶ 試してみよう

(2) ▶ サンプル:useReducer を使用した「元に戻す/やり直し」機能の実装

JSX
function undoReducer(state, action) {
  switch (action.type) {
    case 'SET':
      return { past: [...state.past, state.present], present: action.payload, future: [] }
    case 'UNDO':
      if (state.past.length === 0) return state
      const previous = state.past[state.past.length - 1]
      return { past: state.past.slice(0, -1), present: previous, future: [state.present, ...state.future] }
    case 'REDO':
      if (state.future.length === 0) return state
      const next = state.future[0]
      return { past: [...state.past, state.present], present: next, future: state.future.slice(1) }
    default:
      return state
  }
}

function UndoApp() {
  const [{ past, present, future }, dispatch] = useReducer(undoReducer, {
    past: [], present: '', future: [],
  })
  const [input, setInput] = useState('')

  return (
    <div style={{ maxWidth: 400, margin: '0 auto' }}>
      <h3>Undo/Redo Editor</h3>
      <input value={input} onChange={e => setInput(e.target.value)} placeholder="Type something..."
        style={{ width: '100%', padding: 8, borderRadius: 4, marginBottom: 8 }} />
      <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
        <button onClick={() => { dispatch({ type: 'SET', payload: input }); setInput('') }}
          style={{ padding: '6px 16px', cursor: 'pointer' }}>Save</button>
        <button onClick={() => dispatch({ type: 'UNDO' })} disabled={past.length === 0}
          style={{ padding: '6px 16px', cursor: past.length ? 'pointer' : 'not-allowed' }}>Undo</button>
        <button onClick={() => dispatch({ type: 'REDO' })} disabled={future.length === 0}
          style={{ padding: '6px 16px', cursor: future.length ? 'pointer' : 'not-allowed' }}>Redo</button>
      </div>
      <p>Current: <strong>{present || '(empty)'}</strong></p>
      <p style={{ fontSize: 12, color: '#999' }}>Past: [{past.join(', ')}] | Future: [{future.join(', ')}]</p>
    </div>
  )
}
▶ 試してみよう

(3) ▶ サンプル:useReducer を使用したフォーム状態の管理

JSX
const initialForm = { username: '', email: '', password: '', role: 'user', agree: false, errors: {} }

function formReducer(state, action) {
  switch (action.type) {
    case 'UPDATE_FIELD':
      return { ...state, [action.field]: action.value, errors: { ...state.errors, [action.field]: undefined } }
    case 'SET_ERRORS':
      return { ...state, errors: action.errors }
    case 'RESET':
      return initialForm
    default:
      return state
  }
}

function FormWithReducer() {
  const [form, dispatch] = useReducer(formReducer, initialForm)

  function validate() {
    const errors = {}
    if (!form.username.trim()) errors.username = 'Required'
    if (!form.email.includes('@')) errors.email = 'Invalid email'
    if (form.password.length < 6) errors.password = 'At least 6 chars'
    if (!form.agree) errors.agree = 'Must agree'
    return errors
  }

  function handleSubmit(e) {
    e.preventDefault()
    const errors = validate()
    if (Object.keys(errors).length > 0) { dispatch({ type: 'SET_ERRORS', errors }); return }
    alert(`Submitted: ${form.username}`)
  }

  function fieldStyle(key) {
    return { width: '100%', padding: 8, borderRadius: 4, border: form.errors[key] ? '1px solid #ff4d4f' : '1px solid #d9d9d9', marginBottom: 4 }
  }

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '0 auto' }}>
      <h3>Registration</h3>
      <input value={form.username} onChange={e => dispatch({ type: 'UPDATE_FIELD', field: 'username', value: e.target.value })} placeholder="Username" style={fieldStyle('username')} />
      {form.errors.username && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{form.errors.username}</p>}
      <input value={form.email} onChange={e => dispatch({ type: 'UPDATE_FIELD', field: 'email', value: e.target.value })} placeholder="Email" style={fieldStyle('email')} />
      {form.errors.email && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{form.errors.email}</p>}
      <input type="password" value={form.password} onChange={e => dispatch({ type: 'UPDATE_FIELD', field: 'password', value: e.target.value })} placeholder="Password" style={fieldStyle('password')} />
      {form.errors.password && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{form.errors.password}</p>}
      <label style={{ display: 'block', margin: '8px 0' }}>
        <input type="checkbox" checked={form.agree} onChange={e => dispatch({ type: 'UPDATE_FIELD', field: 'agree', value: e.target.checked })} /> I agree
      </label>
      <button type="submit" style={{ padding: '8px 24px', cursor: 'pointer' }}>Submit</button>
      <button type="button" onClick={() => dispatch({ type: 'RESET' })} style={{ padding: '8px 16px', marginLeft: 8, cursor: 'pointer' }}>Reset</button>
    </form>
  )
}
▶ 試してみよう

(4) ▶ サンプル:グローバルなショッピングカートにおける useReducer と Context の活用

JSX
const CartContext = createContext(null)

const cartInitial = { items: [], coupon: null }

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD': {
      const existing = state.items.find(i => i.id === action.product.id)
      return {
        ...state,
        items: existing
          ? state.items.map(i => i.id === action.product.id ? { ...i, qty: i.qty + 1 } : i)
          : [...state.items, { ...action.product, qty: 1 }],
      }
    }
    case 'REMOVE':
      return { ...state, items: state.items.filter(i => i.id !== action.id) }
    case 'SET_QTY':
      return { ...state, items: state.items.map(i => i.id === action.id ? { ...i, qty: action.qty } : i) }
    case 'CLEAR':
      return cartInitial
    default:
      return state
  }
}

function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, cartInitial)
  const total = useMemo(() => state.items.reduce((sum, i) => sum + i.price * i.qty, 0), [state.items])
  const value = useMemo(() => ({ ...state, total, dispatch }), [state, total])
  return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}

function useCart() { return useContext(CartContext) }

function ProductList() {
  const { dispatch } = useCart()
  const products = [{ id: 1, name: 'Keyboard', price: 79 }, { id: 2, name: 'Mouse', price: 49 }, { id: 3, name: 'Monitor', price: 399 }]
  return (
    <div>
      <h3>Products</h3>
      {products.map(p => (
        <div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', padding: 8, borderBottom: '1px solid #f0f0f0' }}>
          <span>{p.name} - ${p.price}</span>
          <button onClick={() => dispatch({ type: 'ADD', product: p })} style={{ cursor: 'pointer' }}>Add</button>
        </div>
      ))}
    </div>
  )
}

function CartSummary() {
  const { items, total, dispatch } = useCart()
  return (
    <div>
      <h3>Cart ({items.length} items)</h3>
      {items.map(i => (
        <div key={i.id} style={{ display: 'flex', justifyContent: 'space-between', padding: 4 }}>
          <span>{i.name} x{i.qty}</span>
          <span>${i.price * i.qty}</span>
        </div>
      ))}
      <hr />
      <strong>Total: ${total}</strong>
      <button onClick={() => dispatch({ type: 'CLEAR' })} style={{ marginLeft: 8, cursor: 'pointer' }}>Clear</button>
    </div>
  )
}
▶ 試してみよう

❓ よくある質問

Q なぜリデューサーは純粋関数でなければならないのですか?
A 純粋関数とは、同じ入力に対して同じ出力を返し、外部変数を変更せず、副作用がない(APIを呼び出したり、ファイルの読み書きを行ったりしない)関数のことを指します。Reactは、パフォーマンスの最適化(不要なレンダリングのスキップ)やデバッグ(タイムトラベル)のために、リデューサーの予測可能性に依存しています。リデューサー内でAPIを呼び出すと、予測不可能な状態変化を引き起こす可能性があり、デバッグ作業の妨げとなります。
Q useState から useReducer に切り替えるべきなのはいつですか?
A 以下の3つの状況のいずれかが発生した場合です: ① 1つのアクションで3つ以上のuseStateインスタンスを更新する必要がある場合(例:ボタンをクリックすると、isLoadingerrordataが同時に更新される場合); ② 状態ロジックが複雑すぎて、複数のイベントハンドラ関数に分散しており、追跡が困難な場合; ③ コンポーネントに、互いに依存し合う複数の状態のサブ値がある場合。切り替えのタイミング:useStateの実装により、コンポーネントのコード行数が100行を超えるようになったとき。
Q リデューサーに副作用(fetchリクエストなど)を含めることはできますか?
A 絶対にできません。リデューサーは純粋関数でなければなりません。つまり、同じ入力に対しては常に同じ出力を返し、API呼び出し、DOM操作、乱数生成などの副作用があってはなりません。副作用はuseEffectやイベントハンドラ関数に記述する必要があります。リデューサーに副作用を記述すると、状態が予測不能になったり、デバッグが困難になったり、タイムトラベル(DevToolsのリプレイ機能)が機能しなくなったりする原因となります。

📖 まとめ


📝 練習問題

  1. 基本演習(難易度 ⭐)Counter コンポーネントを作成し、useReducer を使用して、+1、-1、+10、リセットの 4 つの演算を実装してください。ページ上に現在の値と演算履歴(直近 5 回の演算)を表示してください。
  2. 上級演習(難易度 ⭐⭐)TodoApp コンポーネントを作成し、useReducer および Context を使用して、追加、完了マークの付け、編集、削除、完了項目のクリアという 5 つの機能を実装してください。
  3. 課題(難易度:⭐⭐⭐)UndoRedoList コンポーネントを作成し、useReducer を使用して「元に戻す/やり直し」機能を実装してください。各操作で履歴のスナップショットを保存し、Ctrl+Z で「元に戻す」、Ctrl+Shift+Z で「やり直し」が実行できるようにしてください。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%