利用 React Context API + useReducer 實作 Redux

前言

在 React 專案中,通常會使用 props 與 state 來進行元件間的資料傳遞與狀態管理。這種方法簡單易懂,讓資料傳遞過程清楚透明。然而,隨著專案規模的擴大,元件間的溝通變得更加複雜,使用 props 和 state 來管理資料變得困難,例如遇到 (1) prop drilling、(2) 難以集中管理與維持資料一致性等問題。此外,(3) 處理非同步請求和 (4) 重新渲染等效能問題也變得困難。

為了解決這些問題,通常會採用 Redux 或利用 context API + useReducer 等方法來進行資料傳遞與狀態管理。這些工具提供了更有效的方式來處理資料傳遞、狀態管理和處理效能議題,這在大型專案中尤為重要。

Redux 和利用 context API + useReducer 的資料傳遞與狀態管理方法,都是基於 Flux 的概念。Flux 的目標是維持資料的一致性,使資料只能單向流動。具體來說,是將資料集中儲存在 store 中,view 透過 action 觸發 dispatcher 來更新 store 的狀態,當狀態改變時,重新渲染 view。這樣設計的好處是 (1) 解決 prop drilling 問題,避免狀態的層層傳遞;(2) 使用 reducer 來處理狀態變化,避免直接修改資料,減少錯誤,並能夠追蹤資料的變化。這種設計能夠更有效地管理和控制狀態。

以下是 Redux data flow 概念圖。

Redux data flow

大家應該都對 Redux 很熟悉,並且本篇文章主要想要聊聊怎麼利用 React Context API 與 useReducer 來實現類似 Redux 的功能,那先來了解一下 Context API 和 useReducer 是什麼吧。

Context API

來看怎麼儲存和傳遞資料,在這裡會用 Context API 來處理這部份。

Context API 是 React 提供的一種機制,用於在元件中共享全域的資料,讓元件可以直接存取這些資料,而不需要透過狀態設定和屬性的層層傳遞。

使用 Context API 的方法很簡單,首先利用 createContext 建立一個 context 物件,然後以向下廣播的方式將這個 context 從 parent component (provider) 傳遞到 child component。在 child component 中使用 useContext 來取得 context 的值 (consumer),再根據需求對這個值做出相應的反應。相較於 props,Context API 不需要將大量的資料細節層層傳遞下去,讓程式碼更簡潔且易於維護。

簡單範例如下。

import React, { createContext, useContext } from 'react';

const MyContext = createContext();

const ParentComponent = () => {
  const data = 'Hello, Context!';

  return (
    <MyContext.Provider value={data}>
      <ChildComponent />
    </MyContext.Provider>
  );
}

const ChildComponent = () => {
  const value = useContext(MyContext);
  return <div>{value}</div>;
}

const App = () => {
  return <ParentComponent />;
}

在這個範例中使用 createContext 建立 MyContext。在 <ParentComponent> 中,使用 MyContext.Provider 作為資料的提供者,將要共享的資料 'Hello, Context!' 傳入 value。在 <ChildComponent> 中,使用 useContext 來取得 MyContext 的值,這樣就能直接共享全域的資料,而不需要透過屬性的層層傳遞。最後,在 <App> 中將 <ParentComponent> 渲染到畫面上。當 <ChildComponent> 渲染時,它會直接存取 MyContext 的資料並將其顯示在畫面上。這種方法避免了屬性的層層傳遞,使程式碼更加簡潔且易於維護。

了解了怎麼儲存和傳遞資料之後,接著要來看怎麼更新資料,在這裡會用 useReducer 來做狀態的更新。

useReducer

再來,實作處理管理與更新狀態的部份。

useReducer 是 React 提供的一個 hook,可用於處理較複雜的狀態邏輯。透過 useReducer 能夠管理元件的狀態並進行相應的狀態更新。useReducer 會回傳一個包含當前狀態和 dispatch 函式的陣列,我們可以使用 useReducer 的 state 來存放狀態,並透過呼叫 dispatch 函式與帶入相對應的 action 和 payload 來完成狀態的更新。簡而言之,useReducer 提供了一種簡易版的狀態管理系統,讓我們能夠更有效地處理複雜的狀態邏輯。

簡單範例如下。

import { useReducer } from 'react';

const initialState = { count: 0 };

const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
};

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>
        +
      </button>
      <button onClick={() => dispatch({ type: 'decrement' })}>
        -
      </button>
    </div>
  );
};

在這個範例中定義了初始狀態 initialState,並實作了一個 reducer 函式,根據不同的 action 類型和 payload 來更新狀態。在 <Counter> 元件中,使用 useReducer hook,將初始狀態和 reducer 函式作為參數傳入,並得到當前的狀態和 dispatch 函式。透過點擊 +- 按鈕來呼叫 dispatch 函式並傳入對應的 action,從而更新狀態。透過 useReducer 能夠更好地組織和管理元件的狀態邏輯,特別是在處理複雜的狀態轉換時,它提供了一種更好讀也更好維護的解法。

手作 Redux

來將 React Context API + useReducer 結合起來,實現 Redux 功能吧!我們將做一個簡單的範例,在點擊按鈕後顯示 “Hello World!” 的訊息。

點擊按鈕之前。

利用 React Context API + useReducer 實作 Redux

在點擊按鈕之後,會顯示 Hello World! 的訊息。

利用 React Context API + useReducer 實作 Redux

完整的範例程式碼,可相互搭配比較

實作過程會按照以下幾個步驟進行。

範例如下。

export const ACTION_TYPE = {
  SET_MESSAGE: 'SET_MESSAGE'
};

const initState = {
  message: '',
  xxoo: 'abc'
};

const helloReducer = (state = initState, action) => {
  switch (action.type) {
    case ACTION_TYPE.SET_MESSAGE:
      return {
        ...state,
        message: action.payload.message
      };
    default:
      return state;
  }
};

export { helloReducer };

由於通常會有多個 reducer,因此需要使用 combineReducer 函數。在 reducers 中將 combineReducer 函數放置其中,以將所有的 reducer 合併在一起。這樣可以確保狀態的集中管理,而不會散佈在各處,這也能達成取得的狀態是同一份資料,避免不一致的問題。

combineReducer 函數執行以下幾個步驟…

範例如下。

import { helloReducer } from './helloReducer';

const combineReducer = (reducers) => {
  const reducerKeys = Object.keys(reducers);
  let objInitState = {};

  reducerKeys.forEach((key) => {
    objInitState[key] = reducers[key](undefined, { type: '' });
  });

  return (state, action) => {
    if (action) {
      reducerKeys.forEach((key) => {
        const previousState = objInitState[key];
        objInitState[key] = reducers[key](previousState, action);
      });
    }
    return { ...objInitState };
  };
};

const reducers = combineReducer({
  hello: helloReducer,
});

export { reducers };

再來,實作資料傳遞的 context provider,利用廣播的方式將資料傳遞給元件。實作的步驟如下…

範例如下。

import { createContext, useReducer } from 'react';
import { reducers } from './reducers';

const ReducerContext = createContext(null);
export { ReducerContext };

const initState = reducers();

const ReducerContextProdiver = ({ children }) => {
  const reducer = useReducer(reducers, initState);

  return (
    <ReducerContext.Provider value={reducer}>
      {children}
    </ReducerContext.Provider>
  );
};

export default ReducerContextProdiver;

然後在最外面的用 <ReducerContextProvider> 包好下面要廣播的元件們,像是 <HelloWorld />

<ReducerContextProvider>
    <HelloWorld />
</ReducerContextProvider>

當元件 (例如 <HelloWorld />) 要用到已存好的狀態時,就用 useContext 把 ReducerContext 裡面的值取出來,像是以下這樣。

const [state, dispatch] = useContext(ReducerContext);
console.log(state.hello.message); // "Hello World"

這樣就大功告成了!

推薦這篇文章,對於如何利用 useContext + useReducer 實作 Redux 有很詳細的說明,還有附上 todo list 範例。

雖然 context API + useReducer 作為狀態管理的解法是很棒的,但並不是萬能,可參考這篇那篇,探討各種狀態管理的疑難雜症以及效能問題與解法。

效能

提到效能,通常會用兩個方向來衡量

測量 loading performance,FCP 和 LCP 應小於 2 秒才是優良。

Metrix Context API + useReducer Redux
FCP, LCP 1102.3 ms 1167.6 ms

測量 rendering performance,檢測執行時間和 re-render 的次數。

Scenario Context API + useReducer Redux
Initial loading Re-render 2 times Re-render 2 times
After click the button to show message Re-render 2 times Re-render 2 times
Duplicate clicking Do re-render (1) Do not re-render
Execution time (2) 83ms 82ms

由以上比較可知,兩者並無太大差異。

總結

常有人問要選用 Redux 還是利用 context API + useReducer 來做狀態管理呢?context API + useReducer 能完全取代 Redux 嗎?這取決於專案需求和技術狀況。根據個人的實作經驗,以下提供一些小小的想法…

最近剛好遇到在做一個 monorepo 架構的專案,除了我手邊的 package 需要用到狀態管裡外,其他 package 都用不到,因此在衡量的其他人不用到+需要極度輕量+使用情境單純的狀況下,而選用 context API + useReducer 自製 Redux 的解法。

最後,選擇 Redux 還是 context API + useReducer 取決於專案的具體需求和使用情境,大家可根據實際情況做出適合的選擇!


react hooks react.js redux Web Vitals Loading Performance Rendering Performance front-end architecture Core Web Vitals 加載效能 效能調校 sharing