skip to content

In a React Native FlatList news feed, a reader pulls to refresh while a page-3 request is still in flight; what goes wrong, and how do you guard it?

level: seniorimportance: should knowfreq 34%

answer

  1. late page lands on a reset list
  2. refresh wins over pagination
  3. generation counter per reset
  4. skip onEndReached while refreshing
  5. footer as loading, error, end states

basics

~20 s

The late page-3 response lands after the refresh has reset the feed, so stale stories are appended to a fresh page 1 and the page cursor skips ahead. Tag requests with a generation counter, drop mismatches, and block paging while refreshing.

solid answer

~50 s

A React Native `FlatList` knows nothing about requests, so the race is the loader's to handle. The reader's `onEndReached` started page 3; they flick to the top and pull, `onRefresh` fetches page 1 and **replaces** the data. Then page 3 resolves and is **appended** to the fresh page 1 - stories from an older snapshot, a jump in the page cursor, possibly duplicate keys. If the refresh reuses the paging `inFlight` guard, it can be dropped outright while `refreshing` is already true. The fix: the **refresh wins**. It bumps a generation counter kept in a ref, clears the paging guard, and resets the cursor and `hasMore`; every request captures the generation it started in and discards its result if the counter moved; the page loader skips while refreshing. Render the footer from explicit states - loading, error with Retry, end of feed - rather than letting `onEndReached` re-fire into a failing endpoint.

code

tsx · 72 lines
tsx
import { useCallback, useRef, useState } from 'react';

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

const PAGE_SIZE = 20;

export function useNewsFeed() {
  const [stories, setStories] = useState<Story[]>([]);
  const [refreshing, setRefreshing] = useState(false);
  const [failed, setFailed] = useState(false);
  const generation = useRef(0);
  const inFlight = useRef(false);
  const refreshingRef = useRef(false);
  const nextPage = useRef(1);
  const hasMore = useRef(true);
  const failedRef = useRef(false);

  const loadMore = useCallback(async () => {
    if (inFlight.current || refreshingRef.current || failedRef.current || !hasMore.current) return;
    const gen = generation.current;
    inFlight.current = true;
    try {
      const page = await fetchStories(nextPage.current);
      if (gen !== generation.current) return; // a refresh reset the feed: drop
      nextPage.current += 1;
      hasMore.current = page.length === PAGE_SIZE;
      setStories(prev => [...prev, ...page]);
    } catch {
      if (gen === generation.current) {
        failedRef.current = true; // stop automatic paging until Retry
        setFailed(true);
      }
    } finally {
      if (gen === generation.current) inFlight.current = false;
    }
  }, []);

  const refresh = useCallback(async () => {
    const gen = ++generation.current;
    inFlight.current = false; // orphan any pending page request
    refreshingRef.current = true;
    setRefreshing(true);
    try {
      const page = await fetchStories(1);
      if (gen !== generation.current) return; // a newer refresh won
      nextPage.current = 2;
      hasMore.current = page.length === PAGE_SIZE;
      failedRef.current = false;
      setFailed(false);
      setStories(page);
    } catch {
      if (gen === generation.current) setFailed(true);
    } finally {
      if (gen === generation.current) {
        refreshingRef.current = false;
        setRefreshing(false);
      }
    }
  }, []);

  const retry = useCallback(() => {
    failedRef.current = false;
    setFailed(false);
    void loadMore();
  }, [loadMore]);

  return { stories, refreshing, failed, loadMore, refresh, retry };
}

// <FlatList data={stories} refreshing={refreshing} onRefresh={refresh}
//           onEndReached={loadMore} onEndReachedThreshold={0.5} ... />

go deeper

for a junior

Recall that a pull-to-refresh and a next-page load can overlap, and that a page fetched before the refresh must not be added to the refreshed list.

for a middle

Walk through the timeline that appends a stale page to a fresh page 1, and explain why refs rather than state hold the in-flight and refreshing guards.

for a senior

Design the reset: a generation counter, refresh-wins semantics, paging blocked while refreshing or in error, and a footer rendered from explicit loading, error and end states.

for a principal

Decide which layer owns request lifecycles - the screen, a shared feed hook or the data library - so every paged surface gets cancellation and reset semantics once, not per screen.

## The race, step by step A React Native news feed loads 20 stories per page with `onEndReached`, and supports pull-to-refresh with `refreshing` and `onRefresh`. Neither callback knows about the other's requests. The failure unfolds like this: 1. The reader nears the end; `onEndReached` fires and the loader requests page 3. 2. Before it returns, the reader flicks to the top and pulls. `onRefresh` sets `refreshing` to `true` and requests page 1. 3. Page 1 returns. The handler **replaces** the data with 20 fresh stories and resets the next-page position to 2. 4. Page 3 returns. The loader **appends** its 20 stories - fetched from the old snapshot - after the fresh page 1, and advances the next-page position to 4. The reader now sees stories 1-20, then a block from much further down, with page 2 and whatever sat between never loaded. If stories shifted between the two snapshots, the same story can appear twice, and duplicate `keyExtractor` values confuse the list's cell bookkeeping. A second variant is quieter: if the refresh handler checks the same `inFlight` ref as the page loader, the pull is ignored because page 3 is pending - but only after `refreshing` was set to `true`, so the indicator may never be reset. ## The guard: refresh wins A refresh is a reset, so it should invalidate everything that belongs to the previous list: | Piece | Owned by | Rule | |---|---|---| | `generation` ref | the feed | incremented at the start of every refresh | | `inFlight` ref | the page loader | cleared by a refresh, which orphans the old request | | `refreshingRef` | the refresh handler | the page loader returns early while it is set | | next page and `hasMore` | the feed | reset only when a refresh's own response lands | Each request captures `generation.current` when it starts and compares it when it resolves. A mismatch means a reset happened in between, and the result is discarded - including its effect on `inFlight` and `refreshing`, which now belong to the newer request. Aborting the old network call as well is a sensible addition, but the generation check is what makes the list correct even when a response is already on its way. ## Why refs, not only state The checks happen inside async callbacks that were created by an earlier render. A `useState` value read there is the value from that render's closure; a ref is one mutable object every closure shares. Keep state for what the screen shows - the `refreshing` prop, the footer - and refs for the guards. ## The footer as a small state machine The footer is where paging reports its state, and it interacts with the edge callback: `onEndReached` fires once per content length while the reader is inside the threshold, so a footer that mounts and unmounts changes the content length and re-arms the callback. | State | Footer | Automatic paging | |---|---|---| | idle | nothing, or a fixed-height spacer | allowed | | loading | spinner | blocked by `inFlight` | | error | message with a Retry control | blocked until Retry | | end | "You are up to date" | blocked by `hasMore` | Two practical consequences: - **Gate automatic paging on the error state.** Otherwise a spinner unmounting after a failure can re-fire `onEndReached` at once and hammer a failing endpoint. - **A fixed-height footer area** (spinner, message and spacer all the same height) stops the footer itself from changing the content length, which removes one source of repeat calls. ## Where a data library fits If the feed is fetched through a caching library with an infinite-query API, the library tracks pages, cancels superseded fetches and exposes "has next page" and "is fetching" flags; that layer is its own subject. The list-level rules stay the same: bind `refreshing` to the library's refetch state, and call the next-page function from `onEndReached` only when a next page exists and none is loading. ## Checklist for a senior answer - Name both failure modes: the late append and the dropped refresh. - Make the refresh a reset that invalidates in-flight pages. - Discard responses by generation, not by timing. - Block paging while refreshing and while in error. - Render the footer from explicit states.

  • Why does the page loader in the example leave inFlight alone when its generation is stale?
    Because after a refresh, `inFlight` may already belong to a newer page request started on the fresh list. If the orphaned request cleared it on the way out, a second page load could start while the new one is still pending - reintroducing the duplicate-load bug. Only the request whose generation is current owns the guard it set.
  • Should a pull-to-refresh be ignored while a next page is loading, instead of winning?
    Usually not. The pull is an explicit request for the newest content, while the pending page belongs to a list the reader is about to throw away. Ignoring the pull also risks leaving the indicator stuck if `refreshing` was set before the check. Letting the refresh win and discarding the old page by generation is simpler to reason about.
  • Why can a failing next-page request retry in a tight loop without any user action?
    Because `onEndReached` re-arms whenever the content length changes while the reader is inside the threshold. A loading spinner that mounts for the request and unmounts after the failure changes the content length twice, so the callback fires again at once. Blocking automatic paging while in an error state, and offering Retry in the footer, breaks the loop.

saying these in an interview costs you the question

  • FlatList cancels a pending onEndReached request when the user pulls to refresh.
  • Discarding a late page response is unnecessary if the refresh finishes first.
  • The refresh handler should share the paging in-flight guard and skip when it is set.
  • Stale responses can be filtered reliably by comparing timestamps or timeouts.
  • A late page response is harmless because keyExtractor removes duplicate stories.