redux-optimistic-thunk
v2.0.0
Published
A redux-thunk like middleware with optimistic UI supported
Downloads
22
Readme
redux-optimistic-thunk
redux-optimistic-thunk is a redux redux-thunk like middleware with optimistic UI supported.
Why this middleware
There are plenty of middleware for optimistic UI in redux ecosystem, such as redux-optimistic and redux-optimistic-ui, but both of them comes with several shortcomings:
- They try to mix some properties in your plain action objects, this makes a high invasive design of your app, you should be aware of optimistic transactions everywhere and it's hard to conditionally decide to be optimistic or not.
- They make developers manage transaction id themselves, if a complex business logic spreads over more than one functions, it is not a good experience to keep the transaction id in sync.
- They take a commit-or-rollback term as its general design, however optimistic UI is not as simple as a transaction, for example, one may mark a newly created item
pending
when it's in an optimistic state to prevent user delete it. In fact both optimistic action and non-optimistic (actual) action are simple logics, they can be identical or different from each other.
In these cases, we decide to create a simpler and business-centric middleware to support optimistic UI development.
How this middleware works
redux-optimistic-thunk is generally combined with 2 parts:
- A middleware that handles optimistic action.
- A higher order function to create reducer for optimistic UI.
The middleware consumes a special type of action (like redux-thunks consumes actions of type function), a optimistic action must be an array includes exactly 2 functions (thunks):
- The first function is exactly the same as what you will pass to redux-thunk, we call it a actual thunk.
- The second function is also a thunk accepting
(dispatch, getState, [extraArgument])
as arguments, but it's used for dispatching actions to produce optimistic states, we call it optimistic thunk.
As you see, all two functions are simple thunks so you can write whatever code for the well known redux-thunk middleware, redux-optimistic-thunk handles actual and optimistic thunks implicitly, creates state save point, applies optimistic actions, rollback them when actual thunk dispatches actual actions, it takes no effect on your action objects, and it is easy to remove optimistic UI (just delete the second item of array) or to conditionally choose whether to be optimistic (reuse the first item of array).
How to use
Create your store
Use of redux-optimistic-thunk is quite simple, you should apply the provided middleware and reducers:
import {createStore, applyMiddleware} from 'redux';
import {optimisticThunk, createOptimisticReducer} from 'redux-optimistic-thunk';
let reducer = (state, action) => (action.type === 'PUSH' ? state.concat(action.value) : state);
let store = createStore(
createOptimisticReducer(reducer), // Wrap your reducer in createOptimisticReducer
[],
applyMiddleware(optimisticThunk()) // Apply middleware
);
Note that different from redux-thunk, the export of redux-optimistic-thunk is a function which returns a middleware, so instead of applyMiddleware(optimisticThunk)
, you should have a extra invocation applyMiddleware(optimisticThunk())
, you can provide an extraArgument
on invocation like applyMiddleware(optimisticThunk(api))
.
After middleware is applied, just wrap your reducer into the createOptimisticReducer
function redux-optimistic-thunk provides, wrapped reducer adds optimistic state rollback ability to your store, it is a must.
Write your thunk
Suppose our requirement is adding a newly submitted todo to list immediately (optimistically) before it is saved in server, however the optimistic todo cannot be deleted before it completes persistence:
// action/todo.js
import {addLog} from './log';
import {warn} from './modal';
import {saveTodo} from '../api';
let newTodo = todo => ({type: 'NEW_TODO', todo: todo});
// A optimistic action consists of 2 thunks
let createTodo = todo => [
// The first thunk (actual thunk) contains your actual business logic
async dispatch => {
// Any sync dispatches will apply first
dispatch(addLog(`Submitted ${todo.title} task`));
try {
// Async logic goes here
let createdTodo = await saveTodo(todo);
// The first async dispatch rollbacks actions produced by optimistic thunk,
// it will rollback your state to the point **after** "Submitted xxx task" log
dispatch(addLog(`Created ${createdTodo.title} task`));
dispatch(newTodo(createdTodo)); // No pending mark so the actually persisted todo can be deleted
}
catch (ex) {
dispatch(warn(`Failed to create ${todo.title} task`));
}
},
// The second thunk (optimistic thunk) dispatches actions to produce optimistic states
dispatch => {
// Optimistic thunk must be sync, any async dispatch throws error
dispatch(addLog(`Created ${todo.title} task`));
dispatch(newTodo({...todo, pending: true})); // Add a pending mark, disable the delete button if pending
}
];
It's simple, you just write 2 blocks of business logic, no extra properties to your plain action object, no transaction id and commit/revert signals.
Determine whether state is optimistic
redux-optimistic-thunk adds an optimistic
property to your state, this property is initialized as false
.
Whenever a plain object action is dispatched from an optimistic thunk, the optimistic
property is set to true
, it will return to false
when all dispatched optimistic actions have been rollbacked, so it's possible to either use getState().optimistic
in thunk/middleware or use state.optimistic
in reducer to resolve whether the state is optimistic.
Detail
Actual thunk must make async dispatch
An async call to dispatch
function provided to actual thunk is the only way to tell redux-optimistic-thunk to rollback optimistic state, so if your actual thunk - in some cases - does not call dispatch
after the thunk function returns, the optimistic state will never be rollbacked, this can lead to a damaged state.
This can usually happen in lack of error handling:
let createTodo = todo => [
async dispatch => {
let createdTodo = await saveTodo(todo);
dispatch(newTodo(createdTodo));
},
dispatch => dispatch(newTodo({...todo, pending: true}));
];
Since the actual thunk does not handle any errors from server response, it is possible that no async dispatch
calls will be made if server returns a 50x
or 40x
response, in this case a optimistic pending todo will live in todo list forever, the entire application state is always optimistic.
If - for some reason - you cannot dispatch any action asynchronously in actual thunk, just call dispatch
function provided to actual thunk without any argument, this dispatch
function can properly "swallow" undefined
action and correctly rollback optimistic states:
let createTodo = todo => [
async dispatch => {
try {
let createdTodo = await saveTodo(todo);
dispatch(newTodo(createdTodo));
}
catch (ex) {
// OK we don't want to handle errors, so just call dispatch with no argument
dispatch();
}
},
dispatch => dispatch(newTodo({...todo, pending: true}));
];
The order of middleware
Internally when redux-optimistic-thunk rollbacks your state and re-applies actions, instead of calling the global dispatch
function it invokes the next
function in middleware, so any middleware placed before redux-optimistic-thunk will not receive any re-applied action.
To ensure all actions - either dispatched by user or re-applied by redux-optimistic-thunk - can be received, a middleware must be placed after optimisticThunk()
in applyMiddleware
function.
Special action types
In order to rollback state and mark state as optimistic, redux-optimistic-thunk will dispatch some special types of actions, these actions have a type prefixed with @@redux-optimistic-thunk/
, it is encouraged that any middleware and reducers should ignore these special actions.
Nested thunk
redux-optimistic-thunk keeps in track of the first async call to dispatch
function provided to actual thunk and will rollback optimistic thunk in this point, even the first call is to dispatch a nested thunk (when combined with redux-thunk), the rollback will be landed, so any optimistic state produced from corresponding optimistic thunk will lost, this is currently by design.
Run example
From running CLI example you could have an overview on how redux-optimistic-thunk manages your state:
npm install
npm run start-cli
See test/cli/main.js for detail code.
Running react example will provide you a more practical app:
npm install
npm run start-react
Follow the instructions on webpage to test optimistic UI effects.
Change Log
2.0.0
- Refreshed build with single rollup.
- The es module version is now located at
dist/index.mjs
. - Add
"sideEffects": false
topackage.json
.