3

I'm using createAsyncThunk to make asynchronous requests to some API. Only one request should be active at any given moment. I understand that the request can be aborted using a provided AbortSignal if I have the Promise returned from the previous invocation. Question is, can the thunk itself somehow abort the previous request "autonomously"? I was considering two options:

  • keeping the last AbortSignal in the state. Seems wrong, because state should be serializable.
  • keeping the last Promise and its AbortSignal in global variable. Seems also wrong, because, you know, global variables.

Any ideas? Thank you.

ondrej.par
  • 191
  • 3
  • 10
  • 1
    [Redux state must _always_ be kept serializable](https://redux.js.org/style-guide/style-guide#do-not-put-non-serializable-values-in-state-or-actions). – markerikson Nov 03 '20 at 14:58
  • It will not abort the request, you can call abort on the promise like that dispatching the thunk returns but it won't abort the xhr request. I'm not sure what you mean by "only one active" does that mean you only want to write to state if the request that resolved was the last one? – HMR Nov 03 '20 at 16:55
  • @HMR: calling abort on the signal will invoke event listeners installed on that signal. The API will install its own listener on that event and act accordingly (btw, in my case, it's not HTTP API, it's a video player thing; but it is also possible to abort XHR request via AbortSignal - fetch supports that). By "only one active" I mean that only results of the last request should be written to the state, but also that previous calls should receive the abort event and stop doing what they're doing. – ondrej.par Nov 03 '20 at 20:17

2 Answers2

2

I don't know how your specific api works but below is a working example of how you can put the abort logic in the action and reducer, it will abort any previously active fake fetch when a newer fetch is made:

import * as React from 'react';
import ReactDOM from 'react-dom';
import {
  createStore,
  applyMiddleware,
  compose,
} from 'redux';
import {
  Provider,
  useDispatch,
  useSelector,
} from 'react-redux';
import {
  createAsyncThunk,
  createSlice,
} from '@reduxjs/toolkit';

const initialState = {
  entities: [],
};
// constant value to reject with if aborted
const ABORT = 'ABORT';
// fake signal constructor
function Signal() {
  this.listener = () => undefined;
  this.abort = function () {
    this.listener();
  };
}
const fakeFetch = (signal, result, time) =>
  new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve(result), time);
    signal.listener = () => {
      clearTimeout(timer);
      reject(ABORT);
    };
  });
// will abort previous active request if there is one
const latest = (fn) => {
  let previous = false;
  return (signal, result, time) => {
    if (previous) {
      previous.abort();
    }
    previous = signal;
    return fn(signal, result, time).finally(() => {
      //reset previous
      previous = false;
    });
  };
};
// fake fetch that will abort previous active is there is one
const latestFakeFetch = latest(fakeFetch);

const fetchUserById = createAsyncThunk(
  'users/fetchByIdStatus',
  async ({ id, time }) => {
    const response = await latestFakeFetch(
      new Signal(),
      id,
      time
    );
    return response;
  }
);
const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: [], loading: 'idle' },
  reducers: {},
  extraReducers: {
    [fetchUserById.fulfilled]: (state, action) => {
      state.entities.push(action.payload);
    },
    [fetchUserById.rejected]: (state, action) => {
      if (action?.error?.message === ABORT) {
        //do nothing
      }
    },
  },
});

const reducer = usersSlice.reducer;
//creating store with redux dev tools
const composeEnhancers =
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
  reducer,
  initialState,
  composeEnhancers(
    applyMiddleware(
      ({ dispatch, getState }) => (next) => (action) =>
        typeof action === 'function'
          ? action(dispatch, getState)
          : next(action)
    )
  )
);
const App = () => {
  const dispatch = useDispatch();
  React.useEffect(() => {
    //this will be aborted as soon as the next request is made
    dispatch(
      fetchUserById({ id: 'will abort', time: 200 })
    );
    dispatch(fetchUserById({ id: 'ok', time: 100 }));
  }, [dispatch]);
  return 'hello';
};

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

If you only need to resolve a promise if it was the latest requested promise and no need to abort or cancel ongoing promises (ignore resolve if it wasn't latest) then you can do the following:

const REPLACED_BY_NEWER = 'REPLACED_BY_NEWER';
const resolveLatest = (fn) => {
  const shared = {};
  return (...args) => {
    //set shared.current to a unique object reference
    const current = {};
    shared.current = current;
    fn(...args).then((resolve) => {
      //see if object reference has changed
      //  if so it was replaced by a newer one
      if (shared.current !== current) {
        return Promise.reject(REPLACED_BY_NEWER);
      }
      return resolve;
    });
  };
};

How it's used is demonstrated in this answer

HMR
  • 37,593
  • 24
  • 91
  • 160
  • 1
    It will need a bit more work to wait for the previous promise to actually finish (which is not needed generally, but it is necessary in my case). But it's a great idea, thanks.| For the curious reader: the main part is the "latest" function. It creates a new function (wrapper) from the original payload creator and returns it. The wrapper stores its signal in the "previous" variable, which is local inside the "latest" function, so each wrapper has its own variable. To use this, wrap your payload creator and pass it to createAsyncThunk. – ondrej.par Nov 04 '20 at 10:06
  • @ondrej.par I updated the answer with a wrapper that will let all promises resolve but reject them if it wasn't the latest request in a set of active requests. – HMR Nov 04 '20 at 10:22
  • Rejecing manually on abort should not be necessary, because redux-toolkit automatically does that upon aborting a signal. – ondrej.par Nov 04 '20 at 10:41
  • Notice that this doesn't work if you are trying to use `latestFakeFetch` multiple times (for different resources). Instead, you need to call `latest()` once for each independent resource. – Bergi Nov 04 '20 at 10:50
  • 1
    I edited your answer for two reasons: 1) redux-toolkit already provides AbortSignal instance, so that one should be used 2) upon finalization, ```previous``` should only be cleared if it's still our signal (it could have been replaced by a newer one). – ondrej.par Nov 04 '20 at 10:53
  • @Bergi The latest/last is when you trigger an async function multiple times but only are interested in the last resolve [example here](https://stackoverflow.com/a/62751846/1641941). If you want to group by arguments then you can do something like [this](https://gist.github.com/amsterdamharu/2dde4a6f531251f3769206ee44458af7) – HMR Nov 04 '20 at 12:03
  • @ondrej.par The AbortSignal of asyncThunk has to be triggered externally, so you have to put the logic in the component that dispatches the action to call [abort on the promise it returns](https://redux-toolkit.js.org/api/createAsyncThunk#listening-for-abort-events). The resolveLatest has the behaviour you want built in the thunk so no extra code is needed in the component dispatching the thunk action. Using the AbortSignal may work but doesn't cancel the request. – HMR Nov 04 '20 at 12:10
  • @HMR oh, I see. I can't actually abort the operation without the AbortController. This is sad, I'll see if I can live with that. Please ignore my edit. At least, the previous thunk is aborted and the results of the payload creator are ignored. – ondrej.par Nov 04 '20 at 21:22
2

Based on @HMR answer, I was able to put this together, but it's quite complicated.

The following function creates "internal" async thunk that does the real job, and "outer" async thunk that delegates to the internal one and aborts previous dispatches, if any.

The payload creator of the internal thunk is also wrapped to: 1) wait for the previous invocation of payload creator to finish, 2) skip calling the real payload creator (and thus the API call) if the action was aborted while waiting.

import { createAsyncThunk, AsyncThunk, AsyncThunkPayloadCreator, unwrapResult } from '@reduxjs/toolkit';

export function createNonConcurrentAsyncThunk<Returned, ThunkArg>(
  typePrefix: string,
  payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg>,
  options?: Parameters<typeof createAsyncThunk>[2]
): AsyncThunk<Returned, ThunkArg, unknown> {
  let pending: {
    payloadPromise?: Promise<unknown>;
    actionAbort?: () => void;
  } = {};

  const wrappedPayloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg> = (arg, thunkAPI) => {
    const run = () => {
      if (thunkAPI.signal.aborted) {
        return thunkAPI.rejectWithValue({name: 'AbortError', message: 'Aborted'});
      }
      const promise = Promise.resolve(payloadCreator(arg, thunkAPI)).finally(() => {
        if (pending.payloadPromise === promise) {
          pending.payloadPromise = null;
        }
      });
      return pending.payloadPromise = promise;
    }

    if (pending.payloadPromise) {
      return pending.payloadPromise = pending.payloadPromise.then(run, run); // don't use finally(), replace result
    } else {
      return run();
    }
  };

  const internalThunk = createAsyncThunk(typePrefix + '-protected', wrappedPayloadCreator);

  return createAsyncThunk<Returned, ThunkArg>(
    typePrefix,
    async (arg, thunkAPI) => {
      if (pending.actionAbort) {
        pending.actionAbort();
      }
      const internalPromise = thunkAPI.dispatch(internalThunk(arg));
      const abort = internalPromise.abort;
      pending.actionAbort = abort;
      return internalPromise
        .then(unwrapResult)
        .finally(() => {
          if (pending.actionAbort === abort) {
            pending.actionAbort = null;
          }
        });
    },
    options
  );
}
ondrej.par
  • 191
  • 3
  • 10