Persist Redux State by using Sagas
Originally posted on InVisionApp's Engineering blog
Written on
redux-saga is a fairly new library in the Redux ecosystem, one that has quickly gained adoption and traction in the community. We’ve started using sagas to manage image uploads and to implement a history mechanism (undo/redo). We really liked the way it helps orchestrate complex flows that have side-effects, by using ES6 generators, which promote more readable asynchronous code and simpler unit tests.
Our app already persisted the state by loading it from the server on page navigation and by saving it periodically, after some specific actions were triggered. It relied on the redux-storage library, a simple plug and play library for loading/storing data, but we started having more complex requirements on the persistence mechanism. That prompted us to replace redux-storage with a custom saga and liked the result so much that decided to share it with you.
This article assumes that you have some redux
and redux-saga
knowledge, but I’ve aimed for the explanations to be gradual and simple, so newcomers to these libraries can learn from them. The documentation for Redux and redux-sagas are great starting points and a good reference if you have questions throughout this article.
Scope
The persistence mechanism described in this article assumes that you’ll save the whole state to the server (although you can easily select just a subset of it). The save operation follows these requirements:
- There must be a whitelist of actions (only actions on the whitelist will trigger a save)
- Some actions (such as dragging an image) should be debounced, in order to only trigger a save operation to the server after an amount of time
- Other actions (such as creating an image) should save immediately
- There should be an “unsaved changes” indicator on the UI, that is displayed when a change is first recorded and hidden when a successful save response is received from the server
This article does not explain how to setup sagas or a react/redux project (as there’s already the documentation and countless examples). It does provide a github repository with a complete example for you to check how it was setup and how it plays together with React/Redux: https://github.com/jportela/redux-saga-persistence
Starting Small
Let’s start by implementing a simple saga that, on all actions, triggers a save immediately to the server:
import { call, put, select, take } from 'redux-saga/effects'
import { serverSave } from '../actions'
import * as PersistenceEngine from './persistence/engine'
export default function* persistenceSaga() {
while (true) {
const action = yield take()
const state = yield select()
yield put(serverSave.request(action))
yield call(PersistenceEngine.save, state, action)
yield put(serverSave.success())
}
}
By wrapping the generator body in a while(true)
, it will be run for all actions, looping through the following instructions:
- Retrieve any action (the
take
effect intercepts actions dispatched to the store) - Get the state from the redux store (the
select
effect does that) - dispatch a
serverSave.request
action (theput
effect dispatches an action to the redux store) - call the
PersistenceEngine.save
function, which is a promise that does a server request, fulfilled when the server responds - dispatch a
serverSave.success
action
The serverSave
actions indicate the app that a request is being made and when it’s fulfilled (it’s your choice to use it or not, depending if you want to show any indication on the UI). One obvious improvement we can make is to add error handling, which redux-saga
makes it as easy as synchronous code, by using the familiar try/catch
:
import { call, put, select, take } from 'redux-saga/effects'
import { serverSave } from '../actions'
import * as PersistenceEngine from './persistence/engine'
export default function* persistenceSaga() {
while (true) {
const action = yield take()
const state = yield select()
yield put(serverSave.request(action))
try {
yield call(PersistenceEngine.save, state, action)
yield put(serverSave.success())
} catch (e) {
yield put(serverSave.failure())
}
}
}
That was simple and easy, but it’s hardly a good solution. All actions are triggering a save, so your server will be mindlessly bombarded with save requests. To help prevent this, we’ll implement a simple whitelist, so only specific actions will trigger a save.
Implementing a Whitelist
This whitelist just uses an Object as a map, to check if the action type exists on the whitelist. If it doesn’t exist, it will just continue
the loop, not saving anything. I recommend separating the Whitelist into its own module, as it will quickly grow.
// persistence/whitelist.js
import * as types from '../../constants/ActionTypes'
const Whitelist = {
[types.CREATE_IMAGE]: true,
[types.MOVE_IMAGE]: true,
}
export default Whitelist
import { call, put, select, take } from 'redux-saga/effects'
import { serverSave } from '../actions'
import * as PersistenceEngine from './persistence/engine'
import Whitelist from './persistence/whitelist'
export default function* persistenceSaga() {
while (true) {
const action = yield take()
if (!Whitelist[action.type]) {
continue
}
const state = yield select()
yield put(serverSave.request(action))
try {
yield call(PersistenceEngine.save, state, action)
yield put(serverSave.success())
} catch (e) {
yield put(serverSave.failure())
}
}
}
Remember the requirements that we had? Let’s implement debouncing, as we don’t want that MOVE_IMAGE
action to bomb our server with requests on every mouse move event.
Implementing Debounce
We’ll start by implementing a delay promise (copied from redux-saga docs), that, by using generators, will look almost like a sleep
function (with the advantage of not actually blocking the UI, it will just block the generator execution). The idea is to yield
that “sleep” promise so the save operation can only be executed later.
import { call, put, select, take } from 'redux-saga/effects'
import { serverSave } from '../actions'
import * as PersistenceEngine from './persistence/engine'
import Whitelist from './persistence/whitelist'
const DEBOUNCE_TIME = 3000 // debounce time in milliseconds
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export default function* persistenceLayer() {
while (true) {
const action = yield take()
if (!Whitelist[action.type]) {
continue
}
const state = yield select()
yield call(delay, DEBOUNCE_TIME)
yield put(serverSave.request(action))
try {
yield call(PersistenceEngine.save, state, action)
yield put(serverSave.success())
} catch (e) {
yield put(serverSave.failure())
}
}
}
While this code works, it doesn’t exactly meet the requirements we’ve set for the app. Say that we have three actions (A, B, C) that occur in a 2-second interval between each other. With the code above, there would be two save operations, one 3 seconds after the A action, and another 3 seconds after the C action. The B action would be ignored because the generator would be “blocked” waiting for the delay promise to be fulfilled.
The behavior I’m looking to implement is to only trigger a save operation when no actions are triggered for 3 seconds. So, it would only save 3 seconds after the C operation. To accomplish that, we need a way to say that, if there’s already a scheduled save, reset the delay
timer and cancel the old scheduled save.
Running Sagas in the Background (and canceling them)
To implement this, we’ll use the fork
effect, that runs a task concurrently (call
waits for the promise to be completed, blocking the saga – we need to keep receiving actions). I like the fork
analogy, as in redux-saga
they behave a lot similar to processes forks. Furthermore, fork
ed processes can be canceled so they’ll work well with our requirements.
import { cancel, call, fork, put, select, take } from 'redux-saga/effects'
import { serverSave } from '../actions'
import * as PersistenceEngine from './persistence/engine'
import Whitelist from './persistence/whitelist'
const DEBOUNCE_TIME = 3000 // debounce time in milliseconds
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// let's separate this function for better readability
function* save(state, action) {
yield put(serverSave.request(action))
try {
yield call(PersistenceEngine.save, state, action)
yield put(serverSave.success())
} catch (e) {
yield put(serverSave.failure())
}
}
function* debounceSave(state) {
try {
yield call(delay, DEBOUNCE_TIME)
yield call(save, state)
} catch (e) {
// empty exception handler because the cancel effect throws an exception
}
}
export default function* persistenceLayer() {
// if there's already a delay task running, we want to cancel it
let debounceTask = null
while (true) {
const action = yield take()
if (!Whitelist[action.type]) {
continue
}
const state = yield select()
if (debounceTask) {
yield cancel(debounceTask)
}
debounceTask = yield fork(debounceSave, state, action)
}
}
We started by separating the save
and debounceSave
generator functions, in order to make the code a bit more easy to read. There’s also a task
concept that was added, which is the result yielded by fork
:
debounceTask
- this is the yielded value from afork
. We need to store it so we cancancel
the debounce event (check the API for a Task here)cancel
- this is another redux-saga effect, that cancels a forked process. Note that canceling a task throws aSagaCancellationException
to the generator that was forked.
Creating a more robust Whitelist
So let’s continue with our requirements. The CREATE_IMAGE
action needs to be saved immediately, canceling the debouncing, if it’s running. That can easily be done by adding types of persistence to the Whitelist. Let’s separate it into it’s own module and provide an utility function to retrieve the type of persistence:
// persistence/whitelist.js
import * as types from '../../constants/ActionTypes'
export const PersistenceType = {
IMMEDIATE: 'IMMEDIATE',
DEBOUNCE: 'DEBOUNCE',
}
const Whitelist = {
[types.CREATE_IMAGE]: PersistenceType.IMMEDIATE,
[types.MOVE_IMAGE]: PersistenceType.DEBOUNCE,
}
export function getPersistenceType(type) {
return Whitelist[type] || null
}
import { cancel, call, fork, put, select, take } from 'redux-saga/effects'
import * as types from '../constants/ActionTypes'
import {
serverSave,
signalUnsavedChanges,
signalSavedChanges,
} from '../actions'
import * as PersistenceEngine from './persistence/engine'
import { getPersistenceType, PersistenceType } from './persistence/whitelist'
const DEBOUNCE_TIME = 3000 // debounce time in milliseconds
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// let's separate this function for better modularity
function* save(state, action) {
yield put(serverSave.request(action))
try {
yield call(PersistenceEngine.save, state, action)
yield put(serverSave.success())
} catch (e) {
yield put(serverSave.failure())
}
}
function* debounceSave(state) {
try {
yield call(delay, DEBOUNCE_TIME)
yield call(save, state)
} catch (e) {
// empty exception handler because the cancel effect throws an exception
}
}
export default function* persistenceSaga() {
let debounceTask = null
while (true) {
const action = yield take()
const type = getPersistenceType(action.type)
if (!type) {
continue
}
const state = yield select()
if (debounceTask) {
yield cancel(debounceTask)
}
if (type === PersistenceType.IMMEDIATE) {
yield fork(save, state) // save immediately
} else if (type === PersistenceType.DEBOUNCE) {
debounceTask = yield fork(debounceSave, state)
}
}
}
Notice that we need to fork
(not call
) both the save
and debounceSave
operations. That way the saga can keep retrieving actions in the background, so that a new IMMEDIATE
action may cancel a previously scheduled one.
Signaling unsaved changes
So there’s only one missing requirement: we need to signal the UI when there are unsaved changes. We already dispatch actions when a save request is sent to the server and when it’s fulfilled. But that does not tell us when there are unsaved changes, as there can be changes that are still waiting the debounce timer to complete, which happens before the server request.
We will implement the signaling by dispatching a UNSAVED_CHANGES
action, waiting for the SERVER_SAVE_SUCCESS
action, and finally dispatching a SAVED_CHANGES
action. We will also rely on the Task interface to act as a lock, to prevent multiple UNSAVED_CHANGES dispatches.
import { cancel, call, fork, put, select, take } from 'redux-saga/effects'
import * as types from '../constants/ActionTypes'
import {
serverSave,
signalUnsavedChanges,
signalSavedChanges,
} from '../actions'
import * as PersistenceEngine from './persistence/engine'
import { getPersistenceType, PersistenceType } from './persistence/whitelist'
const DEBOUNCE_TIME = 3000 // debounce time in milliseconds
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// let's separate this function for better modularity
function* save(state, action) {
yield put(serverSave.request(action))
try {
yield call(PersistenceEngine.save, state, action)
yield put(serverSave.success())
} catch (e) {
yield put(serverSave.failure())
}
}
function* debounceSave(state) {
try {
yield call(delay, DEBOUNCE_TIME)
yield call(save, state)
} catch (e) {
// empty exception handler because the cancel effect throws an exception
}
}
// signals to the UI that there are unsaved changes
export function* signalPersistenceState() {
yield put(signalUnsavedChanges())
yield take(types.SERVER_SAVE_SUCCESS) // waits for a SERVER_SAVE success to continue
yield put(signalSavedChanges())
}
export default function* persistenceSaga() {
let debounceTask = null
let unsavedTask = null
while (true) {
const action = yield take()
const type = getPersistenceType(action.type)
if (!type) {
continue
}
const state = yield select()
if (debounceTask) {
yield cancel(debounceTask)
}
if (!unsavedTask) {
unsavedTask = yield fork(signalPersistenceState)
unsavedTask.done.then(() => {
unsavedTask = null
})
}
if (type === PersistenceType.IMMEDIATE) {
yield fork(save, state) // save immediately
} else if (type === PersistenceType.DEBOUNCE) {
debounceTask = yield fork(debounceSave, state)
}
}
}
The trick here is to use take(ActionTypes.SERVER_SAVE.SUCCESS)
. It will wait until that action has been dispatched, and only then it will signal that there are no saved changes. The UI reducer can reduce those events into a boolean flag, indicating that there are unsaved changes.
Dressing it up a little
To better abstract and reuse the Lock functionality, I created a Lock
class, resulting in much cleaner code:
// utils/lock.js
import { cancel, fork } from 'redux-saga/effects'
export default class Lock {
constructor(func) {
this.isLocked = false
this.task = null
this.func = func
}
*execute(...args) {
if (!this.isLocked) {
// do not execute if it's locked
this.isLocked = true
this.task = yield fork(this.func, ...args)
this.task.done.then(() => {
this.isLocked = false
})
}
}
*cancel() {
if (this.task) {
yield cancel(this.task) // reset the delay timeout
}
}
}
import { call, fork, put, select, take } from 'redux-saga/effects'
import * as types from '../constants/ActionTypes'
import {
serverSave,
signalUnsavedChanges,
signalSavedChanges,
} from '../actions'
import * as PersistenceEngine from './persistence/engine'
import { getPersistenceType, PersistenceType } from './persistence/whitelist'
import Lock from './utils/lock'
const DEBOUNCE_TIME = 3000 // debounce time in milliseconds
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// let's separate this function for better modularity
function* save(state, action) {
yield put(serverSave.request(state))
try {
yield call(PersistenceEngine.save, state, action)
yield put(serverSave.success())
} catch (e) {
yield put(serverSave.failure())
}
}
function* debounceSave(state) {
try {
yield call(delay, DEBOUNCE_TIME)
yield call(save, state)
} catch (e) {
// empty exception handler because the cancel effect throws an exception
}
}
// signals to the UI that there are unsaved changes
export function* signalPersistenceState() {
yield put(signalUnsavedChanges())
yield take(types.SERVER_SAVE_SUCCESS) // waits for a SERVER_SAVE success to continue
yield put(signalSavedChanges())
}
export default function* persistenceSaga() {
let debounceLock = new Lock(debounceSave)
let unsavedLock = new Lock(signalPersistenceState)
while (true) {
const action = yield take()
const type = getPersistenceType(action.type)
if (!type) {
continue
}
const state = yield select()
// each persistent action cancels the debounce timer
yield debounceLock.cancel()
// this lock prevents multiple unsaved changes actions from being dispatched
yield unsavedLock.execute()
if (type === PersistenceType.IMMEDIATE) {
yield fork(save, state) // save immediately
} else if (type === PersistenceType.DEBOUNCE) {
// a new debounce timer is created
yield debounceLock.execute(state, action)
}
}
}
Wrapping up
And that’s all we have to show you today. You can see the example in action (and also a load
saga, if you were wondering about it) in https://github.com/jportela/redux-saga-persistence
There are a lot more features that we are implementing in our app which may warrant a part II on this topic. I’d personally want to see features such as diffing, so we only send to the server what was changed (although that may be the single responsibility of the PersistenceEngine
). I hope you liked this article, if you did please share it and let us know your thoughts on it.