1.0.0 • Published 2 years ago

redux-taking-thunk v1.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
2 years ago

redux-taking-thunk

A Redux middleware that allows dispatching thunks with takeLeading, takeEvery or takeLatest behaviour, and adds loading state.

Setup

  1. Add the reducer
import { combineReducers } from 'redux'
import { reduxTakingThunkReducer } from 'redux-taking-thunk'

export const reducer = combineReducers({
  other: otherReducer,
  reduxTakingThunk: reduxTakingThunkReducer
})
  1. 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
import { createStore, applyMiddleware } from 'redux'
import { reducer } from './reducer'
import { createReduxTakingThunkMiddleware } from 'redux-taking-thunk'

export const store = createStore(reducer, undefined, applyMiddleware(createReduxTakingThunkMiddleware()))
// 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 a TakingTypeAction, and does not handle dispatch of a function, therefore does not interfere with redux-thunk.

Dispatch API

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)

TakingTypeAction Properties

  • name\ identifies the action, used to get the loading state
  • takeType\ 'every'(default), 'leading', or 'latest'
  • thunk\ a function

takeType

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:

takeTypethunk returns a valuethunk returns a Promsiethunk returns a Generator or Async Generator
leadingDoes not execute:star:Does not executeDoes not execute
everyExecutes and increments state's counter.As the function returns immidiately, decrements state's counter:star:Executes and increments state's counter.After the promise resolves or rejects, decrements state's counterExecutes and increments state's counter.After the generator returns, decrements state's counter
latestDoes not allowDoes not allow:star:Executes and changes state's executionId.By changing the executionId, all other running generators will be discontinued(will not call next())

:star:: recommended use case

if takeType does not match the store state, the thunk will not be executed.

every does not deal with execution order.

takeType "loading"

takeType
leadingThe leading promise is not resolved/rejected; orThe leading generator is not finished.
everyNot all promises are resolved/rejected; or Not all generators are finished
latestThe latest generator is not finished (does not care about other generators)

thunk

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

thunk return value

  • If thunk is a generator function or an async generator function, and the execution did not finish (because another takeLatest 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.

dispatch Return value

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 API

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)

With Typescript and Redux Toolkit

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...
})

Unit tests

npm test

Motivation

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

With or without middleware?

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

Peer Depenedency

  • redux

Depenedencies

  • immer
  • nanoid

Publish to npm

publish

Credit

Original inspiration comes from redux-thunk-loading.