跳至主要内容

[note] 後 Redux 時代!?留意那些過去常被視為理所當然的事

過去多數人在開發大型 React 專案時,Redux 幾乎是不可獲缺的選項,但當 React 支援 context 和 custom hooks 後,許多人開始反思專案中整合 Redux 的必要性——太多重複的程式碼、零碎的變數和檔案、較高的學習曲線等等,進而使得許多開發者開始提倡用 useContext + useReducer 來取代 redux。

在這篇文章中,並不會給予要不要整合 Redux 到專案中的建議,但會從自身經驗中分享那些過去使用 Redux 開發時,覺得理所當然,卻在移除 Redux 後才意識到的部分,並提出替代方案,最後再由讀者自行評估是否從專案中移除或導入 Redux。

在開始這篇文章前想要先問問讀者:

  • 如果有一個中大型的專案,你會想要使用 Redux 嗎?
  • 如果不會的話,是什麼原因讓你不想要用 Redux?如果會的話,又是什麼原因?
  • 如果要替換掉 Redux 的話,你第一個會想到的是什麼工具?
資訊

本篇文章可以搭配 you-should-know-without-redux 的實作閱讀。

Redux 的主要任務

Redux 一開始是個單純的「狀態管理工具」,解決了 React 中需要透過 prop drilling 的方式來將狀態傳來傳去,並透過 reducer 避免開發者直接 mutate 資料,以減少可能的錯誤並且能追蹤每次資料的變化。

Redux 本身並不支援非同步的資料處理,但因為在前端的資料很難不透過 API 從後端取得,為了解決非同步的問題,有了 redux-thunk、redux saga 和 redux observable 這些用來處理非同步資料請求的工具。在這些工具中,實際上 redux-thunk 不太能作為「非同步資料請求」的工具,它的性質還是比較適合拿來處理簡單基本的「非同步」操作時使用,這點會在稍後做說明。

總結來說,過去 Redux 的使用扮演了兩個重要的任務:

  • 資料狀態管理:包含「全域」和「模組內」的資料傳遞,避免惱人的 prop drilling
  • 非同步資料請求:透過 API 取得資料後,整合到資料狀態中

現在就讓我們來看一下,拿掉了 Redux 之後,在這兩個主要任務上,有什麼需要留意的地方。

資料狀態管理:常被忽略的問題

避免過多的 Prop Drilling

Redux 的其中一個好處是可以避免過度的 Prop Drilling,透過 selector 可以在任何元件取出所需要的資料。

- App
--- Todos (todos)
----- TodoList (todos)
------- TodoItem (todo)
--------- ToggleTodo (todo.id)

可以參考這張 示意圖 @ How to avoid Prop-drilling in Angular

避免不必要的 rerender

假設現在有一個調色盤的模組 <App />,裡面可以調整不同顏色的比重,每個顏色的調整各是一個獨立的元件,上方 <Display /> 的地方則會顯示最終的顏色。每個顏色當前的值會顯示在方框中,右上角則會顯示重新 render 的次數:

without-redux

它的資料狀態會像是這樣:

const colorState = {
red: 0,
blue: 0,
green: 0,
};

這是用 Redux 寫起來的效果(Redux Demo),每個調色的元件間各自獨立,調整紅色時並不會導致藍色或綠色重新轉譯:

prevent unnecessary rerender

看起來非常自然,沒什麼特別的對吧!?

但這裡我們要留意到,colorState 是一個物件,也就是說即使只是想要改變其中一個顏色的值,實際上它會是一個全新的物件。

也就是說,在只使用 React 且不做優化的情況下(例如,使用 memo),因為資料都是來自父層的物件,因此只要調整任何一個顏色,都會導致另外兩個顏色的調色盤也重新轉譯(React Demo):

prevent rerender

也就是說,過去在使用 Redux 時,我們可能不自覺的解決了這個避免重複轉譯(rerender)的問題。這裡看起來可能微不足道,但當資料量大或是每個 component 的資料在呈現時需要重新運算時,這樣的影響就非常巨大。

資料狀態管理:可能的解決方案

Think

那麼是什麼原因,在資料來源同樣是物件的情況下,Redux 可以減少不必要的 rerender 呢?

這其實有賴於 redux 提供的 useSelector

import { useSelector } from 'react-redux';

// 只有該對應的 color 改變時,該 component 才會 rerender
const count = useSelector((state) => state.counter[color]);
think

現在讓我們想想,在拿掉了 Redux 作為狀態管理的工具後,能夠用什麼作為替代方案呢?

useContext + useReducer

許多開發者鼓勵用 useContextuseReducer 來取代 Redux,如果是針對 Props Drilling 這點,透過 Context API 的確可以獲得解決良好的解決。

然而如果是要避免不必要的 rerender 這點,因為 Context 本身並沒有 selector 的機制,除非使用額外類似 use-context-selector 這種工具,否則一樣無法解決重複 render 的問題(Context Demo)。

另外,為了避免不必要的重新轉譯,使用 context 時不能把資料放在同一個物件中,例如:

// 不要這麼做
const contextState = {
applicationNumber: 'A0000000012',
insuredSubject: {},
product: {},
quotation: {},
};

這麼做會使得當 contextState 中有任何資料改變時,所有有用到這個 contextState 的 component 都會 rerender。比較好的方式是先將這些資料進行分類,然後建立多個不同 context:

const applicationContextState = {};
const insuredSubjectContextState = {};
const productContextState = {};
const quotationContextState = {};

但這麼做就會需要包很多層的 Provider。可能會在最外層的 <App /> 先包了許多不同的 Provider,在特定模組中又包一層該模組所需的 Provider:

// module 內共用的資料狀態
const ApplicationModule = () => {
return (
<ApplicationProvider>
<InsuredSubjectProvider>
<ProductProvider>
<QuotationProvider>
<YourApp />
</QuotationProvider>
</ProductProvider>
</InsuredSubjectProvider>
</ApplicationProvider>
);
};

// global 共用的資料狀態
const App = () => {
return (
<AuthenticationProvider>
<ApplicationModule />
</AuthenticationProvider>
);
};

實在不如使用 redux 時只包一個 store 來的乾淨俐落:

import { Provider } from 'react-redux';

const App = () => {
return (
<Provider store={store}>
<YourApp />
</Provider>
);
};

此外,除了上述提到需要包許多層 Provider 的問題之外,使用 Context 時,有可能一不小心就回到 redux 被許多人詬病的 boilerplate 的問題。

目前許多開發者使用的 useContext + useReducer 的 pattern 會像這樣:

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

const initialState = {
red: 0,
blue: 0,
green: 0,
};

export const CountContext = createContext(initialState);

export const countReducer = (state, action) => {
const { color, value } = action.payload;
switch (action.type) {
case 'increment': {
return {
...state,
[color]: state[color] + 1,
};
}
case 'decrement': {
return {
...state,
[color]: state[color] - 1,
};
}
case 'update': {
return {
...state,
[color]: value,
};
}
default:
return state;
}
};

const CountProvider = ({ children }) => {
const [state, dispatch] = useReducer(countReducer, initialState);
const value = { state, dispatch };
return <CountContext.Provider value={value}>{children}</CountContext.Provider>;
};

const useCount = () => {
const ctx = useContext(CountContext);
if (ctx === undefined) {
throw new Error('useCount must be used with a CountProvider');
}

return ctx;
};

export { CountProvider, useCount };

在父層 component 可以簡單的使用 Provider 包起來:

import { CountProvider } from './features/colorPaletteContext/counterContext';

const App = () => (
<CountProvider>
<ColorPaletteContext />
</CountProvider>
);

而在內部需要使用到 Context 資料的地方則可以簡單使用 useCount 這個 hook 來取得和改變 Context 中資料:

import { useCount } from './counterContext';

const ColorPalette = () => {
const { state, dispatch } = useCount();
// ...
};

然而,值得思考的是,這麼做是否真的解決 redux 中大量 boilerplate 的問題。

Recoil

recoil 是目前 facebook 實驗性的一個專案,它是專門為 react 所做的 state management,官方是這麼寫的:

A state management library for React

也因為是 Facebook 自己的產品,它最大化支援 concurrent mode 等其他新的 feature,有興趣的話,讀者可以看 recoil/motivation 的部分。

個人認為它解決了上述不論是 Redux 或 React Context 的問題。

和 Redux 一樣,它只需在最外層掛一個 Provider:

import { RecoilRoot } from 'recoil';

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

它的基本用法非常簡單,就好像直接使用 React 原生的 useState 一樣

import { useRecoilState } from 'recoil';

const counterState = atom({
key: 'counterState',
default: initialState,
});

// In the React component
const [count, setCount] = useRecoilState(counterState);

更重要的是,它也支援 selector 的作法,也就是說,它可以只取出需要的資料,只有這個資料改變時,這個 component 才會 rerender:

import { useRecoilValue } from 'recoil';

// 只取出特定顏色的資料來用
const count = useRecoilValue(colorCounterSelector(color));

如果回到最開始的例子,也就是說,切換其中一個顏色時,並不會導致另外兩個調色盤的元件重新轉譯(Recoil Demo)。

Recoil 除了能用來作為資料狀態管理之外,因為也支援 concurrent mode,所以內建就整合了非同步的 API 請求,但這個部分因為相對前衛,目前用起來的問題稍多,會在後面再做說明。但如果單純作為一個狀態管理工具的話,我認為是非常方便好用的。

非同步資料請求:常被忽略的問題

在 Web Fetch API 推出之後,大幅降低了使用 AJAX 呼叫 API 的複雜度,許多開發者開始不再依賴第三方套件而是直接使用原生的 Fetch API 來呼叫 AJAX 請求。

同樣的,過去在 React 中如果要呼叫 API 並針對 API 回傳的資料進行狀態管理,常會依賴像是 redux-thunk、redux-saga、redux-observable 這類的工具,也因為 Redux 本身沒辦法處理非同步的資料處理,所以搭配這些工具常常覺得再自然不過。

然而隨著 react hooks 和 fetch API 的普及,要在 React 的 component 中呼叫 API,並直接使用 API 回傳的資料到 component 中使用變得非常容易,我們幾乎可以在到處看到這樣的範例寫法:

function UserProfile({ userId }: Props) {
const [user, setUser] = useState<IUser | undefined>();

useEffect(() => {
const fetchData = async () => {
const data = await fetchUsers('/users', userId);
setUser(data);
};
fetchData();
}, [userId]);

return {
/* ... */
};
}

看起來很簡便,也不太需要依靠第三方套件或處理非同步請求的狀態管理工具,但實際上,當我們自己在 component 中發送 API 請求時,有一些需要特別留意的咩咩角角,許多是被我們視為理所當然,但實際上是第三方工具幫我們處理好的。

dedupe:避免重複打相同的 API

dedupe 是 deduplication 的縮寫,也就是不要重複的意思,這點在處理 API 請求時是非常重要的一點。

以下圖為例,假設使用者正在檢視個人資料頁,這裡有兩個 component,一個是上方的 Navbar 需要知道使用者的登入狀態和基本資料、另一個是下方的 UserProfile,用來呈現該使用者的資料。

這時候 Navbar 和 UserProfile 這兩個 component 都需要打同一隻 API。如果我們是直接把 fetch User API 的方法各自寫在 Navbar 和 UserProfile 時,會發生什麼事呢?

dedupe

如同預期的,Navbar 和 UserProfile 這兩個 component 會各自打各自的 API,也就是說,同樣的 API 會在使用者進來這頁時被重複呼叫兩次,並且得到同樣的資料。雖然感覺只是多打一次好像還好,但換成倍數的話,光是這頁 BE 就需要沒有意義的多承受兩倍的負擔。

以下圖為例,如果我們在同一個頁面在不同元件呼叫相同的 API 時,API 請求會重複的被發出(no dedupe DEMO):

with pure fetch

Race Condition:避免因非同步顯示了錯誤的資料內容

這個問題會發生在資料回來的時間點不同時,例如使用者一開始請求 User1 的資料,後來發現要看的是 User2,這時候同時發出了 User1 和 User2 的 API 請求,理想上我們會期望 User1 的資料先回來後,User2 的資料才會回來,但千萬要記得「非同步的世界是很危險滴!」,實際上 API 卻先回了 User2 的資料,這時候會發生什麼事呢?

recoil race condition

圖片來源:learn recoil

以實際的例子來看,若在短時間內快速發出多個 User 資料的請求(分別是 UserId 1 ~ 4),如果 UserId 2 的資料比 UserId 4 還慢回來時,畫面上會顯示 UserId 幾的資料呢?

Think

如果 UserId 2 的資料比 UserId 4 還慢回來時,畫面上會顯示 UserId 幾的資料呢?

我們會期望畫面上顯示的應該要是 UserId 4 的使用者資料,但實際上從結果我們可以看到,畫面上最終顯示的卻是 UserId 為 2 的使用者,而不是 UserId 4 的使用者(race condition DEMO),之所以如此,是因為我們有個「錯誤的」假設是「先發出請求的資料會先回來」

race condition of AJAX

之所以會這樣,是因為 UserId 2 的資料比 UserId 4 的資料還晚回了最後才回來,最後一個資料回來後,會觸發 React 重新轉譯,進而使得畫面上顯示的是 UserId 2 的使用者。

非同步資料請求:可能的解決方案

Redux Sage 或 Redux Observable

上述這些情況如果是使用 Redux 搭配 Redux Saga 或 Redux Observable,都有機會在 middleware 被處理掉,但要留意的是,如果你只是用 redux 中 RTK(redux toolkit)內建的 redux-thunk,那就和自己在 component 發送 API 請求一樣,對於上述情況是完全沒有幫助的

Recoil

前面有提到,Recoil 處理能作為狀態管理之外,也能用來當作非同步資料處理的工具,同時它也會處理掉上面提到的 race condition 的問題。由於它已經最大化整合了 React concurrent mode,所以預設就會使用 <Suspense /><ErrorBoundary /> 這些功能。

這樣前衛的功能是它的特色卻同時也是它的缺點,由於 concurrent mode 在目前 React 17 還是實驗性質的,進而連帶導致 recoil 中有許多的 API 也掛著 UNSTABLE_ 的前綴。再加上主流的 SSR 方案(Next.js)也還不支援 Suspense 這類 concurrent mode 的使用(同樣是實驗性質,需手動啟用),進而使得用 recoil 仍不適合作為非同步資料請求的主要工具,但若是單純作為全域或模組內的狀態管理則沒什麼問題。

未來 React 18 的 concurrent mode 穩定後,加上主流 SSR 方案跟上後,非常期待 recoil 在非同步的資料請求上能有更突破的進展。

SWR、React Query、RTK Query

這裡我們以 SWR 為例來說明,但這裡列的工具都有對上述的問題進行處理:

import useSWR from 'swr';
import { fetchUsers } from './user';

const { data, error } = useSWR(['/users', id], fetchUsers);

SWR 在呼叫 API 請求時,實際上這些請求的內容會先經過「中央」,所以 SWR 的 dedupe 機制就會避免在短時間內,對同樣的 API endpoint 呼叫多次帶有內容相同的請求,接著把該次請求得到的結果保存在「中央」,讓所有需要使用此資料的元件都可以取用的到,以此來避免了重複發生相同 API 的問題(dedupe DEMO)。

可以看到使用了 SWR 之後,同樣 endpoint 和內容的 request 不會被重複計算發出:

dedupe request

SWR 同樣會協助處理 race condition 的問題,確保畫面上顯示的資料是使用者當前所選擇的,而不會因為 API 回傳的時間問題而導致畫面上呈現錯誤的資料(race condition DEMO)。

可以看到使用了 SWR 之後,左側的 UI 不會因為 UserId 2 的資料比較晚回來而顯示錯誤的資料:

race condition

SWR 是透過 cache 的方式,根據畫面上當前要顯示的資料的 key 從 cache 中把資料取出,以此避免 API 資料回應時間不同的問題。舉例來說,當畫面上的 userId 是 4 時,就只會從 cache 中去拿 key = 4 的資料,其他的還沒回來的請求就不理會了:

key (userId)Promises
1User 1
2User 2
3User 3
4User 4

讀者如果有興趣的話,可以另外參考 Kyle 的 了解 SWR 的運作機制,How this async state manager works ? @ OneDegree Tech Blog。

總結

過去似乎理所當然的使用了 React + Redux 的組合,有些沒料想到的問題,在 Redux 默默幫開發者處理掉後也就忽略了,直到拿掉了 Redux 之後,那些視為理所當然的使用情境才發現要開始尋找替代方案或更好的作法。

不論最終你的專案決定要不要整合 Redux,這些問題都是 Web App 會碰到的,認識和了解這些問題更能夠幫助開發者找出不同的解決方案,而非盲目的把東西拆掉後以為解決了問題,卻衍生更多問題。