Tại sao chúng ta nên dùng một middleware như Redux Thunk

Có bao giờ bạn tự hỏi tại sao mình lại phải sử dụng một middleware như Redux Thunk, Redux Saga hay chưa? Hay bạn chỉ dùng vì thấy các tutorial trên mạng bảo nên dùng và thế là bạn dùng.

Dù gì thì cũng đến lúc bạn cần nhìn nhận lại rằng liệu chúng ta có thực sự cần một middleware hay không, ở bài viết này mình sẽ phân tích giữa cách viết thuần không middleware và cách dùng Redux Thunk nhé.

Dispatch bất đồng bộ

Mình ví dụ người dùng đăng nhập vào trang web chúng ta, sau khi đăng nhập thành công thì hiển thị một cái toast thông báo, sau 5s thì cái thông báo đó tự động ẩn đi.

Đây là cách đơn giản nhất để làm trong Redux

js
store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
setTimeout(() => {
store.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)

Hoặc như thế này bên trong connected component

js
this.props.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
setTimeout(() => {
this.props.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)

Chỉ có một sự khác biệt là bên trong connected component, thường thì bạn sẽ không truy cập trực tiếp đến store mà bạn sẽ nhận dispatch thông qua prop (hoặc hook đối với React Hook). Tuy nhiên, không có sự khác biệt nào đáng kể.

Nếu bạn không muốn gõ lại khi dispatch cùng các action từ các component khác nhau, bạn có thể tách action ra như thế này thay vì phải dispatch một object

js
// actions.js
export function showNotification(text) {
return { type: 'SHOW_NOTIFICATION', text }
}
export function hideNotification() {
return { type: 'HIDE_NOTIFICATION' }
}
js
// component.js
import { showNotification, hideNotification } from '../actions'
this.props.dispatch(showNotification('You just logged in.'))
setTimeout(() => {
this.props.dispatch(hideNotification())
}, 5000)

Hoặc nếu bạn đưa các action vào trong connect() thì bạn sẽ dùng như thế này

js
this.props.showNotification('You just logged in.')
setTimeout(() => {
this.props.hideNotification()
}, 5000)

Nãy giờ thì chúng ta chưa sử dụng bất cứ middleware nào hoặc concept nâng cao nào cả.

Tách ra thành action bất đồng bộ

Cách tiếp cận bên trên làm việc tốt ở trong những trường hợp đơn giản, nhưng bạn có thể tìm thấy một vài vấn đề:

  • Nó làm cho bạn phải viết lại logic này ở bất kỳ đâu mà bạn muốn show thông báo.
  • Các thông báo không có ID để phân biệt với nhau, dễ dẫn đến hiện tượng dispatch HIDE_NOTIFICATION 1 cái là tất cả các thông báo hiện có trên màn hình đều bị ẩn sớm hơn dự tính.

Để giải quyết những vấn đề này, chúng ta cần tách ra thành một function mà chỉ tập trung logic timeout và dispatch 2 action. Nó có thể trông như thế này:

js
// actions.js
function showNotification(id, text) {
return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
return { type: 'HIDE_NOTIFICATION', id }
}
let nextNotificationId = 0
export function showNotificationWithTimeout(dispatch, text) {
// Dựa vào ID để xác định ẩn hiện cái nào.
const id = nextNotificationId++
dispatch(showNotification(id, text))
setTimeout(() => {
dispatch(hideNotification(id))
}, 5000)
}

Bây giờ thì các component có thể sử dụng showNotificationWithTimeout mà không bị duplicate đoạn logic trên hoặc gặp phải vấn đề ẩn hiện notification:

js
// component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
js
// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')

Tại sao showNotificationWithTimeout()dispatch như là đối số thứ nhất? Bởi vì nó cần dispatch các action vào store. Bình thường một component thực hiện việc dispatch nhưng vì chúng ta muốn một function ngoài làm việc này, chúng ta cần truyền dispatch vào.

Nếu bạn có một singleton store được export từ một module nào đó, bạn có thể import nó và sử dụng dispatch trực tiếp như thế này

js
// store.js
export default createStore(reducer)
// actions.js
import store from './store'
// ...
let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
const id = nextNotificationId++
store.dispatch(showNotification(id, text))
setTimeout(() => {
store.dispatch(hideNotification(id))
}, 5000)
}
// component.js
showNotificationWithTimeout('You just logged in.')
// otherComponent.js
showNotificationWithTimeout('You just logged out.')

Điều này trông có vẻ đơn giản hơn nhưng chúng ta không nên làm vậy. Nguyên nhân chính là bởi vì nó ép store phải là một singleton. Điều này làm cho nó khó tích hợp vào server rendering. Trên server, bạn sẽ muốn mỗi request có store riêng, để mỗi user khác nhau nhận một preload data khác nhau.

Một singleton cũng khó để test hơn.

Vì thế chúng ta không nên làm như thế, hoặc bạn chắc chắn trong tương lai đi nữa thì app cũng chỉ client-side thôi.

Quay trở lại với phiên bản trước đó:

js
// actions.js
// ...
let nextNotificationId = 0
export function showNotificationWithTimeout(dispatch, text) {
const id = nextNotificationId++
dispatch(showNotification(id, text))
setTimeout(() => {
dispatch(hideNotification(id))
}, 5000)
}
// component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')

Cách này đã giải quyết vấn đề với việc lặp lại logic và ẩn hiện notification.

Thunk Middleware

Với những app đơn giản, cách tiếp cận trên có vẻ ổn. Bạn không cần quan tâm đến middleware nếu bạn hài lòng với nó.

Trong những app lớn, có thể bạn sẽ gặp một vài bất tiện xung quanh nó.

Ví dụ, nó có vẻ không hay lắm khi chúng ta phải truyền dispatch đi khắp nơi. Điều này làm cho việc phân tách container và conponent trở nên phức tạp hơn bởi vì bất cứ component nào mà dispatch một Redux action bất đồng bộ thì phải nhận dispatch như một prop. Bạn không thể bind action với connect() được nữa bởi vì showNotificationWithTimeout() không thực sự là một action creator nữa rồi. Nó không return về một object (Redux action).

Thêm nữa, khá là không hay khi ta phải nhớ function nào là action đồng bộ như showNotification() và cái nào là bất đồng bộ như showNotificationWithTimeout(). Vì cách sử dụng chúng khác nhau nên bạn cũng phải cẩn thận nếu không sẽ dẫn đến những lỗi không đáng.

Chúng ta cần cách gì đó để cho Redux thấy được những action creator bất đồng bộ như là một trường hợp đặc biệt của action creator thay vì là một function khác biệt hoàn toàn.

Nếu bạn còn ở đây với mình thì bạn cũng nhận ra được vấn đề bên trong app của bạn, chào mừng bạn sử dụng Redux Thunk middleware.

Trong gist, Redux Thunk "dạy" cho Redux nhận biết được các action đặc biệt này.

js
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
const store = createStore(reducer, applyMiddleware(thunk))
// Nó vẫn nhận biết được plain object actions
store.dispatch({ type: 'INCREMENT' })
// Nhưng với thunk middleware, nó cũng nhận biết được các function
store.dispatch(function (dispatch) {
// ... có thể dispatch nhiều lần bên trong
dispatch({ type: 'INCREMENT' })
dispatch({ type: 'INCREMENT' })
dispatch({ type: 'INCREMENT' })
setTimeout(() => {
// ... ngay cả bất đồng bộ!
dispatch({ type: 'DECREMENT' })
}, 1000)
})

Khi middleware này được enable, nếu bạn dispatch một function, Redux Thunk middleware sẽ đưa function đó một đối số dispatch. Redux Thunk cũng giúp cho reducer của bạn chỉ nhận plain object actions.

Redux Thunk cũng cho phép chúng ta khai báo showNotificationWithTimeout() như một Redux action creator thông thường.

js
// actions.js
function showNotification(id, text) {
return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
return { type: 'HIDE_NOTIFICATION', id }
}
let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
return function (dispatch) {
const id = nextNotificationId++
dispatch(showNotification(id, text))
setTimeout(() => {
dispatch(hideNotification(id))
}, 5000)
}
}

Để ý cách viết gần giống với phiên bản trước đó. Tuy nhiên nó không nhận vào dispatch như đối số đầu tiên. Thay vào đó nó return một function mà nhận vào đối số là dispatch.

Chúng ta sử dúng nó trong component như thế nào? Rõ ràng, chúng ta có thể viết như thế này:

js
// component.js
showNotificationWithTimeout('You just logged in.')(this.props.dispatch)

Chúng ta đang dùng như một currying function và truyền dispatch vào.

Có vẻ nó còn trông "ngố" hơn phiên bản trước đó.

Nhưng như mình đã nói trước đó. Nếu Redux Thunk middleware được enable, bất cứ khi nào bạn dispatch một function thay vì một object, middleware sẽ gọi function đó với dispatch được truyền vào như đối số đầu tiên.

Vì thế chúng ta có thể làm như thế này

js
// component.js
this.props.dispatch(showNotificationWithTimeout('You just logged in.'))

Cuối cùng, dispatch một async action trông không khác với một sync action. Đây là điều tốt bởi vì component không cần quan tâm điều gì xảy ra bên trong action, mặc kệ nó là đồng bộ hay bất đồng bộ.

Nếu kết hợp với connect() thì cách chúng ta dispatch sẽ ngắn gọn hơn nữa.

js
// actions.js
function showNotification(id, text) {
return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
return { type: 'HIDE_NOTIFICATION', id }
}
let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
return function (dispatch) {
const id = nextNotificationId++
dispatch(showNotification(id, text))
setTimeout(() => {
dispatch(hideNotification(id))
}, 5000)
}
}
// component.js
import { connect } from 'react-redux'
// ...
this.props.showNotificationWithTimeout('You just logged in.')
// ...
export default connect(mapStateToProps, { showNotificationWithTimeout })(MyComponent)

Đọc state trong Thunk

Trong trường hợp bạn muốn get state hiện tại của Redux store, bạn có thể truyền getState như đối số thứ 2 vào function mà bạn return từ thunk action creator. Điều này cho phép thunk đọc state hiện tại của store.

js
let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
return function (dispatch, getState) {
// Redux không không quan tâm bạn return gì trong thunk
if (!getState().areNotificationsEnabled) {
return
}
const id = nextNotificationId++
dispatch(showNotification(id, text))
setTimeout(() => {
dispatch(hideNotification(id))
}, 5000)
}
}

Return trong Thunk

Redux không quan tâm bạn return gì từ thunk, nhưng nó sẽ đưa cho bạn giá trị mà bạn return từ thunk sau khi dispatch xong. Đó là lý do tại sao bạn có thể return một Promise từ thunk và đợi nó cho đến khi nó thành công bằng cách gọi

js
dispatch(someThunkReturningPromise()).then(...)

Tóm lại

Đừng sử dụng bất cứ middleware nào từ Redux Thunk, Redux Saga nếu bạn thực sự không cần chúng và hiểu bạn đang làm gì.

Nếu app của bạn tương lai có thể mở rộng và bạn muốn nhận được những lợi ích mà thunk mang lại như giải quyết được vấn đề truyền dispatch đi khắp mọi nơi trong component thì mình recommend là dùng ngay và luôn cho chắc. Dù gì nó cũng rất nhẹ.

Cảm ơn bạn đã đọc đến đây, hẹn gặp lại ở những bài viết tiếp theo.

Tham khảo

Từ một câu trả lời của Dan Abramov – founder Redux Thunk trên stackoverflow