skip to content

In a React Native FlatList feed, why can onEndReached fire twice for one page, and how do you guard the page load?

level: middleimportance: must knowfreq 58%

answer

  1. edge detector, not a request scheduler
  2. once per content length, not per page
  3. footer spinner changes content length
  4. a ref flips before the re-render
  5. threshold counted in viewport lengths

basics

~20 s

FlatList's onEndReached fires once per content length while the list sits near its end, so a mounting footer, settling row heights or scrolling out and back re-fires it. Guard the loader with a synchronous in-flight ref and a hasMore flag.

solid answer

~50 s

`onEndReached` is an edge detector: `VirtualizedList` calls it when the last item is rendered, the distance to the end is within `onEndReachedThreshold` times the viewport length, and the content length differs from the length recorded at the previous call. Scrolling back out of the zone clears that record. So the loading spinner mounting in `ListFooterComponent`, a row whose image finishes sizing, or a bounce out of the zone and back all fire it again while page 3 is still loading. The callback knows nothing about your request, so the guard is yours: an `inFlight` ref set *before* the `await`, a `hasMore` flag set when the server returns fewer than 20 stories, and an explicit `onEndReachedThreshold` such as `0.5`. A `useState` loading flag alone is not enough, because two calls can arrive before React re-renders with the new value.

code

tsx · 55 lines
tsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, FlatList, Text } from 'react-native';

type Story = { id: string; title: string };
declare function fetchStories(page: number): Promise<Story[]>;

const PAGE_SIZE = 20;

export function NewsFeed() {
  const [stories, setStories] = useState<Story[]>([]);
  const [loadingMore, setLoadingMore] = useState(false);
  const [failed, setFailed] = useState(false);
  const inFlight = useRef(false);
  const hasMore = useRef(true);
  const nextPage = useRef(1);

  const loadMore = useCallback(async () => {
    if (inFlight.current || !hasMore.current) return;
    inFlight.current = true; // set before the await: blocks the duplicate call
    setLoadingMore(true);
    setFailed(false);
    try {
      const page = await fetchStories(nextPage.current);
      nextPage.current += 1;
      hasMore.current = page.length === PAGE_SIZE;
      setStories(prev => [...prev, ...page]);
    } catch {
      setFailed(true);
    } finally {
      inFlight.current = false;
      setLoadingMore(false);
    }
  }, []);

  useEffect(() => {
    void loadMore();
  }, [loadMore]);

  return (
    <FlatList
      data={stories}
      keyExtractor={story => story.id}
      renderItem={({ item }) => <Text>{item.title}</Text>}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
      ListFooterComponent={
        loadingMore ? (
          <ActivityIndicator />
        ) : failed ? (
          <Text onPress={() => void loadMore()}>Could not load stories. Tap to retry.</Text>
        ) : null
      }
    />
  );
}

go deeper

for a junior

Know that onEndReached is how a FlatList asks for the next page, that it can run more than once, and that the loader must ignore calls while a page is already loading.

for a middle

Explain the three firing conditions: last item rendered, distance within threshold times viewport length, and a content length different from the last call. Then show why a ref, not state, is the in-flight guard.

for a senior

Name the concrete re-fire triggers in a real feed, such as footer toggles, late row heights and bounces, and design the loader so an empty page, an error and a pending refresh all stop paging cleanly.

for a principal

Frame the list callback as an unreliable signal and the loader as the component that owns idempotency, so the same guard serves list callbacks, retry buttons and prefetching without special cases.

## What onEndReached actually promises The React Native docs describe `onEndReached` as "called once when the scroll position gets within `onEndReachedThreshold` from the logical end of the list". The word **once** is scoped much more narrowly than most people assume. `FlatList` and `SectionList` are thin layers over `VirtualizedList`, and in React Native 0.87 its edge check runs on the list's layout, on every content-size change and on scroll events. Once it has real measurements and no scroll adjustment is pending, it calls your handler when all three conditions hold: 1. **The last item is inside the render window** - the list has actually rendered the final row of `data`. 2. **The distance from the end is within the threshold**, where the threshold is `onEndReachedThreshold` multiplied by the list's visible length (its viewport height, for a vertical list). 3. **The content length differs** from the content length the list recorded when it last fired. When it fires, it stores the current content length. When the user scrolls back out of the threshold zone, it clears that stored value, so re-entering fires again. The contract is therefore **once per content length while inside the zone** - not once per page, and not once per request. ## Why a feed sees it twice Take a news feed that loads 20 stories per page. Each of these re-fires the callback while the page-3 request is still pending: - **The loading footer grows the content.** The handler sets `loadingMore` to true, a spinner mounts in `ListFooterComponent`, the content length changes, and the user is still near the end - so the check passes again. - **Rows settle their height.** A thumbnail that sizes itself late or a headline that wraps differently changes the content length inside the zone. - **Scrolling out and back.** A fling that overshoots and bounces, or a reader nudging up and down near the bottom, leaves the zone and re-enters it, which clears the stored length. - **Short pages chain.** If 20 stories do not fill the viewport plus the threshold, the list fires on its first layout and again after each appended page until the content is long enough. That is how a list fills its screen, and it is intended - but it means a feed that has run out of stories needs an explicit stop, because a footer that mounts and unmounts keeps changing the content length. None of these is a bug in your component. The callback is an **edge detector**, not a request scheduler: it has no idea a request is pending. ## The guard | Guard | What it stops | Why this form | |---|---|---| | `inFlight` ref | a second call while a page is loading | a ref changes synchronously, before any re-render | | `hasMore` flag | requests past the last page | a short or empty page is the server's "no more" signal | | skip while refreshing | a page request racing a pull-to-refresh | refresh resets the paging position | | explicit threshold | firing only at the very bottom | makes the prefetch distance a deliberate choice | In order, the loader should: 1. Return immediately if `inFlight.current` is true or `hasMore.current` is false. 2. Set `inFlight.current = true` **before** the first `await`. 3. Fetch the next page, append it with a functional state update, and set `hasMore` from the page size. 4. Reset `inFlight.current` in a `finally` block, so a failed request does not wedge the feed. The reason for a ref rather than state alone: a `useState` flag is read from the closure of the render that created the handler. Two edge events can arrive before React commits the re-render that carries `loading: true`, and both closures still see `false`. A ref is one mutable object shared by every closure, so the second call sees the flag the first one set. Keeping a state flag as well is fine - it drives the footer spinner - but it is not the guard. ## The threshold and its default `onEndReachedThreshold` is measured in **visible lengths of the list**, not pixels or items: `0.5` fires when the end of the content is within half a screen of the bottom edge of the viewport. The 0.87 docs table lists a default of `2`. The 0.87 source is subtler: when the prop is omitted, the firing check itself falls back to a distance of **2 pixels** (a TODO in `VirtualizedList` notes the mismatch with the two-viewport value used elsewhere in the list). An unset threshold therefore fires only when the reader is practically at the bottom, and the footer spinner is what they see. Set it explicitly; `0.5` to `1` suits a feed of 20 tall story cards. ## Fixes that do not work - **Wrapping the handler in `useCallback`.** Function identity plays no part in the edge check. - **Debouncing by a few hundred milliseconds.** A slow network outlasts any window; a call after the window still overlaps the pending request. - **Setting the threshold to 0.** The content-length and scroll-out rules still apply, and the reader now waits at the bottom. - **Removing the footer spinner.** It removes one trigger and the feedback, and leaves the others.

  • Why does a feed that has run out of stories still need a hasMore flag?
    Because an empty page does not stop the edge check. If a loading spinner mounts in `ListFooterComponent` and then unmounts, the content length changes each time, which re-arms `onEndReached` while the reader sits near the end. Without `hasMore`, each re-fire sends another request that returns nothing, forever. Set `hasMore` false when a page comes back shorter than the page size, and check it first in the loader.
  • What happens if you omit onEndReachedThreshold in React Native 0.87?
    The docs list a default of 2 visible lengths, but in the 0.87 `VirtualizedList` source the firing check falls back to a 2-pixel distance when the prop is absent; a TODO there notes the mismatch. In practice the callback fires only when the reader is at the very bottom, so they wait on the spinner. Set the prop explicitly, for example `0.5`.
  • Why does a failed page load need an explicit error state in the footer?
    Without one, whether the list asks again depends on incidental layout. A spinner unmounting changes the content length and can re-fire `onEndReached` immediately, hammering a failing endpoint; a fixed-height footer changes nothing, so no call comes until the reader scrolls away and back. Record the failure, render a Retry control in the footer, and let that control call the loader directly.

saying these in an interview costs you the question

  • onEndReached fires exactly once per page, so the handler needs no guard.
  • A useState loading flag alone reliably blocks the second onEndReached call.
  • onEndReachedThreshold is a pixel distance from the bottom of the list.
  • Wrapping the handler in useCallback stops the duplicate onEndReached calls.
  • Setting onEndReachedThreshold to 0 guarantees onEndReached runs only once.