◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-05-22Intermediate

Why FlatList's onEndReached Fires Multiple Times — and How to Stop It

After wiring up infinite scroll in a Rork-generated FlatList, you may notice the same paginated request hitting your API two or three times in a row. Here's why onEndReached fires more often than you expect and how to add a two-layer defense that survives production.

FlatList10onEndReachedReact Native239Rork585PaginationTroubleshooting43Performance25

Why FlatList's onEndReached Fires Multiple Times — and How to Stop It

You finish wiring up infinite scroll, open the API log to double-check, and see "fetch page 2", "fetch page 3", "fetch page 3", "fetch page 3" stacked on top of each other. If you ship the Rork-generated FlatList code as-is to a real device, onEndReached is very likely to fire several times during the initial render and again when the screen comes back into focus. Left alone, this duplicates server records and burns through free API quotas surprisingly fast.

As an indie developer running a portfolio of wallpaper apps that has accumulated around 50 million downloads since 2014, I hit exactly this problem when I migrated a thumbnail grid from FlatList to FlashList. The list interleaves AdMob native ads every eight items, so each spurious load also inflated duplicated impressions — a problem that hurts revenue as much as it hurts the server bill.

Why onEndReached is not a scroll event

onEndReached is triggered by _maybeCallOnEndReached inside React Native whenever the bottom of the visible window falls within onEndReachedThreshold. The trap is that the FlatList considers itself "near the end" in three very common situations.

First, on the initial render when the data fits within the viewport. The last cell is already on screen, so onEndReached fires immediately. Second, right after a re-render where the list height changes. Even if the user has not moved, the recalculation can fire again. Third, when the screen is restored from a tab switch or a back navigation. If the scroll position was already near the bottom, the event fires once more.

In other words, onEndReached is not "the user scrolled to the bottom." It is "the last cell is now in the viewport." Writing a direct loadNextPage() call inside it without any guard is what causes the runaway requests.

The lightest fix: an onMomentumScrollBegin gate

The simplest and most reliable defense is to ignore onEndReached until the user has actually started a momentum scroll. onMomentumScrollBegin only fires for real, finger-driven scrolling, so it is the perfect signal that the user is genuinely paging through the list.

import { FlatList } from 'react-native';
import { useCallback, useRef } from 'react';
 
export function PostListScreen({ posts, loadNext }: Props) {
  const onEndReachedCalledDuringMomentum = useRef(true);
 
  const handleEndReached = useCallback(() => {
    if (onEndReachedCalledDuringMomentum.current) return;
    onEndReachedCalledDuringMomentum.current = true;
    loadNext();
  }, [loadNext]);
 
  return (
    <FlatList
      data={posts}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <PostCard post={item} />}
      onEndReached={handleEndReached}
      onEndReachedThreshold={0.5}
      onMomentumScrollBegin={() => {
        onEndReachedCalledDuringMomentum.current = false;
      }}
    />
  );
}

The key detail is initializing the ref to true. That way the very first auto-fire on mount is ignored, and the flag only clears when the user has put a finger to the screen.

Add a duplicate-cursor guard inside the fetcher

A UI-side gate alone misses the case where momentum scrolling re-triggers the event right after the flag clears. Adding a guard inside the fetch function — refusing the same cursor twice — gives you a safety net that does not depend on the UI behaving perfectly.

import { useRef, useCallback, useState } from 'react';
 
export function usePaginatedPosts() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const inflightCursor = useRef<string | null | undefined>(undefined);
 
  const loadNext = useCallback(async () => {
    // Bail out if a request for the same cursor is already in flight
    if (inflightCursor.current === cursor) return;
    inflightCursor.current = cursor;
 
    try {
      const res = await fetch(`/api/posts?cursor=${cursor ?? ''}`);
      const data = await res.json();
      setPosts((prev) => [...prev, ...data.items]);
      setCursor(data.nextCursor);
    } finally {
      if (inflightCursor.current === cursor) {
        inflightCursor.current = undefined;
      }
    }
  }, [cursor]);
 
  return { posts, loadNext };
}

For a page-number API, swap the cursor check for "next page number is already in flight" — the principle is the same. Either way, the fetcher refuses to send a redundant request even if the UI hands it one.

Notes when you are using TanStack Query or SWR

If you are using TanStack Query's useInfiniteQuery, fetchNextPage already deduplicates inflight calls internally. When duplicate requests still slip through, the cause is almost always forgetting to check hasNextPage.

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPosts(pageParam),
  initialPageParam: null as string | null,
  getNextPageParam: (last) => last.nextCursor ?? undefined,
});
 
const handleEndReached = useCallback(() => {
  if (!hasNextPage || isFetchingNextPage) return;
  fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);

Both hasNextPage and isFetchingNextPage matter. Without the second guard, a slow network still lets a second request leak out.

Gotchas when migrating to FlashList

Moving to Shopify's FlashList for performance changes the timing of onEndReached a little. FlashList uses recycling and an estimated item size to prefetch, so leaving onEndReachedThreshold at the default 0.5 fires it earlier than FlatList. In my apps, dropping it to 0.3 and tuning estimatedItemSize to a value close to the real measurement was enough to remove the surprise fires.

Diagnosing the issue from logs alone

To verify a fix, drop a timestamped log line into onEndReached:

const handleEndReached = () => {
  console.log('[onEndReached]', { cursor, now: Date.now() });
  // ...
};

If two calls land within 100 ms of each other, the cause is almost certainly a render-driven re-fire. If they are 500 ms apart, the user is probably pausing and resuming a scroll, or your flag is being cleared too early.

A four-step pre-ship checklist

Before pushing the change, walk through these four checks on a real device. They take less than two minutes and they catch almost every infinite-scroll regression I have seen.

  1. Open the list while the initial data is shorter than the viewport, and confirm loadNext did not fire on mount.
  2. Pull to refresh, and confirm loadNext did not fire as a side effect.
  3. Switch tabs and come back, and confirm loadNext did not fire on focus restore.
  4. Fling the list so it sticks to the bottom, and confirm only one request goes out for any given cursor.

The third one matters for monetized lists in particular — it is the easiest way to keep AdMob impression counts honest.

Infinite scroll is one of those features that looks like it works while quietly hammering your backend underneath. Once you understand that onEndReached is render-driven and add defenses on both the UI side and the fetcher side, you stop being the developer who finds out about a runaway loop from a billing alert at 2 a.m. Thanks for reading.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-05-02
When Your FlatList Starts Stuttering: Migrating Rork Apps to FlashList v2
When your Rork app's long lists start feeling sluggish, migrating to FlashList v2 makes scrolling dramatically smoother. Here is the practical migration path, taking advantage of v2's removal of estimatedItemSize.
⬡ Dev Tools2026-07-24
Collapsing Duplicate Requests Into One: A Reference-Counted Single-Flight Layer
When several components fire the same API call at launch, you get a burst of identical requests. Here is a single-flight layer that shares one in-flight promise instead: how to build the key that decides the folding, the trap of handing out a failed promise forever, the trap of one caller's abort cancelling everyone, and the test that keeps it all from regressing, with the real network numbers alongside.
⬡ Dev Tools2026-05-18
Fixing Linking.canOpenURL False Returns on iOS in Rork
Production iOS builds silently route every user to the mobile web instead of opening TikTok or X? This is almost always a missing LSApplicationQueriesSchemes entry. Here is the full fix path for Rork.