redux-managed-thunk v1.0.0
redux-managed-thunk
redux-managed-thunk is a redux middleware which allows you to dispatch thunks with built-in async flow management.
- Oringinal idea
- How to use
- Limit on dispatch
- Use consumer to extend middleware
- Higher order thunk creator
- Optimistic UI support
Oringinal idea
The design of this middleware changes over time, I have been continuously comparing it to redux-thunk, redux-promise and redux-generator and finally comes to current design.
Why do we need a thunk
As of redux-promise you provide a Promise instance to dispatch function, this can be easy to create an async workflow, however when you can provide a Promise instance, the async logic is already started, which means middleware lost the ability to control when to start an async flow or how all async flows should be aligned, as a result, you get a unknown number of uncontrolled async workflows unless you manage them yourself.
Why do we pick promise
Async is an important part of application, however we have different solutions to handle async workflows. The popular redux-thunk middleware chooses to provide a dispatch function which can be invoked at any time, but in this way we could never know when an async workflow ends and whether that flow succeeds or fails.
In this point we need a method to mark the result of an async workflow, one solution is to add extra callback function:
let saveTodo = todo => async dispatch => {
dispatch({type: 'SAVE_START'});
let savedTodo = await post('/todos', todo);
// dispatch.done to report success
dispatch.done({type: 'ADD_TODO', payload: savedTodo});
};Invocation of dispatch.done and dispatch.fail marks the result of an asyn workflow, but users may forget to call these function and breaks async management (e.g. a never-end async breaks the series management).
Promise comes with a bunch of features to make it a better async flow control intrastructure:
- Supported as a standard, behavior explicitly defined.
asyncandawaitkeywords are supported by babel currently, which made control flow simple combined withPromise.all.
How to use
To install redux-managed-thunk via npm or yarn:
npm install --save redux-managed-thunk
# or
yarn add redux-managed-thunkThe default export is a function which creates a redux middleware, use applyMiddleware later to combine it with redux:
import {createStore, applyMiddleware} from 'redux';
import managedThunk from 'redux-managed-thunk';
let store = createStore(
reducer,
preloadedState,
applyMiddleware(managedThunk())
);Note that unlike redux-thunk whose default export is a middleware already, managedThunk is a function, you must invoke it to get the middleware. managedThunk also accepts an options argument containing properties below:
{boolean} loose: enable loose mode, see Limit on dispatch for detail.{Function} consumer: a consumer function to control the dispatching of thunks, see Use consumer to extend middleware for detail.
Breaking changes with redux-thunk
There are 2 dispatch functions in a redux middleware, one is the global dispatch attached with store object, the other one is what we called next in a middleware chain.
redux-thunk provides the global dispatch function to thunks, however redux-managed-thunk provides the next function in order to make optimistic UI to work.
This will not introduce any issue if you only use one middleware or you place redux-managed-thunk as the first argument of applyMiddleware function, however if you have some other middlewares before redux-managed-thunk, note they will not apply when you call dispatch in a thunk.
Play demo
This repository contains 2 demos:
npm run demo-optimisitcprovides an example demostrating how optimistic UI works.npm run demo-reactwill open the webpack-dev-server and give you a simple react based todo application demostrating how consumer and optimistic UI works.
Limit on dispatch
redux-managed-thunk adds several constraints to the dispatch argument of your thunk:
- If your thunk does not return Promise, calling
dispatchafter thunk returns will throw an error. - If your thunk returns Promise, calling
dispatchafter promise resolves or rejects will throw an error.
By including extra state check this middleware can help to prevent unknown dispatch invocation to cause unexpected application state, so that managing async workflows is more reliable.
If you want a 100% redux-thunk compatible API and using dispatch at any time, just pass a {loose: true} option when creating the middleware:
import {createStore, applyMiddleware} from 'redux';
import managedThunk from 'redux-managed-thunk';
let store = createStore(
reducer,
preloadedState,
applyMiddleware(managedThunk({loose: true})) // Passing loose to be redux-thunk compatible
);Use consumer to extend middleware
redux-managed-thunk comes with a mechanism called consumer which controls the invocation of thunk, you can pass a custom consumer function property when creating the middleware, this library includes several built-in consumer functions.
Cocurrency limit and series
The cocurrency consumer function allows a maximum number of thunks running at one time:
cocurrency = ({number} limit) => FunctionFor example, if we decide to limit the cocurrency to 4 to keep server stress at a low level:
import {managedThunk, cocurrency} from 'redux-managed-thunk';
import {applyMiddleware} from 'redux';
applyMiddleWare(managedThunk(null, {consumer: cocurrency(4)}));A special case is series consumer function, it limits the cocurrency to 1, which means all thunk will run one by one (as cocurrency(1)), this can be useful in electron-like environment in which the main and renderer communication is superfase, series dispatching can simply elliminate race conditions:
import {managedThunk, series} from 'redux-managed-thunk';
import {applyMiddleware} from 'redux';
applyMiddleWare(managedThunk(null, {consumer: series()}));Dependency injection
The inject and injectWith consumer function can inject extra arguments to thunks, it works like redux-thunk's withExtraArgument function but have the ability to pass multiple arguments:
inject = ({...any} extraArguments) => Function
injectWith ({...Function} factories) => FunctionThe injectWith function accepts any number of functions, calls each function and inject the return value to thunks, instead inject simply injects given arguments to thunks. injectWith does not support async functions.
import {managedThunk, injectWith} from 'redux-managed-thunk';
import {identity} from 'lodash';
import {applyMiddleware} from 'redux';
let api = {
// ...
};
let getCurrentUser = () => window.currentUser || null;
applyMiddleWare(managedThunk(null, {consumer: injectWith(identity(api), getCurrentUser)}));
// Thunk can get the return value of functions
let invalidCurrentUser = async (dispatch, getState, api, currentUser) => {
if (!currentUser) {
window.currentUser = await api.getCurrentUser();
return dispatch(thunk);
}
dispatch({type: 'INVALID_USER', payload: currentUser});
};Combine multiple consumers
The reduceConsumers function combines multiple consumers from left to right, for example we combine inject and series together:
import {managedThunk, series, inject, reducerConsumers} from 'redux-managed-thunk';
import {identity} from 'lodash';
import {applyMiddleware} from 'redux';
let api = {
// ...
};
applyMiddleWare(managedThunk(null, {consumer: reducerConsumers(series(), inject(api))}));Write a consumer
You can also write a custom consumer function, consumer is simply a function which matches signature:
consumer = ({Function} run) => ({Function({Function} thunk)}) => any;A consumer function receives a run function, caling this function returnes the result of thunk. A consumer SHOULD return the result of thunk (any).
As an example, we implement an injectWith consumer function which accepts async functions:
let injectWithAsync(...factories) => run => async thunk => {
let extraArguments = await Promise.all(factories.map(fn => fn()));
let injectedThunk = (...args) => thunk(...args, ...extraArguments);
return run(injectedThunk);
};Higher order thunk creator
A thunk is a function with signature:
Thunk = ({Function} dispatch, {Function} getState, {...Function} extraArguments) => anyA thunk creator is a function which creates a thunk:
ThunkCreator = ({...any} arguments) => ThunkA higher order thunk creator is a function which receives a thunk creator and returns a new thunk creator, it can add custom behaviors to thunk creators:
higherOrderThunkCreator = ({ThunkCreator} next) => ThunkCreatorredux-managed-thunk has some built-in higher order thunk creators.
Reuse previous thunk
In HTTP environment, some idempotent requests (such as GET) will return the same response if given the same arguments, so we don't need to start a new request every time, by just reusing the previous request if it doesn't finish we can save network roundtrips. The reusePrevious higher order thunk creator provides the functionality to reuse a pending thunk, it simply returns the Promise instance returned by previous reusable thunk.
The reusePrevious function accepts an options argument containing properties:
{Function} shouldReuse: a function accepts(currentArgs, previousArgs)and returns a boolean to determine whether to reuse previous result, the default implement is to compare each argument byshallowEqual.
import {reusePrevious} from 'redux-managed-thunk';
let equal = (x, y) => x === y;
let fetchUser = id => async dispatch => {
let user = http.get(`/users/${id}`);
dispatch({type: 'USER_ARRIVE', payload: user});
};
fetchUser = reusePrevious({shouldReuse: equal})(fetchUser);
fetchUser(123);
// This will not start a new request
fetchUser(123);Cancel previous thunk
Other than idempotent logic, some requests (such as PUT) will always overwrite the previous action, so if a new request is started, the previous one will be "useless", by cancelling it can save some network roundtrips or application state changes. The cancelPrevious higher order thunk creator provides this functionality, when a new thunk starts to run, all later dispatch calls from previous thunk will be ignored.
An important thing to note is we don't have a standard cancellation mechanism in JavaScript world, so cancelPrevious only ignores dispatch calls, all previously calls to dispatch will not rollback. To prevent dispatch calls befor the possible cancellation, use cancelPrevious with transactional together. you can also provide a cancel option to implement the real cancallation logic.
The cancelPrevious function accepts an options argument containing properties:
{Function} shouldCancel: a function accepts(currentArgs, previousArgs)and returns a boolean to determine whether to cancel previous thunk, the default implement is to compare each argument byshallowEqual.{Function} cancel: a function to actually cancel or abort current running thunk, this function will receive thePromiseinstance returned by thunk, the default implement is an empty function.
import {reusePrevious} from 'redux-managed-thunk';
let idEqual = (x, y) => x.id === y.id;
let abortFetch = running => running.abort();
let updateUser = user => async dispatch => {
let updating = http.put(`/users/${user.id}`, user);
updating.then(updatedUser => dispatch({type: 'USER_UPDATE', payload: updatedUser}));
return updating;
};
updateUser = cancelPrevious({shouldCancel: idEqual, cancel: abortFetch})(updateUser);
updateUser({id: 123, name: 'x'});
// Cancels previous update, name will directly updated to "y", no {name: "x"} state change will happen
updateUser({id: 123, name: 'y'});Make thunk transactional
The transactional higher order thunk creator will temporarily save all actions dispatched from a thunk, then dispatch them after the thunk successfully finishes, if the thunk failes (throws in sync or rejectes asynchronously), all dispatch calls will be dismissed:
import {transactional} from 'redux-managed-thunk';
let counter = 1;
let decrementCounter = () => dispatch => {
dispatch({type: 'LOG', payload: 'decrementing...'});
if (counter === 0) {
throw new Error('Cannot decrement');
}
counter--;
dispatch({type: 'NEW_COUNTER', counter});
dispatch({type: 'LOG', payload: 'decremented'});
};
decrementCounter = transactional()(decrementCounter);
decrementCounter();
// Because this will throw an error, the "decrenmenting..." log will not appear
decrementCounter();Write a higher order thunk creator
You can write custom higher order thunk creators, they are just pure functions matching the signature, below is an example of a higher order thunk creator which warns thunk deprecation message in console:
let deprecated = name => next => (...args) => {
let thunk = next(...args);
return (...thunkArgs) => {
console.warn(`${name} thunk is deprecated`);
return thunk(...thunkArgs);
};
};
// myAPI = deprecated('myAPI')(myAPI);Optimistic UI support
redux-managed-thunk also supports optimistic UI, you can use the optimisticEnhancer named export to enable optimistic UI support. The optimisticEnhancer is a function which returns a Redux StoreEnhancer, pass it as the third argument of createStore:
import {createStore} from 'redux';
let store = createStore(reducer, preloadedState, optimisticEnhancer());You can use compose function to merge multiple enhancers:
import {createStore, compose} from 'redux';
import logger from 'redux-logger';
import saga from 'redux-saga';
let store = createStore(
reducer,
preloadedState,
compose(
applyMiddleware(logger, saga),
optimisticEnhancer({/* options */})
)
);The optimisticEnhancer function accepts an options argument, this argument is directly passed to managedThunk function.
The implement of optimistic UI stays the same as redux-optimistic-thunk, you can pass an action with structure [Function, Function] to dispatch function:
- The first function is a standard thunk defined by redux-managed-thunk, this thunk must be async, an error will be thrown if it is sync.
- The second function is also a thunk but must be sync, an error will be thrown if it is async.
On receiving the array, redux-managed-thunk will perform steps listed below:
- Run the first thunk, all synchronously dispatched actions will be applied.
- Run the second thunk, all actions will be applied.
- Wait for the first thunk to invoke
dispatchasynchronously, then rollback actions produced by the second thunk. - Dispatch asynchronous actions from the first thunk.
redux-managed-thunk uses a transaction based mechanism to manage all thunks, there will be no race conditions in optimistic UI.
9 years ago