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.
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.
Share Improve this question asked Nov 3, 2020 at 11:12 ondrej.parondrej.par 2034 silver badges11 bronze badges 3- 1 Redux state must always be kept serializable. – markerikson Commented Nov 3, 2020 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 Commented Nov 3, 2020 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 Commented Nov 3, 2020 at 20:17
2 Answers
Reset to default 3Based on @HMR answer, I was able to put this together, but it's quite plicated.
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
);
}
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,
pose,
} 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 poseEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || pose;
const store = createStore(
reducer,
initialState,
poseEnhancers(
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