React useReducer

useReducer 是一個用於管理複雜狀態邏輯的 Hook。它是 useState 的替代方案,特別適合用於狀態邏輯複雜或狀態之間有依賴關係的情況。

基本語法

import { useReducer } from 'react'

const [state, dispatch] = useReducer(reducer, initialState)
  • reducer:一個函式,接收當前 state 和 action,返回新的 state
  • initialState:初始狀態值
  • state:當前狀態
  • dispatch:用來發送 action 的函式

Reducer 函式

Reducer 是一個純函式,根據 action 類型來決定如何更新狀態:

function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 }
    case 'DECREMENT':
      return { count: state.count - 1 }
    case 'RESET':
      return { count: 0 }
    default:
      return state
  }
}

基本範例:計數器

import { useReducer } from 'react'

// 定義 reducer
function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 }
    case 'DECREMENT':
      return { count: state.count - 1 }
    case 'RESET':
      return { count: 0 }
    case 'SET':
      return { count: action.payload }
    default:
      throw new Error(`未知的 action: ${action.type}`)
  }
}

// 初始狀態
const initialState = { count: 0 }

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, initialState)

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>重置</button>
      <button onClick={() => dispatch({ type: 'SET', payload: 100 })}>設為 100</button>
    </div>
  )
}

useReducer vs useState

情況推薦使用
簡單的獨立狀態useState
多個相關的狀態值useReducer
狀態更新邏輯複雜useReducer
下一個狀態依賴前一個狀態useReducer
需要在深層元件觸發更新useReducer + Context

使用 useState

// 適合簡單狀態
const [name, setName] = useState('')
const [age, setAge] = useState(0)
const [email, setEmail] = useState('')

使用 useReducer

// 適合複雜狀態
const initialState = {
  name: '',
  age: 0,
  email: '',
  isSubmitting: false,
  error: null,
}

function formReducer(state, action) {
  switch (action.type) {
    case 'UPDATE_FIELD':
      return { ...state, [action.field]: action.value }
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true, error: null }
    case 'SUBMIT_SUCCESS':
      return { ...state, isSubmitting: false }
    case 'SUBMIT_ERROR':
      return { ...state, isSubmitting: false, error: action.error }
    case 'RESET':
      return initialState
    default:
      return state
  }
}

實際範例:待辦清單

import { useReducer, useState } from 'react'

// 定義 action types(可選,但有助於避免錯字)
const ACTIONS = {
  ADD: 'ADD',
  TOGGLE: 'TOGGLE',
  DELETE: 'DELETE',
  EDIT: 'EDIT',
  CLEAR_COMPLETED: 'CLEAR_COMPLETED',
}

// Reducer 函式
function todoReducer(todos, action) {
  switch (action.type) {
    case ACTIONS.ADD:
      return [
        ...todos,
        {
          id: crypto.randomUUID(),
          text: action.payload,
          completed: false,
        },
      ]

    case ACTIONS.TOGGLE:
      return todos.map((todo) =>
        todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
      )

    case ACTIONS.DELETE:
      return todos.filter((todo) => todo.id !== action.payload)

    case ACTIONS.EDIT:
      return todos.map((todo) =>
        todo.id === action.payload.id ? { ...todo, text: action.payload.text } : todo
      )

    case ACTIONS.CLEAR_COMPLETED:
      return todos.filter((todo) => !todo.completed)

    default:
      return todos
  }
}

function TodoApp() {
  const [todos, dispatch] = useReducer(todoReducer, [])
  const [inputValue, setInputValue] = useState('')

  function handleSubmit(e) {
    e.preventDefault()
    if (inputValue.trim()) {
      dispatch({ type: ACTIONS.ADD, payload: inputValue.trim() })
      setInputValue('')
    }
  }

  const completedCount = todos.filter((t) => t.completed).length
  const totalCount = todos.length

  return (
    <div>
      <h1>待辦清單</h1>

      <form onSubmit={handleSubmit}>
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="新增待辦事項"
        />
        <button type="submit">新增</button>
      </form>

      <ul>
        {todos.map((todo) => (
          <TodoItem key={todo.id} todo={todo} dispatch={dispatch} />
        ))}
      </ul>

      {totalCount > 0 && (
        <div>
          <p>
            完成 {completedCount} / {totalCount}
          </p>
          {completedCount > 0 && (
            <button onClick={() => dispatch({ type: ACTIONS.CLEAR_COMPLETED })}>清除已完成</button>
          )}
        </div>
      )}
    </div>
  )
}

function TodoItem({ todo, dispatch }) {
  const [isEditing, setIsEditing] = useState(false)
  const [editText, setEditText] = useState(todo.text)

  function handleSave() {
    if (editText.trim()) {
      dispatch({
        type: ACTIONS.EDIT,
        payload: { id: todo.id, text: editText.trim() },
      })
      setIsEditing(false)
    }
  }

  return (
    <li>
      {isEditing ? (
        <>
          <input value={editText} onChange={(e) => setEditText(e.target.value)} />
          <button onClick={handleSave}>儲存</button>
          <button onClick={() => setIsEditing(false)}>取消</button>
        </>
      ) : (
        <>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => dispatch({ type: ACTIONS.TOGGLE, payload: todo.id })}
          />
          <span
            style={{
              textDecoration: todo.completed ? 'line-through' : 'none',
            }}
          >
            {todo.text}
          </span>
          <button onClick={() => setIsEditing(true)}>編輯</button>
          <button onClick={() => dispatch({ type: ACTIONS.DELETE, payload: todo.id })}>刪除</button>
        </>
      )}
    </li>
  )
}

搭配 Context 使用

useReducer 搭配 useContext 是一個常見的模式,可以實現類似 Redux 的全域狀態管理:

import { createContext, useContext, useReducer } from 'react'

// 建立 Context
const TodoContext = createContext(null)

// Reducer
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD':
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: crypto.randomUUID(),
            text: action.payload,
            completed: false,
          },
        ],
      }
    case 'TOGGLE':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
        ),
      }
    case 'SET_FILTER':
      return { ...state, filter: action.payload }
    default:
      return state
  }
}

const initialState = {
  todos: [],
  filter: 'all', // 'all', 'active', 'completed'
}

// Provider 元件
function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(todoReducer, initialState)

  return <TodoContext value={{ state, dispatch }}>{children}</TodoContext>
}

// 自訂 Hook
function useTodos() {
  const context = useContext(TodoContext)
  if (!context) {
    throw new Error('useTodos 必須在 TodoProvider 內使用')
  }
  return context
}

// 使用範例
function TodoList() {
  const { state, dispatch } = useTodos()

  const filteredTodos = state.todos.filter((todo) => {
    if (state.filter === 'active') return !todo.completed
    if (state.filter === 'completed') return todo.completed
    return true
  })

  return (
    <ul>
      {filteredTodos.map((todo) => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => dispatch({ type: 'TOGGLE', payload: todo.id })}
          />
          {todo.text}
        </li>
      ))}
    </ul>
  )
}

function FilterButtons() {
  const { state, dispatch } = useTodos()

  return (
    <div>
      {['all', 'active', 'completed'].map((filter) => (
        <button
          key={filter}
          onClick={() => dispatch({ type: 'SET_FILTER', payload: filter })}
          style={{ fontWeight: state.filter === filter ? 'bold' : 'normal' }}
        >
          {filter}
        </button>
      ))}
    </div>
  )
}

Lazy Initialization

如果初始狀態需要複雜計算,可以使用第三個參數來延遲初始化:

function init(initialCount) {
  return { count: initialCount }
}

function Counter({ initialCount }) {
  const [state, dispatch] = useReducer(reducer, initialCount, init)
  // ...
}

這樣 init 函式只會在元件首次渲染時執行。