useEffect and Data Fetching: Avoiding the Effect Cascade
One user action, one fetch. Except when it wasn’t—and why that kept happening.
Introduction
A common way to load data in React is to put a useEffect that depends on some state (e.g. page, limit, userId) and call your fetch function when that state changes. The problem: if more than one piece of state can change in the same user action, you can end up with multiple effects firing in one render cycle, each calling the same API. The result is duplicate or redundant network requests, harder-to-reason-about behavior, and sometimes race conditions. This post is about why that happens and a simpler pattern that avoids it.
The anti-pattern: multiple effects, one fetch
Suppose you have a paginated list. The user can change the page number and the page size. You might write:
useEffect(() => {
fetchData();
}, [jobId]);
useEffect(() => {
fetchData();
}, [page]);
useEffect(() => {
fetchData();
}, [limit]);
When the user changes the page size, the UI often updates both page and limit (e.g. resetting to page 1 with the new size). That’s two state updates. React re-renders, and both effects run. So fetchData is called twice for one user action. If the route or other state also changes, you might see three calls. In the Network tab it looks like a burst of identical or near-identical requests.
The underlying issue: you’re using state as a trigger for side effects. When several state variables can change together, each effect that depends on them will run. The fetch doesn’t “know” that it was one user gesture; it just runs once per effect that ran.
Why it happens
useEffect runs after commit when its dependency array has changed. If page and limit both change in the same tick (e.g. from one handler that does setPage(1); setLimit(20)), you still get one re-render but two dependency arrays that changed. So two effects run, and you get two fetches. The same idea applies when the router (e.g. query params) is in the dependency array: changing page and limit might also change the URL, so another effect fires. So the “one user action → one fetch” mental model breaks down when the trigger is “any of these state values changed.”
The fix: event-driven fetch with explicit parameters
Instead of “when page or limit changes, fetch,” make the event handler responsible for calling fetch with the new values. The handler already has (or can compute) the new page and limit; pass them straight into the fetch function.
- In the pagination “change page” handler: call
fetchData(newPage, limit)(and update state). - In the “change page size” handler: call
fetchData(1, newLimit)(and update state).
So the fetch is triggered by user events, and the parameters are explicit. You don’t need useEffect for pagination-driven fetch at all in this pattern; you might keep one effect only for the initial load (e.g. when jobId is first set).
Refactoring the fetch to accept page and limit as arguments (instead of reading them from state inside the effect) is the key. Then the UI always calls it with the values that just changed. One click → one call.
Benefits
- Predictable: One user action produces one request. No more “why did it fire twice?” when changing page size.
- Easier to debug: The call site is the event handler; you don’t have to reason about which effect ran and in what order.
- Better performance: In real products this can cut redundant requests significantly (e.g. on the order of 50–70% on paginated views) and reduce load on the backend.
When to use which
- Event-driven fetch: When the user explicitly triggers a load (pagination, filters, search, submit). The handler has the new values; call the fetch there with those values.
- Effect-driven fetch: When the load is a reaction to “this entity or route just became visible” (e.g. initial load when
idfrom the URL is set, or when navigating to a detail page). One effect for “load whenidis available” is usually enough.
Keeping that distinction in mind — “user did something” vs “something we depend on appeared” — helps avoid the effect cascade and keeps data fetching easier to reason about and maintain.
Have thoughts on this post or questions? Get in touch. More blog posts.