This post covers how to effectively handle responses from concurrent API calls made to the same endpoint. I recently faced this situation and found a reliable solution, so I decided to share the experience. I’ll start by looking at the issues that can occur when processing concurrent responses in your React app. Then, I’ll show a solution that involves managing the state of the application globally, rather than within individual components, allowing for explicit and deliberate management of interactions between API calls and the application’s state.
Setting Up A Test React App
Imagine we have a UI with a list or a table of items (the specifics don’t matter). Each of item listed has a particular status, and the UI allows the user to change the status by clicking on a corresponding link or button next to the item.
No matter which item is clicked, it calls the same API endpoint, but using a different item ID for the item clicked. At the same time, we want to show the user some kind of loading indicator. This gives feedback to the user that the application is working while also preventing an accidental double-click by the user.
The UI might look something like this:

Understanding the Issue with Concurrent Same-Endpoint API Calls
The front-end logic will keep the ID of the clicked item in any appropriate data structure (the choice is up to you, but I prefer a plain object). The component checks if that data structure contains the ID as a token of the currently pending request. Depending on the result of that check, it conditionally renders UI the UI as either the button or a loading indicator.
Our test application was built with the RTK (Redux Toolkit) Query package and initially used the hooks useState and useEffect for internal state management. That was an obvious solution, such data structure is considered for keeping internal state only.
The code looks like this
function createData(name: string, status: string, id: string) {
return { name, status, id };
}
const rows = [
createData("Item 1", "Status 1", 'uniqueId12'),
createData("Item 2", "Status 2", 'uniqueId23'),
createData("Item 3", "Status 3", 'uniqueId34'),
];
const MainScreen: FC = (): JSX.Element => {
const [ loadingIds, setLoadingIds ] = useState<{ [key: string]: boolean }>({});
const [
changeStatus,
changeStatusResult,
] = statusesStore.api.useLazyChangeStatusQuery();
const handleChangeStatus = useCallback((entryId: string) => {
setLoadingIds(prevState => {
return {
...prevState,
[entryId]: true
}
});
changeStatus({ entryId });
}, [changeStatus]);
useEffect(() => {
const {isError, isSuccess, originalArgs, isFetching} = changeStatusResult;
if (isFetching) {
return;
}
if ((isSuccess || isError) && originalArgs) {
setLoadingIds(prevState => {
return {
...prevState,
[originalArgs?.entryId]: false
}
});
}
}, [changeStatusResult]);
return (
<>
<section className='page-wrapper'>
<Container>
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Title</TableCell>
<TableCell align="center">Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={row.name}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell component="th" scope="row">
{row.name}
</TableCell>
<TableCell align="center">{row.status}</TableCell>
<TableCell align="right" style={{ width: '200px' }}>
<>
{loadingIds[row.id] && (
<>Loading...</>
)}
{!loadingIds[row.id] && (
<Button
variant="text"
onClick={() => handleChangeStatus(row.id)}
>Change status</Button>
)}
</>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Container>
</section>
</>
);
};
export default MainScreen;
When users click a single button or click several buttons with a substantial interval between those events, everything works as you’d expect. However, the issue arises when the user presses several buttons in rapid succession—the requests fire and complete successfully but the UI starts to look very strange.
For example, the loading indicator turns back into a button only for the last request completed. The issue will only gets worse when the user’s connection is slow (you can simulate this using the developer tools in a browser). If the connection is fast, there is a high probability that a request is executed before the user can fire off another one.

These issues occur because useEffect processes only the result of one request and marks only that corresponding item’s ID as completed. For some reason, the results of previous requests are ignored by the useEffect hook. It is triggered only with the latest completed request. It doesn’t matter if it is a query (GET) request, or a mutation request (POST etc.). RTK Query allows you to reset every mutation request to its initial state but it is not helpful – the issue persists.
Possible Explanations
I wasn’t able to find any clear and direct explanation in the official documentation for RTK Query lib or for React hooks themselves. However, my best guess about why this happens is implicitly confirmed by several cases described by the community. I think this issue happens because of React’s internal optimization and synchronization of updating state with the useState hook, rerendering of components, and firing the useEffect execution.
Also, we should keep in mind that useEffect is executed AFTER component rendering. At the same time, it seems hard to predict the moment and order when RTK hook will update the results of the API call and the way it is synchronized with the life-cycle of the component.
An Effective Solution
Let’s refactor the app and move the collection where the IDs of clicked items are stored from inside the component’s state to the Redux store. Steps are easy and self-declarative, no additional installation of any packages is required.
Full code (initial and suggested solution, separate branch for every solution) you can find in our GitHub Repository.
1. Define the structure of the store for a particular feature or global store. It depends on the architecture of your application. My example is simple but ready for scaffolding, so I defined a global store and added a store for a particular feature
interface IState {
loadingIds: { [key: string]: boolean };
}
const initialState: IState = {
loadingIds: {},
};
2. Define a selector for your data structure
export const selectors = {
selectStatusesLoadingIds: (state: RootState): { [key: string]: boolean } => {
return state.statuses?.loadingIds;
},
};
3. Add the selector to your component instead of useState hook
4. You don’t need useEffect here anymore
const MainScreen: FC = (): JSX.Element => {
// We don't need this code any more, replacing it with selector from store
// const [ loadingIds, setLoadingIds ] = useState<{ [key: string]: boolean }>({});
const loadingIds = useSelector(statusesStore.selectors.selectStatusesLoadingIds);
const [
changeStatus,
changeStatusResult,
] = statusesStore.api.useLazyChangeStatusQuery();
const handleChangeStatus = useCallback((entryId: string) => {
// This piece of code is redundant as well
// setLoadingIds(prevState => {
// return {
// ...prevState,
// [entryId]: true
// }
// });
changeStatus({ entryId });
}, [changeStatus]);
// And we can completelly get rid from useEffect here itself
// useEffect(() => {
// const {isError, isSuccess, originalArgs, isFetching} = changeStatusResult;
// if (isFetching) {
// return;
// }
// if ((isSuccess || isError) && originalArgs) {
// setLoadingIds(prevState => {
// return {
// ...prevState,
// [originalArgs?.entryId]: false
// }
// });
// }
// }, [changeStatusResult]);
5. And the most interesting part – adding matchers (I’d rather say – interceptors) to request executing function. First, one should watch the pending request status and change the state at the start of each call. This allows you to mark the corresponding item ID as pending. Another matcher should be applied on both reject or fulfill statuses. The task of these interceptors is to unmark the item ID as pending.
interface IState {
loadingIds: { [key: string]: boolean };
}
const initialState: IState = {
loadingIds: {},
};
export const slice = createSlice({
name: 'statuses',
initialState,
reducers: { },
extraReducers: (builder) => {
builder.addMatcher(api.endpoints.changeStatus.matchPending, (state, { meta }) => {
const entryId = meta?.arg?.originalArgs?.entryId;
if (entryId && typeof entryId === 'string') {
state.loadingIds[entryId] = true;
}
});
builder.addMatcher(api.endpoints.changeStatus.matchFulfilled, (state, { meta }) => {
const entryId = meta?.arg?.originalArgs?.entryId;
if (entryId && typeof entryId === 'string') {
const newLoading = { ...state.loadingIds };
delete newLoading[entryId];
state.loadingIds = newLoading;
}
});
builder.addMatcher(api.endpoints.changeStatus.matchRejected, (state, { meta }) => {
const entryId = meta?.arg?.originalArgs?.entryId;
if (entryId && typeof entryId === 'string') {
const newLoading = { ...state.loadingIds };
delete newLoading[entryId];
state.loadingIds = newLoading;
}
});
},
});
export default slice.reducer;
Summary
The solution I’ve proposed is an effective way of handling concurrent same-endpoint API calls. It makes the flow more predictable and independent of the component life-cycle. Moreover, even the official React documentation suggests that you be careful with useEffect.
Matchers of every request guarantee that the status will be changed correctly on every call. Therefore, Redux architecture will update data inside the component every time the state is changed. It is not an obvious solution nowadays to separate logic and lift the state to a global level. However, it allows us to avoid confusing behavior and increase the stability and failure tolerance of the system.


