CORE JSC

International Technology Partnership

React Native

Fixing React Native FlatList's onEndReached Firing Multiple Times for a Single Scroll to the Bottom

A paginated list calls its "load more" handler two, three, or more times for what the user experiences as a single scroll to the bottom, resulting in duplicate network requests and, often, duplicate items appended to the list. The scroll event isn't firing extra times — onEndReached simply keeps re-evaluating "are we still near the bottom" faster than the new page actually arrives and changes that answer.

Core JSC Team·October 24, 2026
React NativeFlatListPerformancePaginationMobile Development

The Problem

A FlatList wired up for infinite scrolling — onEndReached triggering a fetch for the next page of results — ends up calling that fetch handler two or more times in quick succession for a single, continuous scroll gesture to the bottom of the list. The network tab shows duplicate requests for the same page, and if the handler isn't defensive about it, the rendered list ends up with the same items appended twice, or a loading spinner that flickers on and off rapidly instead of showing once per actual page load.

Why It Happens

onEndReached is a proximity check, re-evaluated on every scroll-position update, not a one-shot "you hit the bottom" event

onEndReached fires whenever the scroll position comes within onEndReachedThreshold of the list's end, and that proximity condition gets re-checked continuously as scroll events come in — it isn't a single edge-triggered event that fires exactly once per approach to the bottom. If the user is scrolling and lingering near the bottom edge, or if the list's content height doesn't change immediately after the first call, the same "we're still within the threshold" condition can evaluate true again on a subsequent scroll event before anything about the list has actually changed.

The new page's data hasn't been appended yet by the time the next scroll event re-checks the threshold

Fetching the next page is asynchronous — there's a gap between the moment onEndReached first fires and the moment the fetched items actually get appended to the list's data and re-render with a taller scrollable area. During that gap, the scroll position is still just as close to the (not-yet-extended) end as it was on the first call, so if nothing in the handler itself prevents a second invocation, the exact same proximity condition triggers the exact same fetch again before the first one has had a chance to change anything.

A loose or large onEndReachedThreshold widens the window in which repeated firing can happen

A larger threshold value means the "near the end" zone covers more of the scrollable area, which gives the user's scroll gesture more room to produce multiple scroll-position updates while still inside that zone — each of which re-evaluates the condition — before the list has a chance to grow past it. A very small threshold narrows this window but doesn't eliminate the underlying race between the async fetch and the next scroll event entirely.

The Fix

1. Guard the handler with a ref-based flag that blocks a second call while a fetch is already in flight

const isFetchingRef = useRef(false);

const handleEndReached = useCallback(async () => {
  if (isFetchingRef.current || !hasNextPage) return;
  isFetchingRef.current = true;
  try {
    await fetchNextPage();
  } finally {
    isFetchingRef.current = false;
  }
}, [hasNextPage, fetchNextPage]);

A ref (rather than state, which would trigger a re-render and could itself introduce a timing gap) flips synchronously the instant the first call starts, so any additional onEndReached invocation that fires before the fetch resolves is rejected immediately — this is the most direct fix, since it addresses the duplicate call regardless of why the underlying proximity condition re-evaluated true.

2. Rely on the data-fetching library's own in-flight state instead of a custom flag where one is already available

// With React Query / TanStack Query's useInfiniteQuery, for example:
const { fetchNextPage, isFetchingNextPage, hasNextPage } = useInfiniteQuery(/* ... */);

const handleEndReached = useCallback(() => {
  if (isFetchingNextPage || !hasNextPage) return;
  fetchNextPage();
}, [isFetchingNextPage, hasNextPage, fetchNextPage]);

Most pagination-aware data-fetching libraries already track an in-flight state for exactly this purpose, so checking that flag instead of maintaining a separate manual one avoids a second source of truth that could itself drift out of sync with the actual request lifecycle.

3. Tune onEndReachedThreshold deliberately rather than leaving it at a default that doesn't suit the list's typical item size

<FlatList
  data={items}
  onEndReached={handleEndReached}
  onEndReachedThreshold={0.5} // tune based on typical item height and scroll velocity
  /* ... */
/>

A threshold chosen deliberately for the list's actual item sizes and typical scroll speed — rather than an untouched default — reduces how often a user's scroll gesture produces several scroll events while still inside the "near the end" zone, narrowing the window in which the guard in fix #1 would otherwise need to reject a repeat call.

4. Verify the fix by logging each call with a counter during a deliberately fast, sustained scroll to the bottom

const handleEndReached = useCallback(() => {
  console.log("onEndReached fired, isFetching:", isFetchingRef.current);
  // ...guard logic
}, []);

Testing specifically with a fast, sustained scroll gesture — rather than a slow, deliberate one that happens to avoid triggering the race — and confirming the log shows the guard rejecting repeat calls while a fetch is in flight is what verifies the fix holds under the scroll behavior most likely to expose the original bug, rather than just the easy case.

Why This Works

Each fix narrows the same underlying race between an async fetch and a continuously-re-evaluated proximity check. The in-flight guard is the direct fix, rejecting any call that arrives before the previous fetch has resolved, regardless of how many times the threshold condition re-evaluates true in that window. Using a data library's own in-flight flag avoids maintaining a second, potentially inconsistent source of truth for the same state. Tuning the threshold reduces how often multiple scroll events land inside the vulnerable window in the first place, even though it doesn't eliminate the race on its own. And testing with a fast scroll specifically targets the exact condition — several scroll events arriving before the first fetch resolves — that produces the bug, rather than a gentler test that might pass without actually confirming the guard works.

Conclusion

A paginated list firing its load-more handler multiple times for one scroll isn't a bug in FlatList's scroll handling — it's onEndReached correctly re-evaluating a proximity condition that hasn't changed yet because the previous page's data hasn't finished loading and extending the list. Guard the handler with an in-flight flag that rejects a repeat call while a fetch is pending, prefer a data-fetching library's own in-flight state when one is available, tune onEndReachedThreshold deliberately for the list's actual content, and verify the fix specifically with a fast, sustained scroll rather than a gentle one that might not expose the race at all.