A Redux middleware that allows dispatching thunks with takeLeading, takeEvery or takeLatest behaviour, and adds loading state.
- Setup
- Dispatch API
- createIsLoadingSelector API
- With Typescript and Redux Toolkit
- Unit tests
- Motivation
- Depenedencies
- Credit
- Add the reducer
import { combineReducers } from 'redux'
import { reduxTakingThunkReducer } from 'redux-taking-thunk'
export const reducer = combineReducers({
other: otherReducer,
reduxTakingThunk: reduxTakingThunkReducer
})
- Add the middleware
// with Redux Toolkit
import {
configureStore,
createImmutableStateInvariantMiddleware,
createSerializableStateInvariantMiddleware
} from '@reduxjs/toolkit'
import thunkMiddleware from 'redux-thunk'
import { reducer } from './reducer'
import { createReduxTakingThunkMiddleware } from 'redux-taking-thunk'
export const store = configureStore({
reducer,
middleware: process.env.NODE_ENV !== 'production'
? [
thunkMiddleware,
createReduxTakingThunkMiddleware(),
createImmutableStateInvariantMiddleware(),
createSerializableStateInvariantMiddleware()
]
: [
thunkMiddleware,
createReduxTakingThunkMiddleware(),
],
})
without Redux Toolkit
// without Redux Toolkit
import { createStore, applyMiddleware } from 'redux'
import { reducer } from './reducer'
import { createReduxTakingThunkMiddleware } from 'redux-taking-thunk'
export const store = createStore(reducer, undefined, applyMiddleware(createReduxTakingThunkMiddleware()))
extraArgument
// extraArgument
export const store = configureStore({
reducer,
middleware: process.env.NODE_ENV !== 'production'
? [
thunkMiddleware,
createReduxTakingThunkMiddleware(),
createImmutableStateInvariantMiddleware(),
createSerializableStateInvariantMiddleware()
]
: [
thunkMiddleware,
createReduxTakingThunkMiddleware(),
],
})
Note about
redux-thunk
The
redux-taking-thunk
middleware only handles the dispatch of aTakingTypeAction
, and does not handle dispatch of a function, therefore does not interfere withredux-thunk
.
Adds a dispatch
overload that accepts a TakingTypeAction
object as parameter and returns a Promise.
dispatch(a: TakingThunkAction) => Promise<any>
const takingThunkAction = {
name: 'fetchProducts',
takeType: 'latest',
thunk: function*(dispatch){
try {
const response = yield fetch('http://example.com/products.json')
dispatch({type: 'fetchProductSuccess', products: response.json()})
} catch(e) {
dispatch({type: 'fetchProductsError', error: 'failed to fetch products'})
}
}
}
dispatch(takingThunkAction)
name
identifies the action, used to get the loading statetakeType
'every'(default), 'leading', or 'latest'thunk
a function
In case of a newly dispatched TakingTypeAction
:
If store's state of the name
is not "loading", the thunk
will execute and store's state will be "loading".
If store's state of the name
is "loading", see table:
takeType | thunk returns a value |
thunk returns a Promsie |
thunk returns a Generator or Async Generator |
---|---|---|---|
leading | Does not execute | ⭐Does not execute | Does not execute |
every | Executes and increments state's counter. As the function returns immidiately, decrements state's counter |
⭐Executes and increments state's counter. After the promise resolves or rejects, decrements state's counter |
Executes and increments state's counter. After the generator returns, decrements state's counter |
latest | Does not allow | Does not allow | ⭐Executes and changes state's executionId. By changing the executionId, all other running generators will be discontinued(will not call next()) |
⭐: recommended use case
if
takeType
does not match the store state, thethunk
will not be executed.
every
does not deal with execution order.
takeType | |
---|---|
leading | The leading promise is not resolved/rejected; or The leading generator is not finished. |
every | Not all promises are resolved/rejected; or Not all generators are finished |
latest | The latest generator is not finished (does not care about other generators) |
thunk
can be
- a normal function (but no need to use this library),
- a function that returns a promise(async function),
- a generator function, or
- an async generator function
thunk
Parameters
dispatch
getState
extraArgument
the argument passed in the middleware
-
If
thunk
is a generator function or an async generator function, and the execution did not finish (because anothertakeLatest
action with same name is dispatched),dispatch
will return a resolved Promise with value undefined. -
If
thunk
's return value is a promise,dispatch
will return a promise with the same resolved or rejected value. -
If
thunk
's return value is not a promise,dispatch
will return a resolved promise with that value. -
If an exceptionis thrown from, or uncaught within
thunk
,dispatch
will return a rejected promise with that error.
This dispatch
overload returns a Promise. (see thunk return value)
E.g. call then
on the returned Promise.
dispatch(takingThunkAction).then(() => alert('got porducts!!'))
createIsLoadingSelector
creates a selector function of the loading state(see takeType loading) of the actions identified by name
.
import { createIsLoadingSelector } from 'redux-taking-thunk'
const isLoadingSelector = createIsLoadingSelector(name)
const isLoading = isLoadingSelector(state)
Type benefits when used with Typescript and Redux Toolkit.
- Type hint of the
dispatch
overload
// in store.ts
export type AppDispatch = typeof store.dispatch
// in component
const dispatch = useAppDispatch()
dispatch(
- Type safety when adding middleware, if you forgot to add the reducer
// in store.ts
export const store = configureStore({
reducer,
middleware: process.env.NODE_ENV !== 'production'
? [
thunkMiddleware,
createReduxTakingThunkMiddleware(),
createImmutableStateInvariantMiddleware(),
createSerializableStateInvariantMiddleware()
]
: [
thunkMiddleware,
createReduxTakingThunkMiddleware(),
],
// eslint errors: ... Type 'TakingThunkMiddleware...' is not assignable to type 'Middleware...
})
npm test
When using Redux Thunk to make API requests, how to handle concurrency?
One way is to just call the thunks, without caring the order of dispatch. The order of promise resolve is not controlled. For example a slow first dispatch, could resolve later than a fast second dispatch.
// take every
async function fetchProducts(dispatch, getsState){
dispatch({type: 'fetchProductStart'})
try {
const response = await fetch('http://example.com/products.json')
dispatch({type: 'fetchProductSuccess', products: response.json()})
} catch(e) {
dispatch({type: 'fetchProductError', error: 'failed to fetch products'})
}
}
// with redux-thunk
dispatch(fetchProducts) // first
dispatch(fetchProducts) // second, both respond will update state, but do not know which will be the last
A common pattern to handle concurrency is to block the late dispatch if state already is loading.
// take leading
async function fetchProducts(dispatch, getsState){
if(getsState().productIsLoading === true){
return
}
dispatch({type: 'fetchProductsStart'})
try {
const response = await fetch('http://example.com/products.json')
dispatch({type: 'fetchProductsSuccess', products: response.json()})
} catch(e) {
dispatch({type: 'fetchProductsError', error: 'failed to fetch products'})
}
}
// with redux-thunk
dispatch(fetchProducts) // first
dispatch(fetchProducts) // second, will be blocked if first is not resolved
An interesting way is take latest.
Take latest discontinus, not cancels. E.g. if an API request is made, and while waiting for the promise to resolve, another thunk is dispatched.
We will not try to cancel the previous API request, as it is already made. Instead, we want to skip the execution of any code following the API request.
However, how to skip the code after the API request? We will transform the async function thunk into a generator thunk, so that the execution exits, and re-enters at yield
.
// take latest
// not care about the loading state
function* fetchProducts(dispatch, getsState){
try {
const response = yield fetch('http://example.com/products.json')
dispatch({type: 'fetchProductsSuccess', products: response.json()})
} catch(e) {
dispatch({type: 'fetchProductsoError', error: 'failed to fetch products'})
}
}
Because there is a yield
, redux-taking-thunk
middleware can decide whether or not to re-enter the execution.
For example, with only one dispatch, the thunk executes "normally" as expected, execution "exits" at the yield, after the API request resolves/rejects, execution "re-enters" and executes the following code, and updates state's value.
For another example, the first thunk executes and "exits" at the yield, and waits for the API request to resolve. At this moment, a second thunk is dispatched. The second thunk will execute "normally" until the end (no other dispatches). When the first thunk's API request resolves, the middleware decides not to "re-enter" the first thunk, code following the yield will not be executed, so the state's value will not be overridden.
With redux-taking-thunk
the examples will become
// take every
const takingThunkAction = {
name: 'fetchProducts',
takeType: 'every',
thunk: async function(dispatch){
try {
const response = await fetch('http://example.com/products.json')
dispatch({type: 'fetchProductsSuccess', products: response.json()})
} catch(e) {
dispatch({type: 'fetchProductsError', error: 'failed to fetch products'})
}
}
}
dispatch(takingThunkAction) // first
dispatch(takingThunkAction) // second, both respond will update state, but do not know which will be the last
// take leading
const takingThunkAction = {
name: 'fetchProducts',
takeType: 'leading',
thunk: async function(dispatch){
try {
const response = await fetch('http://example.com/products.json')
dispatch({type: 'fetchProductsoSuccess', products: response.json()})
} catch(e) {
dispatch({type: 'fetchProductsError', error: 'failed to fetch products'})
}
}
}
dispatch(takingThunkAction) // first, called normally
dispatch(takingThunkAction) // second, not called if first is not resolved
// take latest
const takingThunkAction = {
name: 'fetchProducts',
takeType: 'latest',
thunk: function*(dispatch){
try {
const response = yield fetch('http://example.com/products.json')
dispatch({type: 'fetchProductsSuccess', products: response.json()})
} catch(e) {
dispatch({type: 'fetchProductsError', error: 'failed to fetch products'})
}
}
}
dispatch(takingThunkAction) // first
dispatch(takingThunkAction) // second, graunteed to be the last to update state
On Idiomatic Redux
Just my 2¢.
Redux pros
- centralized state
- separate action from state update logic
- logic stems from "how the state slice respond the actions?", not "what happens for the action?"
- vs context, recoil, zustand or global getter setters
- can reason about state changes by the dispatched actions
Redux cons
- imposed limitations
- abstractions
Redux without middleware
- can reason about state changes by the all the dispatched actions
Redux-thunk and/or redux-taking-thunk
- use dispatch as an extension point
- dispatch is modified to accept fucntions/thunks
- can still reason about state changes by the dispatched "simple" actions
- redux
- immer
- nanoid
Original inspiration comes from redux-thunk-loading.