iT邦幫忙

2024 iThome 鐵人賽

DAY 23
0
自我挑戰組

React 開發者的 TypeScript 探索之旅系列 第 23

【 Day 23 】使用 useContext、useReducer 優化資料管理(二)

  • 分享至 

  • xImage
  •  

本系列文章 GitHub

今天我們要持續優化資料管理的部分,目標是使用 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

在前面我們有提到,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
  }
}

創建 Context

因為 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,
})

Provider

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 中,這樣就可以讓所有的子元件(如 TodoListCreateTodo ...等)直接使用 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

刪除原本在 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,因此不再需要傳遞 todosTodoList

<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

TodoList

刪除 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 應該能運作如初。


上一篇
【 Day 22 】使用 useContext、useReducer 優化資料管理(一)
下一篇
【 Day 24 】加入編輯功能
系列文
React 開發者的 TypeScript 探索之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言