跳至主要内容

Async Reducer

https://redux.js.org/usage/writing-logic-thunks#thunk-overview
https://redux.js.org/tutorials/essentials/part-5-async-logic

Reducer 預設是同步的操作,若要執行非同步邏輯,會需要使用其他的 method、Middleware。

備註

「dispatch 是同步機制」,不過經由處理帶有延遲或者非同步的 reducer 時,會讓人誤以為 dispatch 為非同步。我的誤解來自於,dispatch 的行為跟 useState setState 很像,但其實完全不一樣。
dispatch 是因為 Middleware 在中間的操作 ,看起來執行時機像非同步,實際上,非同步邏輯是由 Middleware 處理。

Redux Middleware

藉由 dispatch 到 reducer 這段路程之間,加入額外的邏輯,Redux Middleware 可以做的事
-- action 觸發時,執行 reducer 前先跑額外的邏輯
-- 暫停、停止、更改、延遲、取代 被出發的 action
-- 可以取得當下的 dispatchgetState ,攔截 dispatch 。除了原有的 action object 外,可以跑非同步、函數等。原本的 dispatch 只能接受Action 純物件。

Middleware update the Redux data flow by adding an extra step at the start of dispatch. That way, middleware can run logic like HTTP requests, then dispatch actions. That makes the async data flow look like this: redux async flow

Redux Thunk

thunk 單詞意思為重擊,但在程式語言則代表「一段延遲處理的程式碼」。
有很多 Middleware 可以處理 Redux 執行非同步,官方推薦、最多人使用的是 redux-thunk。RTK 庫的 configureStore 已經包含 redux-thunk,不用再額外安裝。

Thunk Function

基本上,thunk function 的運作模式依賴在回傳一個函數,並且帶有 (dispatch, getState) 做為參數,也就是會跑兩次 dispatch,包含最開始的 dispatch(action/thunk) 以及 thunk 回傳的 dispatch(action/type)

const exampleThunkFunction = (
dispatch: AppDispatch,
getState: () => RootState
) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(increment())
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}

store.dispatch(exampleThunkFunction)

也可以 return 另一個函數,一樣帶有參數 (dispatch, getState)

const logAndAdd = (amount: number) => {
return (dispatch: AppDispatch, getState: () => RootState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}

store.dispatch(logAndAdd(5))

可預測的流程

既然是由 Redux 控制的函數,就必須符合 Redux 的特性。

  1. 在 request 實際執行之前,會有「start」的 action,讓 UI 可以做回饋 -> Loading,也防止重複的請球
  2. 執行 request,返回 Promise
  3. Thunk 根據 Promise 返回相應的 action - Success 或者 Failure。
  4. Reducer 處理 action,並將 start 清除
Dispatch "Start" Action
|
v
+----------------+
| Update state: |
| loading = true |
+----------------+
|
v
Perform Async Request
|
+---------------------------------------+
| |
v v
Request Success Request Failure
| |
v v
Dispatch "Success" Action Dispatch "Failure" Action
| |
v v
+--------------------------+ +--------------------------+
| Update state: | | Update state: |
| loading = false | | loading = false |
| data = receivedData | | error = errorMessage |
+--------------------------+ +--------------------------+

實際例子

我們可以自己造 Thunk 的輪子,或用 createAsyncThunk ,這邊就直接使用 createAsyncThunk
useDispatch 一樣,我們可以先預先給 pre-typed,這樣不用每次定義都要帶入一樣的型別。

import { useDispatch, useSelector } from 'react-redux';
import { createAsyncThunk } from '@reduxjs/toolkit';

import type { AppDispatch, RootState } from './index';

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState;
dispatch: AppDispatch;
}>();

由於之前在另一篇有寫過 extraReducer 搭配 thunk 做非同步,這邊就不再贅述類似 extraReducer 運作的內容。

createAsyncThunk

createAsyncThunk 是 RTK 提供用來創建處理非同步 reducer 的方法,接收兩個參數,第一個是該 thunk 的 action type,還記得 RTK 的 createSlice 會自動幫我們創建 action type 嗎?這邊也是一樣的。
第二個參數為一個函數,用來取得新的 payload 並回傳,這樣 reducer 才有辦法拿的真正被預期處理過、更新後的 payload。

export const fetchPosts = createAppAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
})

因此,createAsyncThunk 本身最後可能會回傳一個 Promise,或者已經解析完成的 data,也就是會帶給 reducer 的新 payload。
我們試著以 dispatch(fetchPosts()) 觸發,打開 Redux Debug Panel 會看到先跑 Pending 再 fulfilled。 redux thunk 為什麼會有兩次呢?因為 React Strict Mode 會 render 兩次來確保組件的初始跟卸載沒有其他 side effect,不僅僅針對 useEffect 裡。

Thunk Status Handle

透過 extraReducer 的 addCase,我們可以處理 createAsyncThunk 會給予的三種狀態 pending | succeeded | failed 。這三種不是一定都要定義,可以根據需求定義需要的處理即可。

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit existing reducers here
},
extraReducers: builder => {
builder
.addCase(userLoggedOut, state => {
return initialState
})
// pending 在一開始, fetchPosts 執行前就會執行
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'pending'
})
// fetchPosts 執行完成並取得 resolved
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
state.posts.push(...action.payload)
})
// fetchPosts 執行完成並取得 rejected
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message ?? 'Unknown Error'
})
}
})