Redundant API Calls on Pagination
Changing the page size should trigger one request. So why were we seeing two?
The Problem
On a paginated list, when the user changed the page size (e.g. from 10 to 25 per page), the Network tab showed two identical API requests. The list updated correctly, but we were doubling load on the backend and sometimes briefly showing the wrong page of data. The same thing could happen when changing the page number if the URL and local state both triggered a fetch.
Investigation
I looked at how the list component fetched data. There was one effect that ran when the page index changed and another when the page size changed. Both called the same fetch function, which read the current page and limit from state. When the user changed the page size, the UI often reset to page 1 and updated the limit in one go. That meant two state updates, one re-render, and both effects ran because both dependencies had changed. So the fetch ran twice with the same (or nearly the same) arguments.
I also checked whether the route (e.g. query params) was in the dependency array. In one place we had an effect that synced the URL with state and then a separate effect that fetched when the URL changed. So a single "change page size" action could update state, push a new URL, and trigger both the sync and the fetch effects. That explained the occasional third request.
Root Cause
The fetch was driven by effects that depended on page and limit (and sometimes the router). When more than one of those changed in a single user action, every effect that depended on them ran. The fetch did not "know" it was one user gesture; it just ran once per effect. So the root cause was using multiple effects as triggers for the same side effect, with overlapping dependencies.
The Fix
We stopped triggering the fetch from effects for pagination. Instead, the handlers that ran when the user changed the page or page size called the fetch function directly with the new values. For example, the "change page size" handler did something like: set limit to the new value, set page to 1, and call fetchData(1, newLimit). The fetch was no longer reading page and limit from state inside an effect; it received them as arguments. So one user action produced one call.
We kept a single effect only for the initial load when the list first mounted or when a parent ID (e.g. job ID) changed. That way we still loaded data when entering the page or switching context, but pagination and page-size changes were explicit and did not double-fire.
Lessons Learned
- When several pieces of state can change together (page and limit, or URL and state), multiple effects that all call the same fetch will run multiple times. Prefer one call per user action by triggering the fetch in the event handler with explicit parameters.
- Reading pagination params from state inside an effect makes it hard to control when the fetch runs. Passing them in from the handler makes the flow obvious and easier to debug.
- The initial-load case (e.g. when the route or parent ID is set) is different from "user clicked next page." One effect for initial load and event-driven fetch for user actions keeps behaviour predictable and reduces redundant requests.
Have thoughts on this story or questions? Get in touch.