← Blog

Reliable Pagination: Load More, Retry Safely, and Refresh

11 min read
Watch the walkthrough on YouTube

A list reaches the bottom twice while the first request is still pending. Later, the user refreshes, but an old page finishes after the fresh first page. Both bugs come from accepting work without deciding which operation owns the result.

Load more appends, refresh replaces, and outdated or duplicate requests must not corrupt either operation.

This is Lists lesson 2 of 3, Day 4 of Ship Native. Day 3 covered initial rendering and unnecessary row updates. This lesson completes pagination, duplicate prevention, retry and refresh. Day 5 finishes Lists with safe programmatic navigation inside a list.

The video uses sample diagrams and illustrative responses, with English synthetic narration and English/Arabic subtitles. The integration code below was reviewed, not executed or device-tested. No device performance or production incident reproduction is claimed.

Define the response and the operation

The sample API is one-based and returns { items: Product[], page: number, hasMore: boolean }, with up to twenty products per page. IDs are stable strings. The adapter rejects HTTP errors, validates this payload, consumes AbortSignal, and enforces a request timeout. It must reject on timeout so the coordinator can release the request and show retry. Authentication and the endpoint belong to the consuming app.

A successful first page replaces the dataset. A successful next page appends through a stable-ID merge. The server’s hasMore decides when to stop; do not derive that decision from the number of rows left after deduplication. A successful empty first page with hasMore: false is an empty state. A rejected request is an error state.

For cursor pagination, store the exact next token returned by an accepted response instead of doing page + 1. Never advance a page number or cursor merely because a request started.

onEndReachedThreshold={0.5} expresses half the visible list length from the end. It controls when to ask for work; it does not act as a network mutex. List geometry and new content can produce later end-reached events. The callback still needs a guard. React Native VirtualizedList reference.

One coordinator for this manual example

The daily brief explicitly includes the manual-ref pattern, so this example keeps the complete coordinator visible. It is a teaching adaptation of source patterns. I did not copy it from a single production screen. In an app already using TanStack Query, let its infinite-query machinery own this state instead; do not layer this hook over fetchNextPage.

The immediate write to active.current happens before the network await. A second callback therefore sees the request even if React has not yet rendered the loading indicator. State drives the UI; the request record coordinates asynchronous operations.

Each request captures a generation and has its own object identity. Refresh increases the generation, aborts obsolete work where supported, and installs a new request. Only the matching request may change data, errors, cursors or loading indicators. Checking finally matters: old cleanup must not release a newer request’s lock.

Reviewed TypeScript integration example

import React, {useCallback, useEffect, useRef, useState} from 'react';
import {ActivityIndicator, Button, FlatList, Text, View} from 'react-native';

export type Product = {id: string; name: string};
export type Page = {items: Product[]; page: number; hasMore: boolean};
// The adapter must reject HTTP errors, validate the payload and honor signal.
// It must also bound requests with a timeout that rejects on expiry.
export type LoadPage = (page: number, signal: AbortSignal) => Promise<Page>;
type Kind = 'initial' | 'more' | 'refresh';
type Failure = {kind: Kind; message: string};
type Snapshot = {
  rows: Product[];
  nextPage: number | null;
  busy: Kind | null;
  failure: Failure | null;
  needsRefresh: boolean;
  loaded: boolean;
};
type Request = {generation: number; controller: AbortController};
const empty = (): Snapshot => ({
  rows: [], nextPage: 1, busy: null, failure: null,
  needsRefresh: true, loaded: false,
});

// Later payload wins; first occurrence determines position. Also dedupes a page.
export function mergeById(previous: Product[], incoming: Product[]): Product[] {
  return [...new Map([...previous, ...incoming].map(item => [item.id, item])).values()];
}

export function usePagination(loadPage: LoadPage) {
  const [view, setView] = useState<Snapshot>(empty);
  const current = useRef<Snapshot>(empty());
  const active = useRef<Request | null>(null);
  const generation = useRef(0);
  const mounted = useRef(false);
  const publish = useCallback((next: Snapshot) => {
    current.current = next;
    setView(next);
  }, []);

  const run = useCallback(async (kind: Kind, retry = false) => {
    if (!mounted.current) return;
    const before = current.current;
    if (kind === 'more') {
      if (active.current || before.needsRefresh || before.nextPage === null) return;
      if (before.failure && !retry) return;
    } else {
      // A replacement supersedes load-more; repeated refresh taps are ignored.
      if (active.current && before.busy !== 'more') return;
      generation.current += 1;
      active.current?.controller.abort();
    }
    const page = kind === 'more' ? before.nextPage! : 1;
    const request: Request = {
      generation: generation.current,
      controller: new AbortController(),
    };
    // This synchronous write is the lock, before the first await.
    active.current = request;
    publish({...before, busy: kind, failure: null,
      needsRefresh: kind === 'more' ? before.needsRefresh : true});
    const isCurrent = () => mounted.current &&
      generation.current === request.generation && active.current === request;
    try {
      const result = await loadPage(page, request.controller.signal);
      if (!isCurrent()) return;
      if (result.page !== page) throw new Error('Unexpected response page');
      const rows = mergeById(kind === 'more' ? current.current.rows : [], result.items);
      publish({...current.current, rows, loaded: true, needsRefresh: false,
        nextPage: result.hasMore ? page + 1 : null, failure: null});
    } catch {
      if (!isCurrent()) return;
      publish({...current.current, failure: {
        kind, message: kind === 'more' ? 'Could not load more products.' :
          kind === 'refresh' ? 'Refresh failed. Existing products are still shown.' :
          'Could not load products.',
      }});
      // Cursor and rows stay unchanged. A failed replacement keeps needsRefresh.
    } finally {
      // Old cleanup must not unlock a newer request or hide its indicator.
      if (isCurrent()) {
        active.current = null;
        publish({...current.current, busy: null});
      }
    }
  }, [loadPage, publish]);

  useEffect(() => {
    mounted.current = true;
    publish(empty());
    void run('initial');
    return () => {
      mounted.current = false;
      generation.current += 1;
      active.current?.controller.abort();
      active.current = null;
    };
  }, [run, publish]);

  const loadMore = useCallback(() => {void run('more');}, [run]);
  const refresh = useCallback(() => {void run('refresh');}, [run]);
  const retry = useCallback(() => {
    const failed = current.current.failure;
    if (failed) void run(failed.kind, true);
  }, [run]);
  return {...view, loadMore, refresh, retry};
}

function PaginationSession({loadPage}: {loadPage: LoadPage}) {
  const list = usePagination(loadPage);
  const topError = list.failure && list.failure.kind !== 'more';
  return (
    <FlatList
      data={list.rows}
      keyExtractor={item => item.id}
      renderItem={({item}) => <Text>{item.name}</Text>}
      onEndReached={list.loadMore}
      onEndReachedThreshold={0.5}
      refreshing={list.busy === 'refresh'}
      onRefresh={list.refresh}
      ListHeaderComponent={topError ? <View>
        <Text accessibilityRole="alert">{list.failure?.message}</Text>
        <Button title="Retry" onPress={list.retry} />
      </View> : null}
      ListEmptyComponent={list.busy === 'initial' ? <ActivityIndicator /> :
        list.loaded && !list.failure && !list.busy ? <Text>No products.</Text> : null}
      ListFooterComponent={list.busy === 'more' ? <ActivityIndicator /> :
        list.failure?.kind === 'more' ? <View>
          <Text accessibilityRole="alert">{list.failure.message}</Text>
          <Button title="Retry loading more" onPress={list.retry} />
        </View> : list.loaded && list.nextPage === null && !list.busy && !list.failure ?
          <Text>All products loaded.</Text> : null}
    />
  );
}

// Stable loadPage function: module-level or useCallback with real dependencies.
// Include account/filter/sort/language in datasetKey so a different dataset remounts.
// Add app styling and translated UI copy at integration time.
export function PaginationExample({datasetKey, loadPage}: {
  datasetKey: string; loadPage: LoadPage;
}) {
  return <PaginationSession key={datasetKey} loadPage={loadPage} />;
}

Pass a stable loadPage function, defined at module scope or with useCallback and its actual dependencies. Recreating it on every render causes this effect to restart. datasetKey must identify account, filter, sorting and language context where they affect results; changing it remounts the session, clears its state and invalidates old work. Never reuse old account data under a new identity. Styling and translated UI strings are intentionally left to integration.

The list is not mounted against a real endpoint in this production workflow. API validation, signal handling, timeout behavior, platform layout and accessibility need verification in the consuming app.

Refresh has an explicit policy

Suppose pages one through three are visible and page four is pending:

  1. Pull to refresh increments the generation and supersedes page four.
  2. Existing rows remain visible while a fresh page one loads. Load-more is blocked.
  3. Accepted refresh success replaces all rows and resets the next-page state from that response.
  4. A late page-four result fails the identity check, including its error and cleanup paths.
  5. If refresh fails, the old rows stay visible with a refresh error. This example deliberately blocks further pagination until refresh succeeds on retry.

That last decision avoids combining a failed refresh attempt with new pages from an uncertain dataset. A different product may choose another recovery policy, but it must be explicit. Two loading flags cannot supply this ordering rule.

The top indicator follows only the refresh operation; the footer follows only load-more. refreshing is controlled by the application. Keep it true for the relevant operation rather than reusing a generic network flag. React Native RefreshControl reference.

Dedupe is a defensive merge

Map keeps the first position of an ID and replaces its value with the later payload encountered in the merge. That handles overlap with earlier pages and duplicates inside the incoming page. “Later” means later in this accepted response sequence. It does not mean a verified server revision. If your API exposes revisions, use that contract to choose which data wins.

Dedupe does not prevent wasteful requests, recover missing items, or repair an incorrect cursor. With changing data, offset pagination can skip products when earlier rows are inserted or deleted. Stable ordering, a server snapshot, or a suitable cursor contract may be needed. Do not present client dedupe as a complete solution to server consistency.

Failure must not skip a page

The page number advances only inside the accepted-success branch. Failure leaves rows and nextPage untouched, releases the matching request, and exposes a retry button. Automatic end-reached loading pauses after an error, so a list sitting at the bottom does not repeatedly hammer a failing endpoint. Repeated retry taps encounter the same immediate lock.

Initial failure, next-page failure, refresh failure and a successful empty response remain different states. An old failure cannot replace the new operation’s state because the same identity test applies in catch.

If the app already uses TanStack Query

Use one useInfiniteQuery owner with initialPageParam, a queryFn consuming its signal, and getNextPageParam returning undefined at the end. Guard automatic fetchNextPage against !hasNextPage and any isFetching activity, including background refetches. This is stronger than checking only isFetchingNextPage.

Normal infinite-query refetch can reload retained pages sequentially from the first page. It is not automatically equivalent to the manual example’s replace-with-page-one refresh. Choose and implement that cache policy deliberately, preserving both pages and pageParams when editing cached data. Do not combine manual page counters from this article with the query’s counters. TanStack Query infinite queries.

Source evidence and attribution

  • Liana/src/screens/StoreFlow/SearchScreen/index.tsx:88–92,206–216: hasNextPage/isFetchingNextPage guard, threshold and footer. Guard blame: Ibrahim Fathi, fa3eafea. I use its loading pattern as context here; Search is not reopened as a lesson.
  • riya-mobile-app/src/screens/Home/useHomeData.ts:120–143: refs mirror Redux loading, has-more and current-page values. The callback has Ibrahim Fathi history (54e83f55), while the ref changes are under Riya-Mobile-Team (d6f3b453). There is no immediate loadingRef.current = true lock in the reopened handler; our active-request lock is an adaptation.
  • riya-mobile-app/src/store/slices/homeSlice.ts:129–158: success-side page state, server has-more and an existing-ID filter. Team-attributed merge lines (4ddb1761) do not add IDs to the set during the incoming filter, so our Map handling of within-page duplicates is also an adaptation. This source starts its separate recommendation stream at page two; our standalone sample starts at page one.
  • riya-mobile-app/src/store/slices/refundSlice.ts:194–214: initial/load-more flags, preserved pending rows, append versus replacement, and failure cleanup. Ibrahim Fathi, 24a711e1 and 9e7f0ad7.
  • HM-tasks2/src/hooks/useTaskQueries.ts:140–155: located useTaskPoolQuery, page parameters, signal, and end condition. Blame uses the team identity; individual authorship is not claimed.
  • HM-tasks2/src/screens/Tasks/Notifications/index.tsx:408–411: refresh indicator distinguishes next-page fetching. Ibrahim Fathi, b16af9bf. The expression can also represent background refetch; it is not proof of a dedicated user-refresh state or generation protection.

alamthal-app supplied a comparative footer pattern, but the inspected lines are attributed to another developer and are not used as an owner-authored fix. No source repository was modified. The previously noted reset-dispatch engineering lead remains outside this lesson’s production task.

Verify in the consuming app

Verification steps to run in your app. I have not performed them:

Controlled scenario Expected result
Two immediate end-reached calls One active next-page request
Overlap across pages and within one response One row per stable ID
Server hasMore becomes false Further end events start no request
Page two rejects, then retry succeeds Page two retried; no page three request before success
Refresh during page-four request Old rows visible pending refresh; success replaces with page one
Old response or rejection arrives last No change to refreshed rows, cursor, errors or indicators
Old finally runs during refresh Refresh request remains locked and indicated
Refresh fails Old data retained; pagination waits for successful refresh retry
First request fails or succeeds empty Retry error or actual empty state, respectively
Navigate away or change dataset identity Old work cancelled/invalidated; no old-account rows leak

Use a controllable mock transport that can ignore abort to exercise the identity check independently of cancellation. Then verify real adapter timeout and abort behavior, pull-to-refresh, fast scrolling, translated copy and list positioning on target devices.

Next: Why FlatList’s Failure Callback Didn’t Catch This Crash, safe list navigation and Lists part 3 of 3. The video link will be added after publication.

Comments

No account needed — your first comment creates an anonymous name and a secret key.

  1. Loading comments…