redux-action-sync
is an action persistence middleware for Redux
applications. A simple solution for keeping state synchronised across multiple
clients. It uses a single-endpoint backend as an alternative to CRUD APIs.
See it in action: todomvc-action-sync
Please treat what you see as an experiment, an idea being explored with code.
The idea of action synchronization stems from the principles of Redux:
- State is read-only
- The only way to change the state is to emit an action
What follows is the main premise of redux-action-sync
:
Replaying a sequence of actions can be used to recreate the application state.
Redux actions - being simple, serializable Javascript objects - can be easily
stored and transferred. Ensuring the same sequence of actions on all clients
guarantees consistent state across them. This is the job of redux-action-sync
.
All the above make action log a viable persistence format for certain applications. Obviously this approach yields certain benefits as well as disadvantages.
A complete system with action synchronization requires the following:
This step is entirely up to you. Your backend must persist a list of actions and compare two numbers with each other. The following is a basic Express backend with a single HTTP JSON endpoint and in-memory store:
const router = require('express').Router();
const actions = [];
router.use(require('body-parser').json());
router.post('/actions', (req, res) => {
const index = req.body.index;
if (actions.length == index) {
actions.push(req.body.action);
res.end();
} else {
res.status(409).json(actions.slice(index));
}
});
module.exports = router;
The push
function is the glue between the client and your backend.
redux-action-sync
will use it to push all client-initiated actions to the
backend. The method must return a Promise. For more details, see API.
For the Express backend above, an implementation of push
using the axios
HTTP client may look like the following:
const push = (index, action) =>
axios.post('http://localhost:4000/actions', { index, action })
.catch(error => {
throw Object.assign(error, { conflicts: error.response.data });
})
Finally, apply the middleware to your Redux store:
import createActionSync from 'redux-action-sync';
const store = createStore(
rootReducer,
applyMiddleware(
createActionSync(push)
)
);
As a user interacts with a Redux app, actions are dispatched. The middleware
intercepts each action and pushes it to a backend. It also maintains a counter
of successfully dispatched and pushed actions, called actionCount
.
A push
can result in either of the two scenarios:
This scenario represents a situation when the client & server are in sync.
- A user performs an action in the UI
redux-action-sync
intercepts and pushes the action to the backend and specifies its currentactionCount
- The backend confirms it currently holds
actionCount
actions in its store and appends the new one - The
push
promise is successfully resolved andredux-action-sync
continues with the action dispatch redux-action-sync
increments itsactionCount
by 1
This scenario represents a situation when the server is ahead of the client, i.e. holds more actions than the client knows of. They work together to bring the client up to date.
- A user performs an action in the UI
redux-action-sync
intercepts and pushes the action to the backend and specifies its currentactionCount
- The backend detects it currently holds more than
actionCount
actions in its store. It rejects the push and returns a list of all actions stored at indexactionCount
and above. redux-action-sync
dispatches all the received received actions and increments itsactionCount
for each oneredux-action-sync
retries the push (i.e. goto step 2) until the backend accepts the push
createActionSync(push)
- this is the default (and only) export of
redux-action-sync
- provide your
push
function as the only argument - returns the middleware function
- this is the default (and only) export of
A valid push
implementation looks as follows:
push(index, action)
- arguments:
index
: an integer with the currentactionCount
value. Use it to tell the backend "I want this new action atindex
"action
: the action being pushed
- returns: a Promise
- the Promise should fulfill when the push was accepted by the backend (e.g.
backend returns
200 OK
) - the Promise should reject when the push was rejected by the backend. The
error must contain an Array of conflicting actions in its
conflicts
field.
- the Promise should fulfill when the push was accepted by the backend (e.g.
backend returns
- arguments:
This approach allows for trivial backend implementations:
- append only, no updates and no deletes
- ensuring synchronization across clients requires only a simple number comparison
Furthermore, a single backend implementation is universal and can work across multiple Redux apps with no changes.
- The storage always grows, even if actual data is deleted by user
- Recreating store from a long sequence of actions may take a long time
- This can (should) be alleviated by persisting state (locally or on the server) and proceeding from then on
- Applications with a small Redux state
- "small" is obviously relative, but if you can imagine the client containing the entirety of user's data in its redux state, then you might want to look into it
- Applications where conflicts are relatively rare (so the conflict resolution doesn't disrupt user's experience)
- Applications where the client state is only a small window into the
available data
- most CRUDs with pagination will fit this description
- Realtime apps
- manual state rehydration
- optimistic updates
- server-side component
- offline support (queueing & batching actions to sync)
MIT