How to avoid duplicate API requests with Redux-Saga?

11,090

Solution 1

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.

Solution 2

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
  }
}

Solution 3

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))
  }
}
Share:
11,090
Mouad Debbar
Author by

Mouad Debbar

Updated on June 28, 2022

Comments

  • Mouad Debbar
    Mouad Debbar almost 2 years

    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:

    1. Maintaining the status of API calls to avoid sending duplicate requests.
    2. 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!

  • Sander van den Akker
    Sander van den Akker over 5 years
    This does not prevent the call being triggered twice, it only discards the previous (unfinished) call.
  • Oli C
    Oli C about 4 years
    Question asks how to "avoid sending duplicate requests". Where as this does not do that, it discards unfinished calls.