Sequential async actions in Redux and isFetching

Viewed 2637

My React component needs to fetch some data A asynchronously, and then based on its contents, send a second async request to get data B. All result are stored in Redux and we use Redux-thunk.

There may be several components on the page at the same time that all need A, so there' s a good chance that it already exists n Redux; but another component could also be fetching it, and then isFetching is true. I don't want to have multiple identical requests (not least because the browser cancels them).

Solutions for sequential actions like https://github.com/reactjs/redux/issues/1676 and https://github.com/reactjs/redux/issues/723 propose a redux-thunk that returns a promise, one that is already resolved if the object is already present; e.g.:

function getA(uuid) {
    return (dispatch, getState) => {
        const currentA = getState().a[uuid];

        if (currentA) {
            // Return resolved promise with the already existing object
            return Promise.resolve(currentA);
        } else {
            // Return async promise
            return goFetchA(uuid).then(objectA => {
                dispatch(receivedA(uuid, objectA));
                return objectA;
            });
        }
    };
}

function getAthenB(uuidA, uuidB) {
    return dispatch => 
        dispatch(getA(uuidA)).then(
            objectA => dispatch(getB(objectA, uuidB)));
}

So far, so good. But what kind of promise can I return in case the state contains both the object and an 'isFetching' boolean? This would be trivial if we could store the actual Promise of the request in the state, but that sort of thing shouldn't go into a Redux state.

function getA(uuid) {
    return (dispatch, getState) => {
        const currentA = getState().a[uuid];

        if (currentA) {
            if (!currentA.isFetching) {
                return Promise.resolve(currentA.data);
            } else {
                // WHAT TO RETURN HERE?
            }
        } else {
            dispatch(startFetchingA(uuid));
            return goFetchA(uuid).then(objectA => {
                receivedObjectA(uuid, objectA);
                return objectA;
            });
        }
    };
}

A similar problem exists when I want to cancel an ongoing request -- it's not stored anywhere, so a solution that also helps with that would be ideal.

1 Answers

I believe it's not possible to handle this kind of scenario without introducing some mutable, unserializable storage, which will store running requests, along with your pure, fully serializable Redux store.

There is a bunch of libraries out there which can help you with handling this kind of side-effects (Redux Saga and Redux Observable, to name a few). But if your case is limited to what you have just described, I would suggest using extra argument feature of Redux Thunk, which, I believe, is designed especially for such cases.

Fast sketch of how you can implement your task with an extra argument:

/**
 * init.js
 */
const api = ...; // creating api object here
const requests = {};

const store = createStore(
  reducer,
  applyMiddleware(thunk.withExtraArgument({api, requests}))
)

/**
 * actions.js
 */
function getA(uuid) {
    return (dispatch, getState, {api, requests}) => {
        if (requests[uuid]) return requests[uuid];

        const currentA = getState().a[uuid];
        if (currentA) {
             return Promise.resolve(currentA.data);
        }

        dispatch(startFetchingA(uuid));
        const request = requests[uuid] = api.fetchA(uuid);
        return request.then(objectA => {
            delete requests[uuid];
            receivedObjectA(uuid, objectA);
            return objectA;
        });
    };
}

Same technique can be used to cancel requests.

Also, you can probably end up with cleaner solution by introducing custom Redux middleware, but it really depends on what is your true full-blown case, and not this simplified one.

Related