跳至主要内容

React - useReducer

比起 useState 只能用一個方法來管理狀態,Reducer 可以讓我們更靈活的管理複雜的資料狀態。

舉例來說,在一個有 CRUD 的 Todo-list 可能會長這樣 :

TodoList.js
function TodoList() {
const [todos, setTodos] = useState([])
const inputRef = useRef()

// 新增 Todo
function addTodoHandler() {
setTodos([
...todos,
{
id: Date.now(),
content: inputRef.current.value,
completed: false,
},
])
inputRef.current.value = ''
}

// 完成/取消完成 Todo
function toggleTodoHandler(e, id) {
setTodos(
todos.map(todo => {
return {
...todo,
completed: todo.id === id ? e.target.checked : todo.completed,
}
})
)
}

// 編輯 Todo
function editTodoHandler(originalContent, id) {
const editContent = prompt('請輸入修改內容', originalContent)
if (!editContent) return
setTodos(
todos.map(todo => {
return {
...todo,
content: todo.id === id ? editContent : todo.content,
}
})
)
}

// 刪除 Todo
function deleteTodoHandler(id) {
setTodos(todos.filter(todo => todo.id !== id))
}

return (
<>
<div>
<input
type="text"
ref={inputRef}
/>
<button
type="button"
onClick={addTodoHandler}
>
Add
</button>
</div>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<div>
<input
type="checkbox"
onChange={e => toggleTodoHandler(e, todo.id)}
/>
<span>{todo.content}</span>
<button
type="button"
onClick={() => editTodoHandler(todo.content, todo.id)}
>
Edit
</button>
<button
type="button"
onClick={() => deleteTodoHandler(todo.id)}
>
Delete
</button>
</div>
</li>
))}
</ul>
</>
)
}

可以注意一下,每一個 CRUD 的方法中都會使用到 setTodos 去更新狀態,當未來這個元件的功能愈來愈多,不管是在原本 CRUD 方法中需要額外添加邏輯,或是元件有其他新增的功能導致 function 愈寫愈多,都會影響到程式的複雜度和可讀性。

因此,為了降低元件的複雜度,將某個資料狀態的處理邏輯拆分清楚並集中管理,Reducer 就是其中一個解藥,接下來就讓我介紹一下 useReducer 這個 hook 吧 !

信息

Reducer 的取名來自於 JavaScript 陣列操作的 reduce() 方法中累積資料狀態的特性,可以參考官方文件 Why are reducers called this way ?


useReducer

同前述說明,是一種管理資料狀態的 hook,useState 的替代方案,接近 Redux 寫法 ( 類似 Vuex、Pinia )。

基本語法

const [state, dispatch] = useReducer(reducer, initialArg, init?)

👉 參數 1 : reducer

負責改變 statepure function,reducer 有兩個參數分別是 stateaction

function reducer(state, action) {
// ...
}
  • state : 原本的資料狀態
  • action : 也就是 dispatch 傳入的參數,可以是任何型別,但以常規開發來說,通常會是一個物件 ( object ),且包含一個 type 來抽象描述要對 state 做些什麼事情。
const reducer = (state, action) => {
// 通常會使用 switch 依據 action.type 的描述進行動作
switch (action.type) {
case 'INCREMENT':
// do something...
case 'DECREMENT':
// do something...
default:
return state
}
}

Reducer 最重要的是他必須要是一個 pure function,也就是「One input, One output」,輸入同一個值,回傳的值必須相同,不能對 state 有任何 mutate 的行為。

const reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
state.count += 1 // 錯誤寫法
return state
default:
return state
}
}

這樣直接改變原本資料的行為雖然不會報錯,但不建議這麼做。


👉 參數 2 : initialArg

初始的資料狀態,可以是任何型別,但會使用 useReducer 通常是因為有較複雜的資料狀態需要管理,所以一般會給定一個物件並且包含所需的初始資料。

const initialCount = { count: 0 }

👉 參數 3 : init(option)

這個參數是可選的,一般情況蠻少會用到,init 會是一個 function 且需要 return 一個值,並且會傳入 initialArg ( 也就是初始資料狀態 ) 當作參數。

function init(initialCount) {
// You can do something...
return initialCount
}

會使用到 init 通常是為了實現 lazy initialization,這與 useStateinitializer function 概念差不多,可以避免重複執行成本較高的資料狀態初始化方法。


function CustomerList() {
const initialCustomerData = loadCustomerData()
const [customerData, dispatch] = useReducer(reducer, initialCustomerData)
// ...
}

上面的程式碼中,loadCustomerData() 假設是執行成本比較高的方法,在每次 CustomerList 這個元件 re-render 時都會被重新執行,要避免重新執行就可以使用 init :

function CustomerList() {
const init = () => loadCustomerData()
const [customerData, dispatch] = useReducer(reducer, {}, init)
// ...
}

這個 init 就只會在 useReducer 第一次執行時呼叫,減少了一直重新執行帶來的效能問題。


👉 回傳值 : state

在元件初次渲染時,會回傳 initialArg 或是 init(initialArg) 的回傳值,若使用 dispatch 更新 state,則會觸發 re-render,並且回傳新的 state


👉 回傳值 : dispatch

dispatch 是一個 function,執行時會調用 reducer,並且可以傳入一個參數,這個參數會傳遞給 reducer function 的 action 參數做使用。

const reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
// do something..
case 'DECREMENT':
// do something...
default:
return state
}
}

dispatch({ type: 'INCREMENT' })

前面有提到,action 通常會是一個物件,並且裡面有 type 來描述抽象行為,這邊我們使用 dispatch({ type: 'INCREMENT' })reducer 裡面執行 switch 判斷要做什麼事情。

不過 dispatch 有個需要注意的點,與 useState 一樣,當次元件 render 所獲取的 state 資料狀態都是不變的,例如 :

const initialCount = { count: 0 }

const reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT': // 抽象描述 : 我要增加 count
return { count: state.count + 1 }
case 'DECREMENT': // 抽象描述 : 我要減少 count
return { count: state.count - 1 }
default:
return state
}
}

function App() {
const [state, dispatch] = useReducer(reducer, initialCount)

function clickHandler() {
dispatch({ type: 'INCREMENT' })
console.log(state.count) // 此時 log 出來依然是 dispatch 之前的狀態 0
}
// ...
}

可以想像每一次元件的 render 都是一個快照 ( snapshot ),在這個快照的 state 是不會改變的,而 dispatch 改變後的 state 是在下一次的快照中 ( next snapshot ) 的狀態,因此若是想在 dispatch 後能立即拿到下一次快照的狀態,可以這麼做 :

const initialCount = { count: 0 }

const reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 }
case 'DECREMENT':
return { count: state.count - 1 }
default:
return state
}
}

function App() {
const [state, dispatch] = useReducer(reducer, initialCount)

function clickHandler() {
const action = { type: 'INCREMENT' }
dispatch(action)
const nextState = reducer(state, action)
console.log(state.count) // 此時 log 出來的就是 dispatch 後的狀態 1
}
// ...
}
提示

💡 其實快照 ( snapshot ) 就是 JavaScript 的 閉包 ( closure ) 原理。


useReducer 實作一個 TodoList

讓我們用 useReducer 來改寫一開始的 Todo-list 吧,預計使用到的檔案會是這樣 :

  • ./App.js
  • ./components/TodoList.js
  • ./components/TodoItem.js
  • ./reducers/TodoReducer.js

Step 1 : 建立一個 TodoReducer.js

這個 TodoReducer.js 就是專門處理對 Todo-list 所有操作邏輯的程式。

在撰寫邏輯前,先來制定這個 reducer 的初始狀態 initialArg

./src/reducers/TodoReducer.js
export const initState = {
todoList: [],
}

這邊最外層使用物件包裝,裡面包含 Todo-list 的陣列資料,也許在這個範例不需要這麼做,但這麼做的原因是想和大家說明,若這個 state 有擴充資料的需求,物件會比陣列方便許多。

接下來就是我們 reducer 的主程式 :

./src/reducers/TodoReducer.js
export const initState = {
todoList: [],
}

function TodoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
// do something...
case 'TOGGLE_TODO':
// do something...
case 'EDIT_TODO':
// do something...
case 'DELETE_TODO':
// do something...
default:
return state
}
}

export default TodoReducer

看到 case 對應的抽象描述字串,此時可以思考一下,萬一之後我們對這個抽象描述字串不滿意要更改,會需要一同更改所有使用到對應的 dispatch,非常麻煩,因此我們可以這麼做 :

./src/reducers/TodoReducer.js
export const ACTIONS = {
ADD_TODO: 'ADD_TODO',
TOGGLE_TODO: 'TOGGLE_TODO',
EDIT_TODO: 'EDIT_TODO',
DELETE_TODO: 'DELETE_TODO',
}

export const initState = {
todoList: [],
}

function TodoReducer(state, action) {
switch (action.type) {
case ACTIONS.ADD_TODO:
// do something...
case ACTIONS.TOGGLE_TODO:
// do something...
case ACTIONS.EDIT_TODO:
// do something...
case ACTIONS.DELETE_TODO:
// do something...
default:
return state
}
}

export default TodoReducer

將所有抽象描述字串使用物件封裝變數 ACTIONS,並且將它 export 方便我們在其他檔案引入給 dispatch 使用,而 case 所需要對應的字串直接呼叫該物件的 key,在撰寫的時候你會發現配合你的編輯器提示功能,更不容易打錯字了 !


Step 2 : 撰寫 reducer 裡面抽象描述的邏輯

接下來就開始為每個 action.type 撰寫對應的邏輯,這個步驟務必注意不能對 state 有任何 mutate 的行為,另外也建議每個 case 使用 block scope { } 包住,避免 case 裡面的變數與其他 case 的變數互相衝突。

新增 Todo :

./src/reducers/TodoReducer.js
// ...
function addTodo(todoContent = '') {
return {
id: crypto.randomUUID(),
content: todoContent,
completed: false,
}
}

function TodoReducer(state, action) {
const { payload } = action

switch (action.type) {
case ACTIONS.ADD_TODO: {
return {
...state,
todoList: [...state.todoList, addTodo(payload.content)],
}
}
// ...
}
}

export default TodoReducer

新增一個 addTodo 函式 return 新的 Todo 物件,並且將接下來操作有用的資料放在 payload 物件裡面。

完成 / 取消完成 Todo :

./src/reducers/TodoReducer.js
// ...
function TodoReducer(state, action) {
const { payload } = action

switch (action.type) {
// ...
case ACTIONS.TOGGLE_TODO: {
const newTodo = state.todoList.map(todo => {
return {
...todo,
completed: todo.id === payload.id ? payload.checked : todo.completed,
}
})

return {
...state,
todoList: newTodo,
}
}
// ...
}
}

export default TodoReducer

編輯 Todo :

./src/reducers/TodoReducer.js
// ...
function TodoReducer(state, action) {
const { payload } = action

switch (action.type) {
// ...
case ACTIONS.EDIT_TODO: {
const newTodo = state.todoList.map(todo => {
return {
...todo,
content: todo.id === payload.id ? payload.content : todo.content,
}
})

return {
...state,
todoList: newTodo,
}
}
// ...
}
}

export default TodoReducer

刪除 Todo :

./src/reducers/TodoReducer.js
// ...
function TodoReducer(state, action) {
const { payload } = action

switch (action.type) {
// ...
case ACTIONS.DELETE_TODO: {
const newTodo = state.todoList.filter(todo => todo.id !== payload.id)

return {
...state,
todoList: newTodo,
}
}
// ...
}
}

export default TodoReducer
TodoReducer.js 完整程式碼
./src/reducers/TodoReducer.js
export const ACTIONS = {
ADD_TODO: 'ADD_TODO',
TOGGLE_TODO: 'TOGGLE_TODO',
EDIT_TODO: 'EDIT_TODO',
DELETE_TODO: 'DELETE_TODO',
}

export const initState = {
todoList: [],
}

function addTodo(todoContent = '') {
return {
id: crypto.randomUUID(),
content: todoContent,
completed: false,
}
}

function TodoReducer(state, action) {
const { payload } = action

switch (action.type) {
case ACTIONS.ADD_TODO: {
return {
...state,
todoList: [...state.todoList, addTodo(payload.content)],
}
}

case ACTIONS.TOGGLE_TODO: {
const newTodo = state.todoList.map(todo => {
return {
...todo,
completed: todo.id === payload.id ? payload.checked : todo.completed,
}
})

return {
...state,
todoList: newTodo,
}
}

case ACTIONS.EDIT_TODO: {
const newTodo = state.todoList.map(todo => {
return {
...todo,
content: todo.id === payload.id ? payload.content : todo.content,
}
})

return {
...state,
todoList: newTodo,
}
}

case ACTIONS.DELETE_TODO: {
const newTodo = state.todoList.filter(todo => {
return todo.id !== payload.id
})

return {
...state,
todoList: newTodo,
}
}

default: {
return state
}
}

export default TodoReducer

Step 3 : 在 TodoList 元件引入 TodoReducer 使用

./src/components/TodoList.js
import TodoItem from './TodoItem'
import TodoReducer, { ACTIONS, initState } from './contexts/TodoReducer'

function TodoList() {
const [state, dispatch] = useReducer(TodoReducer, initState)
const todoInputRef = useRef(null)

// 新增 Todo
function addTodo() {
if (!todoInputRef.current.value.trim()) {
alert('請輸入內容')
return
}

dispatch({
type: ACTIONS.ADD_TODO,
payload: { content: todoInputRef.current.value },
})
todoInputRef.current.value = ''
todoInputRef.current.focus()
}

return (
<>
<div>
<input
type="text"
ref={todoInputRef}
onKeyDown={e => (e.code === 'Enter' ? addTodo() : null)}
/>
<button
type="button"
onClick={addTodo}
>
Add
</button>
</div>
<ul>
{state.todoList.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
dispatch={dispatch}
></TodoItem>
))}
</ul>
</>
)
}

export default TodoList

這邊將 dispatch 方法使用 props 的方式傳遞給 TodoItem 元件使用 :

./src/components/TodoItem.js
import { ACTIONS } from './contexts/TodoReducer'

function TodoItem({ todo, dispatch }) {
// 完成/取消完成 Todo
function toggleTodo(e, id) {
dispatch({
type: ACTIONS.TOGGLE_TODO,
payload: {
id,
checked: e.target.checked,
},
})
}

// 編輯 Todo
function editTodo(id, content) {
const editContent = prompt('請輸入修改內容', content) || content

dispatch({
type: ACTIONS.EDIT_TODO,
payload: {
id,
content: editContent,
},
})
}

// 刪除 Todo
function deleteTodo(id) {
dispatch({
type: ACTIONS.DELETE_TODO,
payload: {
id,
},
})
}

return (
<li key={todo.id}>
<div>
<input
type="checkbox"
onChange={e => toggleTodo(e, todo.id)}
/>
<span>{todo.content}</span>
<button
type="button"
onClick={() => editTodo(todo.id, todo.content)}
>
Edit
</button>
<button
type="button"
onClick={() => deleteTodo(todo.id)}
>
Delete
</button>
</div>
</li>
)
}

export default TodoItem

🎉 大功告成 !!

CodeSandbox 完整程式碼


寫一個使用 useState 模擬 useReducer 的 Custom Hook !

這也是官方文件 Extracting State Logic into a ReducerChallenges 第 4 題。

先準備一個簡單增減 count 的 reducer :

./src/reducers/CountReducer.js
const ACTIONS = {
INCREMENT: 'INCREMENT',
DECREMENT: 'DECREMENT',
}

const CountReducer = (state, action) => {
switch (action.type) {
case ACTIONS.INCREMENT: {
return { count: state.count + 1 }
}
case ACTIONS.DECREMENT: {
return { count: state.count - 1 }
}
default: {
return state
}
}
}

再來是 Custom Hook : useMyReducer

./src/hooks/useMyReducer.js
import { useState } from 'react'

function useMyReducer(reducer, initialState) {
const [state, setState] = useState(initialState)

function dispatch(action) {
const nextState = reducer(state, action)
setState(nextState)
}

return [state, dispatch]
}

export default useMyReducer

useMyReducer 會與 React 原生的 useReducer 一樣會有兩個主要參數,第一個參數是 reducer,也就是我們剛剛準備的 CountReducer,第二個參數是 initialState,會將它當作 useState 的初始值。

還記得我們在前面提到的快照 ( snapshot ) 嗎 ? 裡面的 dispatch 方法就是利用執行 reducer(state, action) 來獲取下一次的 state 快照,然後再使用 setState 改變 state 並回傳。

如此一來,就可以應用在我們的元件上囉 !

import CountReducer from './reducers/CountReducer'
import useMyReducer from './hooks/useMyReducer'

const initialCount = { count: 0 }

function App() {
const [state, dispatch] = useMyReducer(CountReducer, initialCount)

return (
<div>
<button
type="button"
onClick={() => dispatch({ type: 'DECREMENT' })}
>
-
</button>
<span>{state.count}</span>
<button
type="button"
onClick={() => dispatch({ type: 'INCREMENT' })}
>
+
</button>
</div>
)
}

CodeSandbox 完整程式碼


總結

以下情況可以考慮使用 useReducer :

  • 元件內使用到很多 useState 來處理相似的邏輯操作。
  • 想要更有條理的管理資料狀態。
  • 不同層級的元件使用到 Context,而 Context 的資料狀態需要被操作時。

希望這個筆記對大家有幫助,如果有錯誤或不夠清楚的地方也歡迎在底下留言讓我改進 (-ω-ゞ


Reference