Reputation: 5727
I'm trying to cache ajax data called via an a redux-observable epic.
My goal is to call the API only the first time I dispatch LOAD_DATA_REQUEST
, then the second time return the cached data.
Below is the code I've tried, but the data is not cached, the API is being call whenever I dispatch LOAD_DATA_REQUEST
.
const loadDataEpic =
action$ => action$.ofType(LOAD_DATA_REQUEST)
.mergeMap(action => getData$(action.criteria)
.map(x => loadDataSuccess(x.response))
.catch(error => Observable.of(loadDataFailure(error.xhr)))
);
const getData$ = criteria => Observable.ajax.post('some-url', criteria)
.publishLast()
.refCount();
export default combineEpics(
loadDataEpic
);
I also tried this:
const getData$ = criteria => Observable.ajax.post('some-url', criteria)
.publishReplay(1)
.refCount();
and
const getData$ = criteria => Observable.ajax.post('some-url', criteria)
.shareReplay();
Upvotes: 2
Views: 1644
Reputation: 15401
As ddcc3432 mentioned, I personally would defer to storing the cached results in the redux store itself. This is the most natural place for it.
Here's a general example of doing that, assuming you maintain some sort of loading state as well. If you don't need a loading state, than dispatching some action when serving from cache isn't neccesary? You can just ignore by filter LOAD_DATA_REQUEST
as ddcc3432 mentioned.
const yourDataCache = (state, action) => {
switch (action.type) {
case LOAD_DATA_REQUEST:
return {
...state,
// however you index your requests
[action.criteria]: {
isLoading: true
}
};
case LOAD_DATA_SUCCESS:
return {
...state,
// however you index your responses
// here I'm assuming criteria is a string and is
// also included in the response. Change as needed
// for your real data
[action.criteria]: {
isLoading: false,
...action.response
}
};
case LOAD_DATA_CACHED:
return {
...state,
// however you index your responses
[action.criteria]: {
isLoading: false, // just change the loading state
...state[action.criteria] // keep existing cache!
}
};
default:
return state;
}
};
const loadDataEpic = (action$, store) =>
action$.ofType(LOAD_DATA_REQUEST)
.mergeMap(action => {
const { yourDataCache } = store.getState();
// If the data is already cached, we don't need to
// handle errors. All this code assumes criteria is
// a simple string!
if (yourDataCache[action.criteria]) {
return Observable.of({
type: LOAD_DATA_CACHED,
criteria: action.criteria
});
} else {
return getData(action.criteria)
.map(x => loadDataSuccess(x.response))
.catch(error => Observable.of(loadDataFailure(error.xhr)))
}
});
You might find it easier to store the loading (e.g. isLoading
) state in its own reducer, so you don't need to do additional merging of it with the actual response payloads--I personally do that, but I didn't in this example as I've most don't and it sometimes throws them off.
However, you clarified that you wish to use RxJS replay instead, so here's one way of doing that
(see my comments on your answer first)
If you want to cache based on the "criteria", you can create your own little helper that does this:
const cache = new Map();
const getData = criteria => {
if (cache.has(criteria)) {
return cache.get(criteria);
} else {
// Using publishReplay and refCount so that it keeps the results
// cached and ready to emit when someone subscribes again later
const data$ = Observable.ajax.post('some-url', criteria)
.publishReplay(1)
.refCount();
// Store the resulting Observable in our cache
// IMPORTANT: `criteria` needs to be something that will later
// have reference equallity. e.g. a string
// If its an object and you create a new version of that object every time
// then the cache will never get a hit, since cache.has(criteria) will return
// false for objects with different identities. `{ foo: 1 } !== { foo: 1}`
cache.set(criteria, data$);
return data$;
}
};
const loadDataEpic = action$ =>
action$.ofType(LOAD_DATA_REQUEST)
.mergeMap(action =>
getData(action.criteria)
.map(x => loadDataSuccess(x.response))
.catch(error => Observable.of(
loadDataFailure(error.xhr)
))
);
However, it's critical that criteria
is something that will always have strict reference equality given the same intent. If it's an object and you create a new object every time, they will never get a cache hit because they are not the same reference, they have different identities--regardless of whether they have the same content.
let a = { foo: 1 };
let b = { foo: 1 };
a === b;
// false, because they are not the same object!
If you need to use objects and cannot otherwise key off some primitive (like an ID string), you will need some way to serialize them.
JSON.stringify({ foo: 1, bar: 2 }) === JSON.stringify({ foo: 1, bar: 2 })
// true, but only if the keys were defined in the same order!!
JSON.stringify({ bar: 2, foo: 1 }) === JSON.stringify({ foo: 1, bar: 2 })
// false, in most browsers JSON.stringify is not "stable" so because
// the keys are defined in a different order, they serialize differently
// See https://github.com/substack/json-stable-stringify
Try as much as possible to use and send unique IDs to the server, rather than complex JSON. Sometimes it's inescapable for various reasons, but try hard! As you see with cache stuff, it will make your life much easier.
You may want to consider cache eviction. Does this cache always keep results indefinitely while the window is open? Is that bad? Depending on how often, the size, etc this could cause significant memory leaks. Careful :)
Upvotes: 7
Reputation: 479
First you need a redux reducer that will listen to the action from loadDataSuccess and cache the data in state.
Second, in your epic filter the stream and check whether there is any data in state yet. You can access any value in state using store.getState().
const loadDataEpic = (action$, store) =>
action$.ofType(LOAD_DATA_REQUEST)
.filter(() => !store.getState().yourDataCache)
.mergeMap(action => getData$(action.criteria)
.map(x => loadDataSuccess(x.response))
.catch(error => Observable.of(loadDataFailure(error.xhr)))
);
Upvotes: 0