今天我們要持續優化資料管理的部分,目標是使用 useContext
搭配 useReducer
,將新增與刪除功能改為使用全域狀態管理。
目前 Todo List 中使用 useState
管理的狀態只有 todos
以及 messageDetails
,像 messageDetails
這樣只涉及少數元件的狀態,使用局部管理可以避免不必要的全域依賴,也能保持程式碼的簡潔,我們在這邊選擇維持原本的局部狀態管理,只將應用於多個元件的 todos
提出。
useReducer
的基本用法:
const [state, dispatch] = useReducer(reducer, initialState);
state
表示的是狀態,這邊與 useState
的狀態意思是一樣的。dispatch
用來觸發 reducer
函式,dispatch
會接收 action
為參數,並把這個參數傳遞給 reducer
函式。reducer
是一個純函式,接受兩個參數:當前的狀態和一個 action
。根據 action
的類型,reducer
函式會決定如何更新狀態。initialState
為狀態的初始值,類似於 useState
中的初始值。昨天我們已經定義好 action
的型別,現在我們要來定義狀態與初始狀態的型別,這是我們原本在 App.tsx
定義的型別:
const [todos, setTodos] = useState<TodoItem[]>([])
移到 TodoContext.tsx
檔案後,會有一個小地方不太一樣,關於初始狀態的型別,在這邊我們多用了一個物件把它包起來,這是考慮到將來擴充可能的寫法,而這樣的寫法也與 StateType
一致,在閱讀性上也是比較好的:
// 定義狀態
type StateType = {
todos: TodoItem[]
}
// 初始狀態
const initialState: StateType = {
todos: [],
}
在前面我們有提到,reducer
接受兩個參數,分別為當前的狀態和一個 action
,搭配我們剛剛定義好的型別分別指定,reducer
是用來更新狀態的函式,因此它返回的內容會是狀態,狀態的型別為 StateType
,整理後如下:
const todoReducer = (state: StateType, action: ActionType): StateType => {}
接著,我們要來處理功能的部分,原本寫在 App.tsx
的新增以及刪除邏輯如下:
// 新增
const newTodo: TodoItem = {
id: Math.random(),
title: title,
isFinished: false,
}
setTodos((prevTodos) => [...prevTodos, newTodo])
// 刪除
const deleteTodoHandler = (id: number) => {
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id))
}
將它們搬入 reducer
,並根據 action.type
更新狀態,先前有提到為了擴展性把 todos
包進了物件,因此需要稍微改變狀態更新的寫法,使用 ...state
是為了保留原有的狀態,確保除了 todos
外,其他狀態不會被覆蓋:
const todoReducer = (state: StateType, action: ActionType): StateType => {
switch (action.type) {
case 'ADD_TODO': {
const newTodo: TodoItem = {
id: Math.random(),
title: action.payload,
isFinished: false,
}
return { ...state, todos: [...state.todos, newTodo] }
}
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload),
}
default:
return state
}
}
因為 dispatch
是用來觸發狀態更新的核心函式,我們希望將其提供給應用中的所有子元件,這樣它們都可以根據需要觸發相應的 action
。因此 dispatch
與 狀態都需要存放在 context
內。
在使用 createContext
的時候,泛型的目的是用來定義 context
的預期數據結構,以確保在使用 useContext
時可以有正確的型別推斷。而當你初始化 createContext
時,必須提供一個初始值來滿足這個定義的型別。
初始化 createContext
時,必須提供一個符合這個型別的初始值。這就是為什麼在這裡提供了 state: initialState
和一個空的 dispatch
函式,我們使用 dispatch: () => null
作為初始值,只是為了滿足 TypeScript 的型別定義,在實際應用中,dispatch
會在 Provider
中透過 useReducer
正確賦值:
export const TodoContext = createContext<{
state: StateType
dispatch: Dispatch<ActionType>
}>({
state: initialState,
dispatch: () => null,
})
context
的內容需要透過 Provider
來提供,因此我們需要在這邊定義 Provider
並匯出:
export const TodoProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(todoReducer, initialState)
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
)
}
接著,在 main.tsx
中引入 TodoProvider
,將 App
包裹在 TodoProvider
中,這樣就可以讓所有的子元件(如 TodoList
、CreateTodo
...等)直接使用 useContext
來存取 todos
狀態:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { TodoProvider } from './store/TodoContext.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TodoProvider>
<App />
</TodoProvider>
</StrictMode>
)
目前完整的 TodoContext.tsx
如下
import { createContext, Dispatch, ReactNode, useReducer } from 'react'
export type TodoItem = {
id: number
title: string
isFinished: boolean
}
export type MessageDetails = {
visible: boolean
message: string
mode: 'error' | 'success'
}
// 定義 action
type ActionType =
| { type: 'ADD_TODO'; payload: string }
| { type: 'DELETE_TODO'; payload: number }
// 定義狀態
type StateType = {
todos: TodoItem[]
}
// 初始狀態
const initialState: StateType = {
todos: [],
}
// reducer
const todoReducer = (state: StateType, action: ActionType): StateType => {
switch (action.type) {
case 'ADD_TODO': {
const newTodo: TodoItem = {
id: Math.random(),
title: action.payload,
isFinished: false,
}
return { ...state, todos: [...state.todos, newTodo] }
}
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload),
}
default:
return state
}
}
// 創建 Context
export const TodoContext = createContext<{
state: StateType
dispatch: Dispatch<ActionType>
}>({
state: initialState,
dispatch: () => null,
})
// Provider
export const TodoProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(todoReducer, initialState)
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
)
}
刪除原本在 App.tsx
中的 todos
狀態定義,並在元件內引入 TodoContext
,改為使用 useContext
取得狀態:
const { state,dispatch } = useContext(TodoContext)
將原本的新增邏輯:
const newTodo: TodoItem = {
id: Math.random(),
title: title,
isFinished: false,
}
setTodos((prevTodos) => [...prevTodos, newTodo])
改為使用 dispatch
更新:
dispatch({ type: 'ADD_TODO', payload: title });
接著,將原本的刪除邏輯:
const deleteTodoHandler = (id: number) => {
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id))
}
也改為使用 dispatch
更新:
const deleteTodoHandler = (id: number) => {
dispatch({ type: 'DELETE_TODO', payload: id })
}
現在我們改為使用 useContext
取得 todos
,因此不再需要傳遞 todos
給 TodoList
:
<TodoList onDeleteTodo={deleteTodoHandler} />
目前完整的 App.tsx
如下:
import './App.css'
import Header from './components/Header'
import logo from './assets/logo.png'
import { useContext, useState } from 'react'
import TodoList from './components/TodoList'
import CreateTodo from './components/CreateTodo'
import Message from './components/Message'
import { TodoContext, type MessageDetails } from './store/TodoContext'
function App() {
const { dispatch } = useContext(TodoContext)
const [messageDetails, setMessageDetails] = useState<MessageDetails>({
visible: false,
message: '',
mode: 'error',
})
// Create Todo Handler
const createTodoHandler = (title: string) => {
if (title.trim().length === 0) {
setMessageDetails({
visible: true,
message: 'Input cannot be empty!',
mode: 'error',
})
return
}
dispatch({ type: 'ADD_TODO', payload: title })
setMessageDetails({
visible: true,
message: 'Todo created successfully!',
mode: 'success',
})
}
// Delete Todo Handler
const deleteTodoHandler = (id: number) => {
dispatch({ type: 'DELETE_TODO', payload: id })
}
return (
<main className='w-[500px] h-[100dvh] portrait:w-[90%] flex flex-col'>
<Header image={{ src: logo, alt: 'logo' }}>
<h1>Todo List</h1>
</Header>
<CreateTodo onCreateTodo={createTodoHandler} />
<TodoList onDeleteTodo={deleteTodoHandler} />
<Message
visible={messageDetails.visible}
mode={messageDetails.mode}
message={messageDetails.message}
onMessageVisible={setMessageDetails}
/>
</main>
)
}
export default App
刪除 props
中的 todos
,改為使用 useContext
取得:
const { todos } = useContext(TodoContext).state
完整程式碼如下:
import Todo from './Todo'
import { TodoContext } from '../store/TodoContext'
import { useContext } from 'react'
type TodoListProps = {
onDeleteTodo: (id: number) => void
}
export default function TodoList({ onDeleteTodo }: TodoListProps) {
const { todos } = useContext(TodoContext).state
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} className='list-none'>
<Todo
isFinished={todo.isFinished}
id={todo.id}
onDelete={onDeleteTodo}
>
<p>{todo.title}</p>
</Todo>
</li>
))}
</ul>
)
}
打開瀏覽器,你的 Todo List 應該能運作如初。