Please give this post a 💓, 🦄, or 🔖 if it you enjoy it!
A common conundrum in today's front-end framework world is knowing when and how to take certain asynchronous actions, such as persisting data to a backend. If we're using a state management library like Redux, we might be further confused as to where without our Redux code we might put this logic.
I make other easy-to-digest tutorial content! Please consider:
- Subscribing to my DevTuts mailing list
- Subscribing to my DevTuts YouTube channel
A Concrete Scenario
For the purposes of this blog post, let's assume we are using React with Redux and want to periodically save our state data to a backend. We have elected to use debouncing to do this, meaning we'd like to perform the save action after our state hasn't changed for a certain amount of time.
Considering Our Options
So, what are our options when using React with Redux? I think the following list covers it:
- Do it in a component - Have a component that subscribes to our state and, when it renders, do the debouncing/saving.
- Do it in a redux action creator - Using something like thunk middleware, trigger the debounce function in an action create prior to dispatching the associated action.
- Do it in a reducer - As you update your site data in the reducer, call a debounce function. (See note below for why I think this option is bad).
- Do it in Redux middleware - Create a middleware that runs the debounce function anytime your state changes.
Note: I think all of these are actually legitimate ways except performing the save in a reducer. Reducers really should be pure functions and performing data fetching from within the reducer is a side effect.
Why I Like the Middleware Approach
As I mentioned above, I think most of these approaches could work fine, but I especially like the middleware approach. It nicely isolates your saving code, can selectively define which actions cause saving to start, doesn't require installing thunk middleware if you're not already using it, and doesn't require you to include a component that exists only to handle saving.
The Implementation
First, we can create a saveDebounce
function that will be called by our middleware. To implement debouncing, we'll make use of setTimeout
and clearTimeout
.
let saveTimer;
let debounceTime = 10000; // 10 seconds
const saveDebounce = data => {
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(() => {
// Use request library of choice here
fetch('my-api-endpoint', {
method: 'POST',
body: JSON.stringify(data),
});
}, debounceTime);
};
Next, the actual middleware, which is pretty simple.
export const dataSaver = store => next => action => {
saveDebounce(store.getState());
return next(action);
};
As a user is modifying state, the saveDebounce
function will clear any previous timeout and start a new one. Only when the user hasn't changed state for 10 seconds will our fetch
actually be called.
Finally, we need to register our middleware with Redux. This is done when we create our store
.
import { createStore, combineReducers, applyMiddleware } from 'redux';
import { dataSaver } from '../middleware/dataSaver';
const allReducers = combineReducers(reducers);
const store = createStore(allReducers, applyMiddleware(dataSaver));
Some Optimizations
The above code should get you started pretty well, but we can make some optimizations.
Let's stop calling getState so much
Calling getState
on our store
every time is unnecessarily and potentially expensive. Let's only do that when we're actually performing our fetch
.
let saveTimer;
let debounceTime = 10000;
const saveDebounce = store => {
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(() => {
fetch('my-api-endpoint', {
method: 'POST',
body: JSON.stringify(store.getState()),
});
}, debounceTime);
};
export const dataSaver = store => next => action => {
saveDebounce(store);
return next(action);
};
This of course means our saveDebounce
function has to have knowledge of the store's getState
method. I think this trade-off is worth the performance boost.
Let's only save a piece of our state
It seems unlikely we would really want to save the entire state object to a backend. More likely, we would just want to save a piece of our state object, which only gets updated by one or more actions.
Let's pretend that we only want to save data when the userDetails
part of state changes. Perhaps we know this happens only when the UPDATE_USER_DETAILS
action is dispatched. Accordingly, we could make the following changes:
let saveTimer;
let debounceTime = 10000;
const saveDebounce = store => {
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(() => {
fetch('my-api-endpoint', {
method: 'POST',
body: JSON.stringify(store.getState().userDetails),
});
}, debounceTime);
};
export const dataSaver = store => next => action => {
if (action.type === 'UPDATE_USER_DETAILS') {
saveDebounce(store);
}
return next(action);
};
Now, we only consider firing the save event if the UPDATE_USER_DETAILS
action is dispatched. Furthermore, other parts of the state can be updating without cancelling our debounce!
Please give this post a 💓, 🦄, or 🔖 if it you enjoy it!
Top comments (0)