How to avoid duplicate API requests with Redux-Saga? - javascript

So far I like Redux better than other Flux implementations, and I'm using it to re-write our front end application.
The main struggling points that I'm facing:
Maintaining the status of API calls to avoid sending duplicate requests.
Maintaining relationships between records.
The first issue could be solved by keeping a status field in the sub-state of each type of data. E.g.:
function postsReducer(state, action) {
switch(action.type) {
case "FETCH_POSTS":
return {
...state,
status: "loading",
};
case "LOADED_POSTS":
return {
status: "complete",
posts: action.posts,
};
}
}
function commentsReducer(state, action) {
const { type, postId } = action;
switch(type) {
case "FETCH_COMMENTS_OF_POST":
return {
...state,
status: { ...state.status, [postId]: "loading" },
};
case "LOADED_COMMENTS_OF_POST":
return {
status: { ...state.status, [postId]: "complete" },
posts: { ...state.posts, [postId]: action.posts },
};
}
}
Now I can make a Saga for Posts and another one for Comments. Each of the Sagas knows how to get the status of requests. But that would lead to a lot of duplicate code soon (e.g. Posts, Comments, Likes, Reactions, Authors, etc).
I'm wondering if there is a good way to avoid all that duplicate code.
The 2nd issue comes to existence when I need to get a comment by ID from the redux store. Are there best practices for handling relationships between data?
Thanks!

redux-saga now has takeLeading(pattern, saga, ...args)
Version 1.0+ of redux-saga has takeLeading that spawns a saga on each action dispatched to the Store that matches pattern. After spawning a task once, it blocks until the spawned saga completes and then starts to listen for a pattern again.
Previously I implemented this solution from the owner of Redux Saga and it worked really well - I was getting errors from API calls sometimes being fired twice:
You could create a higher order saga for this, which would look something like this:
function* takeOneAndBlock(pattern, worker, ...args) {
const task = yield fork(function* () {
while (true) {
const action = yield take(pattern)
yield call(worker, ...args, action)
}
})
return task
}
and use it like this:
function* fetchRequest() {
try {
yield put({type: 'FETCH_START'});
const res = yield call(api.fetch);
yield put({type: 'FETCH_SUCCESS'});
} catch (err) {
yield put({type: 'FETCH_FAILURE'});
}
}
yield takeOneAndBlock('FETCH_REQUEST', fetchRequest)
In my opinion this way is far way more elegant and also its behaviour can be easily customized depending on your needs.

I had the exact same issue in my project.
I have tried redux-saga, it seems that it's really a sensible tool to control the data flow with redux on side effects. However, it's a little complex to deal with the real world problem such as duplicate requests and handling relationships between data.
So I created a small library 'redux-dataloader' to solve this problem.
Action Creators
import { load } from 'redux-dataloader'
function fetchPostsRequest() {
// Wrap the original action with load(), it returns a Promise of this action.
return load({
type: 'FETCH_POSTS'
});
}
function fetchPostsSuccess(posts) {
return {
type: 'LOADED_POSTS',
posts: posts
};
}
function fetchCommentsRequest(postId) {
return load({
type: 'FETCH_COMMENTS',
postId: postId
});
}
function fetchCommentsSuccess(postId, comments) {
return {
type: 'LOADED_COMMENTS_OF_POST',
postId: postId,
comments: comments
}
}
Create side loaders for request actions
Then create data loaders for 'FETCH_POSTS' and 'FETCH_COMMENTS':
import { createLoader, fixedWait } from 'redux-dataloader';
const postsLoader = createLoader('FETCH_POSTS', {
success: (ctx, data) => {
// You can get dispatch(), getState() and request action from ctx basically.
const { postId } = ctx.action;
return fetchPostsSuccess(data);
},
error: (ctx, errData) => {
// return an error action
},
shouldFetch: (ctx) => {
// (optional) this method prevent fetch()
},
fetch: async (ctx) => {
// Start fetching posts, use async/await or return a Promise
// ...
}
});
const commentsLoader = createLoader('FETCH_COMMENTS', {
success: (ctx, data) => {
const { postId } = ctx.action;
return fetchCommentsSuccess(postId, data);
},
error: (ctx, errData) => {
// return an error action
},
shouldFetch: (ctx) => {
const { postId } = ctx.action;
return !!ctx.getState().comments.comments[postId];
},
fetch: async (ctx) => {
const { postId } = ctx.action;
// Start fetching comments by postId, use async/await or return a Promise
// ...
},
}, {
// You can also customize ttl, and retry strategies
ttl: 10000, // Don't fetch data with same request action within 10s
retryTimes: 3, // Try 3 times in total when error occurs
retryWait: fixedWait(1000), // sleeps 1s before retrying
});
export default [
postsLoader,
commentsLoader
];
Apply redux-dataloader to redux store
import { createDataLoaderMiddleware } from 'redux-dataloader';
import loaders from './dataloaders';
import rootReducer from './reducers/index';
import { createStore, applyMiddleware } from 'redux';
function configureStore() {
const dataLoaderMiddleware = createDataLoaderMiddleware(loaders, {
// (optional) add some helpers to ctx that can be used in loader
});
return createStore(
rootReducer,
applyMiddleware(dataLoaderMiddleware)
);
}
Handle data chain
OK, then just use dispatch(requestAction) to handle relationships between data.
class PostContainer extends React.Component {
componentDidMount() {
const dispatch = this.props.dispatch;
const getState = this.props.getState;
dispatch(fetchPostsRequest()).then(() => {
// Always get data from store!
const postPromises = getState().posts.posts.map(post => {
return dispatch(fetchCommentsRequest(post.id));
});
return Promise.all(postPromises);
}).then() => {
// ...
});
}
render() {
// ...
}
}
export default connect(
state => ()
)(PostContainer);
NOTICE The promised of request action with be cached within ttl, and prevent duplicated requests.
BTW, if you are using async/await, you can handle data fetching with redux-dataloader like this:
async function fetchData(props, store) {
try {
const { dispatch, getState } = store;
await dispatch(fetchUserRequest(props.userId));
const userId = getState().users.user.id;
await dispatch(fetchPostsRequest(userId));
const posts = getState().posts.userPosts[userId];
const commentRequests = posts.map(post => fetchCommentsRequest(post.id))
await Promise.all(commentRequests);
} catch (err) {
// error handler
}
}

First, you can create a generic action creator for fetching post.
function fetchPost(id) {
return {
type: 'FETCH_POST_REQUEST',
payload: id,
};
}
function fetchPostSuccess(post, likes, comments) {
return {
type: 'FETCH_POST_SUCCESS',
payload: {
post,
likes,
comments,
},
};
}
When you call this fetch post action, it'll trigger onFetchPost saga.
function* watchFetchPost() {
yield* takeLatest('FETCH_POST_REQUEST', onFetchPost);
}
function* onFetchPost(action) {
const id = action.payload;
try {
// This will do the trick for you.
const [ post, likes, comments ] = yield [
call(Api.getPost, id),
call(Api.getLikesOfPost, id),
call(Api.getCommentsOfPost, id),
];
// Instead of dispatching three different actions, heres just one!
yield put(fetchPostSuccess(post, likes, comments));
} catch(error) {
yield put(fetchPostFailure(error))
}
}

Related

React Custom Hooks - Handling errors

I am displaying all my api requests errors in a toast.
In my code, I have separated concepts, moving the components logic to business/ui hooks.
In order to render a toast (an imperative component), I just do the following inside a functional component:
const toast = useToast(); // UI hook
toast.display(message, { type: "error", duration: 500 });
and, in order to connect to my api, I can use custom business hooks, for example:
const useRequestSomething() {
const [data, setData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const isRequesting = useRef(false);
const requestSomething = async (someParam, onSuccess = undefined, onError = undefined) => {
if (isRequesting.current) return;
isRequesting.current = true;
setIsLoading(true);
try {
const data = await api.requestSomething(someParam);
setData(data);
onSuccess?.();
} catch(err) {
onError?.(err);
}
setIsLoading(false);
isRequesting.current = false;
}
return {
data,
isLoading,
requestSomething
}
}
My main concern is the separation of concepts... I don't think it is a good idea to use the useToast() inside the this hook that is a container of my business logic... although it may be a good idea.
So, in order to handle errors, inside any component, I can do something like:
function MyComponent() {
const toast = useToast();
const { t } = useTranslation(); // i18n.js hook
const { data, isLoading, requestSomething } = useRequestSomething();
const handleOnPress = () => {
requestSomething("x", undefined, handleOnRequestSomethingError);
}
const handleOnRequestSomethingError = (err) => {
toast.display(t(err), { type: "error", duration: 500 });
}
... JSX
}
It seems that I have defined some kind of callback-based api with the business hook... what do you think about my implementation?
Is it an anti-pattern to handle errors this way (with callbacks) inside hooks?
What is the typical approach to handle this situations? (I cannot use useQuery, because of my backend)
I think your solution is good, but, IMHO, instead of prematurely handling the error, I like to let the error propagate to where we actually know how to handle it. For example, I would do this.
const requestSomething = async (params) = {
...
try {
await api.doRequest(params);
} catch (err) {
... do some common clean up ...
throw err;
}
}
const handleOnPress = async () => {
try {
await requestSomething("x");
} catch (err) {
toast.display(t(err), { type: "error", duration: 500 });
}
}
Actually, I would wrap it in a general error handler like this.
const handleOnPress = async () => {
await withGeneralErrorHandling(async () => {
try {
await requestSomething("x");
} catch (err) {
if (err.errorCode === 'SOME_KNOWN_CASE') {
toast.display(t(err), { type: "error", duration: 500 });
} else {
throw err;
}
}
});
}
async function withGeneralErrorHandling(callback: () => Promise<void>) {
try {
await callback()
} catch (err) {
if (err.errorCode === 'GENERAL_CASE1') { ...}
else if (err.errorCode === 'GENERAL_CASE2') { ... }
else {
if (isProduction) { reportError(err); }
else { throw err; }
}
}
}
This is because I usually cannot list out all the error cases at the first implementation. Each error case will be discovered incrementally. I have to let it fail fast by letting it propagate to as closest to the outermost controller as possible.
By utilizing this built-in error propagation, you retain the stack trace information and can know exactly where the error occurs.
Yeah, your Component knows about the Toast, every future component which handles some errors will know about the Toast.
This makes your error handling logic a little rigid, if you need to use another way of handling errors in the future, you'll have to edit every component.
I'd use some state management system (redux, mobx, whatever).
The idea is that in order to show an error you need to update the state of your application. Your toast component will be subscribed to the state change and react accordingly.
This way you depend on the state, not some actual component/way of displaying errors, which is more abstract and flexible.

Race condition when updating redux state?

I'm using redux-saga with takeEvery which allows multiple actions to execute simultaneously:
export function* watchSaga() {
yield all([
takeEvery(actionTypes.FETCH_DATA, fetchDataSaga)
])
}
and the fetchDataSaga looks like:
export function* fetchDataSaga(action) {
yield put(actions.fetchDataStart())
try {
const response = yield axios.post(url, body)
yield put(actions.fetchDataSuccess(response.data))
} catch (error) {
yield put(actions.fetchDataFail(error.response.data.error))
}
}
in fetchDataSuccess, I'm adding a new field (or updating if exist) into my redux state object:
const initialState = {
myObj: {}
}
...
const fetchDataSuccess = (state, action) => {
const newKey = action.newKey
return {
...state,
myObj: {
...myObj,
[newKey]: {
...myObj[newKey],
timestamp: new Date().getTime()
}
}
}
}
So I make concurrent calls to the saga ,let's say 100 times:
yield all([
call(fetchDataSaga, action[0])
call(fetchDataSaga, action[1])
call(fetchDataSaga, action[2])
...
call(fetchDataSaga, action[99])
])
There should be 100 fields in myObj afterward.
My question is, am I going to get the latest state every time in the fetchDataSuccess reducer? Or will there be a race condition that one of the reducers read the old previous state, and if so, how can I prevent it?

How to jest-test a redux-thunk action with two dispatches inside?

I'm using react and redux-thunk.
I have this action here, to fetch documents from API (I need the dispatch to call it so I wouldn't like to remove it) and then save the docs on my store with a second dispatch:
export function searchDocuments(search) {
return async (dispatch, getState) => {
const { loggedUser } = getState().userReducer;
let documents = [];
if (search.length > 1) {
documents = await dispatch(apiSearchFiles(search, loggedUser));
}
return dispatch({
type: UPDATE_SEARCH_DOCUMENTS,
payload: documents,
});
};
}
And I'm trying test it with:
describe('Call searchDocuments correctly', () => {
it('should call the correct type on searchDocuments dispatch', async () => {
const store = mockStore({
userReducer: { loggedUser: { name: 'user1' } },
documentsResults: [],
});
const payload = [{ name: 'doc1' }];
const expectedActions = [{ type: UPDATE_SEARCH_DOCUMENTS, payload }];
return store.dispatch(searchDocuments('searchTerm')).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});
But I always got this error, pointing to this line:
Error:
Actions must be plain objects. Use custom middleware for async actions
Line:
documents = await dispatch(gDriveSearchFiles(search, loggedUser));
I tried to follow the approach from this similar question but it didn't work for me, his action is a little different.
So I would like to know if there is any way to handle that first dispatch inside of my action.

Using react-redux and redux-thunk for loading indicator

I am working a project which use react-redux and redux-thunk for development. Previously the approach was to check api calls one by one and manually check if all the data is being fetched. As I found that after v7.0, batch is introduced in react-redux to help solving the issue. But the page also requires loading indicator as well.
The current approach is having several dispatches in batch to reduce unnecessary re-rendering, and manually check if all the data is fetched in the render, but I was wondering if there is any other method that can be applied on the batch to cut some hard code check.
Here is the current sample code:
// in action file
...
function fetchSomeData() {
// call api to store data
return dispatch => {
batch(() => {
dispatch(fetchData1());
dispatch(fetchData2());
dispatch(fetchData3());
..some more dispatches...
});
}
}
...
// in react component
dataLoaded(){
....retrieve all the data from different places...
if (!data1) return false;
if (!data2) return false;
...check all the data...
return true;
}
...
render() {
if (this.dataLoaded()) {
return actual_content;
} else {
return loading_content;
}
}
...
I tried to directly use then, and create another method, return batch, call fetchSomeData, then use then(), but all produce "Cannot read property 'then' of undefined" error.
I also used Promise.all, but with no luck. Use of Promise.all is shown as below:
function fetchSomeData() {
// call api to store data
return dispatch => {
Promise.all([
dispatch(fetchData1());
dispatch(fetchData2());
dispatch(fetchData3());
..some more dispatches...
])
.then(() => dispatch(setLoading(false)));
}
}
I also checked other posts on the stackoverflow, but many posts suggest other middleware, and additional dependency requires approval as one of the requirement is limited bandwidth, use minimal dependencies as needed.
Redux Toolkit actually helped me to resolve this issue. A sample code looks like:
userSlice
import { createAsyncThunk, createSlice } from '#reduxjs/toolkit';
import axios from 'axios';
export const userSlice = createSlice({
name: 'user',
initialState: {
data: [],
isLoading: false,
error: null,
},
extraReducers(builder) {
builder.addCase(fetchUsers.pending, (state, action) => {
state.isLoading = true;
});
builder.addCase(fetchUsers.fulfilled, (state, action) => {
state.isLoading = false;
state.data = action.payload;
});
builder.addCase(fetchUsers.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error;
});
},
});
export const fetchUsers = createAsyncThunk('users/fetch', async () => {
const response = await axios.get(
'https://jsonplaceholder.typicode.com/users'
);
console.log(new Date());
return response.data;
});
export const usersReducer = userSlice.reducer;
postSlice
// similar configuration as user
function later(delay) {
return new Promise(function (resolve) {
setTimeout(resolve, delay);
});
}
export const fetchPosts = createAsyncThunk('posts/fetch', async () => {
await later(5000);
const response = await axios.get(
'https://jsonplaceholder.typicode.com/posts'
);
console.log(new Date());
return response.data;
});
third slice to call the thunks
This action doesn't have to be async thunk, writing a custom thunk should also work.
// similar configuration as previous
export const fetchHome = createAsyncThunk(
'home/fetch',
async (_, thunkAPI) => {
const res = await Promise.all([
thunkAPI.dispatch(fetchUsers()),
thunkAPI.dispatch(fetchPosts()),
]);
console.log(res);
return [];
}
);
The result looks like:
The fetch home thunk waited until all the async thunks have been resolved and then emit the final result.
reference: Dispatch action on the createAsyncThunk?

How to wait for all actions(success/failure after request) to dispatch in redux-saga?

I have following actions:
export function createRequest(inputValues) {
return {
type: actions.PERSON_CREATE_REQUEST,
payload: {
inputValues
}
};
}
export function createSuccess(person) {
return {
type: actions.PERSON_CREATE_SUCCESS,
payload: {
person: person
}
};
}
export function createFailure(error) {
return {
type: actions.PERSON_CREATE_FAILURE,
payload: {
error
}
};
}
Saga:
export function* createPerson(action) {
try {
const data = yield call((async () => {
const person = action.payload.inputValues;
return await api.post(person);
}));
yield put(createSuccess(data));
} catch (error) {
yield put(createFailure(error));
}
}
export function* watchCreatePerson() {
yield takeEvery('PERSON_CREATE_REQUEST', createPerson);
}
export function* rootSaga() {
yield [
fork(watchCreatePerson),
];
}
Function that is called on form submit:
handleSubmit = async (event, values) => {
event.preventDefault();
await store.dispatch(action.createRequest(values));
if (this.props.error === null) {
store.dispatch(reset(this.props.form));
}
};
After await store.dispatch(action.createRequest(values)); I want to check if PERSON_CREATE_FAILURE or PERSON_CREATE_SUCCESS was dispatched. On PERSON_CREATE_FAILURE I set error in store that is mapped to props. But next line of code with if is called after PERSON_CREATE_REQUEST is dispatched. But I need to wait until PERSON_CREATE_FAILURE or PERSON_CREATE_SUCCESS dispatch. How to change saga to wait for that actions dispatches?
In the handleSubmit function, just dispatch the createRequest action, don't await it's response at this point in your code.
Now, if the request fails, write a reducer to handle the PERSON_CREATE_FAILURE action. Within this reducer you can then set the application state appropriately i.e. reset the form state. Then using MapStateToProps for your component which encompasses your form, you form will automatically re-render to match the updated (and in this case reset) form state.
Note that with this approach you will need to create your form's state in the Redux store, but doing so will simplify the application's state lifecyle.

Categories

Resources