Concurrent API Calls to the Same Endpoint in React with Redux

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.

Picture of Alex Movchan

Alex Movchan

Alex started his professional path in finance and banking and devoted at least 15 years to the financial industry before switching to software engineering. Now he has over 7 years of experience as a full-stack developer of web applications. Alex is experienced in all the popular front-end Javascript frameworks, TypeScript, and plain old Javascript itself. He is also closely familiar with NodeJS as a backend technology. His academic background gives him an advantage in algorithms, statistics, and data analysis. Alex likes a result-oriented approach to software development, which he likes to execute in a reliable and solution-oriented manner. He likes to be up-to-date with the latest trends and best practices and even enjoys learning a little in connected spheres such as mobile development.

Free Consultation

Sign up for a FREE consultation with one of Trailhead's experts.

"*" indicates required fields

This field is for validation purposes and should be left unchanged.

Related Blog Posts

We hope you’ve found this to be helpful and are walking away with some new, useful insights. If you want to learn more, here are a couple of related articles that others also usually find to be interesting:

Our Gear Is Packed and We're Excited to Explore With You

Ready to come with us? 

Together, we can map your company’s software journey and start down the right trails. If you’re set to take the first step, simply fill out our contact form. We’ll be in touch quickly – and you’ll have a partner who is ready to help your company take the next step on its software journey. 

We can’t wait to hear from you! 

Main Contact

This field is for validation purposes and should be left unchanged.

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the form below. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Montage Portal

Montage Furniture Services provides furniture protection plans and claims processing services to a wide selection of furniture retailers and consumers.

Project Background

Montage was looking to build a new web portal for both Retailers and Consumers, which would integrate with Dynamics CRM and other legacy systems. The portal needed to be multi tenant and support branding and configuration for different Retailers. Trailhead architected the new Montage Platform, including the Portal and all of it’s back end integrations, did the UI/UX and then delivered the new system, along with enhancements to DevOps and processes.

Logistics

We’ve logged countless miles exploring the tech world. In doing so, we gained the experience that enables us to deliver your unique software and systems architecture needs. Our team of seasoned tech vets can provide you with:

Custom App and Software Development

We collaborate with you throughout the entire process because your customized tech should fit your needs, not just those of other clients.

Cloud and Mobile Applications

The modern world demands versatile technology, and this is exactly what your mobile and cloud-based apps will give you.

User Experience and Interface (UX/UI) Design

We want your end users to have optimal experiences with tech that is highly intuitive and responsive.

DevOps

This combination of Agile software development and IT operations provides you with high-quality software at reduced cost, time, and risk.

Trailhead stepped into a challenging project – building our new web architecture and redeveloping our portals at the same time the business was migrating from a legacy system to our new CRM solution. They were able to not only significantly improve our web development architecture but our development and deployment processes as well as the functionality and performance of our portals. The feedback from customers has been overwhelmingly positive. Trailhead has proven themselves to be a valuable partner.

– BOB DOERKSEN, Vice President of Technology Services
at Montage Furniture Services

Technologies Used

When you hit the trails, it is essential to bring appropriate gear. The same holds true for your digital technology needs. That’s why Trailhead builds custom solutions on trusted platforms like .NET, Angular, React, and Xamarin.

Expertise

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

  • Project Management
  • Architecture
  • Web App Development
  • Cloud Development
  • DevOps
  • Process Improvements
  • Legacy System Integration
  • UI Design
  • Manual QA
  • Back end/API/Database development

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

Our Gear Is Packed and We're Excited to Explore with You

Ready to come with us? 

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the contact form. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Thank you for reaching out.

You’ll be getting an email from our team shortly. If you need immediate assistance, please call (616) 371-1037.