Refreshing and Paging

Pull-to-refresh with RefreshControl, infinite scroll with onEndReached, and the guard that stops it firing twice.

Syntax<FlatList refreshControl={<RefreshControl refreshing={busy} onRefresh={reload} />} onEndReached={loadMore} onEndReachedThreshold={0.5} />

Two gestures load more data into a list: pulling down from the top to replace what is there, and scrolling to the bottom to append the next page. FlatList and SectionList support both.

Pull to refresh

Pass refreshing and onRefresh and FlatList builds a RefreshControl for you. Passing the control yourself is the same thing, and the only way to style it:

<FlatList refreshing={busy} onRefresh={reload} />
<FlatList refreshControl={<RefreshControl refreshing={busy} onRefresh={reload} />} />

refreshing is yours to control: the spinner stays until you set it back to false. Its styling props are split by platform - tintColor, title and titleColor are iOS only; colors, progressBackgroundColor, size and enabled are Android only.

Paging

onEndReached is called with { distanceFromEnd } when the scroll position comes within onEndReachedThreshold of the end. That threshold is measured in screenfuls, not pixels, and defaults to 2.

The double fire

Nothing stops onEndReached running again while your first request is still in flight, and it also fires immediately when the content is shorter than the viewport. Guard it with a flag you can read synchronously - a useRef box, whose .current value changes without waiting for a re-render. State is too late: the next call reads the old value.

Example

Example · tsx
import { useCallback, useRef, useState } from "react";
import { FlatList, RefreshControl, StyleSheet, Text } from "react-native";

type Post = { id: string; title: string };

const ALL: Post[] = Array.from({ length: 60 }, (_, i) => ({
  id: String(i),
  title: `Post ${i}`,
}));

// stands in for a server; Stage 5 replaces it with a real request
function loadPage(page: number): Promise<Post[]> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(ALL.slice(page * 20, page * 20 + 20)), 300);
  });
}

export default function Feed() {
  const [posts, setPosts] = useState<Post[]>(ALL.slice(0, 20));
  const [refreshing, setRefreshing] = useState(false);
  const page = useRef(1);
  const busy = useRef(false); // a ref, so the next call sees it at once

  const loadMore = useCallback(async () => {
    if (busy.current) return; // the guard: onEndReached can fire repeatedly
    busy.current = true;
    const next = await loadPage(page.current);
    page.current += 1;
    setPosts((prev) => [...prev, ...next]);
    busy.current = false;
  }, []);

  const refresh = useCallback(async () => {
    setRefreshing(true);
    setPosts(await loadPage(0)); // replace, do not append
    page.current = 1;
    setRefreshing(false); // nothing else hides the spinner
  }, []);

  return (
    <FlatList
      data={posts}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
      refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5} // half a screen from the bottom; default is 2
    />
  );
}

const styles = StyleSheet.create({
  row: { padding: 16, fontSize: 16 },
});

When to use it

  • Let the reader pull down on a feed to fetch the newest items.
  • Append the next page of results as the reader nears the bottom instead of showing a Load more button.
  • Stop a paged list from firing three requests when the first screenful does not fill the display.
  • Keep already-loaded pages after a refresh instead of resetting to page one.

More examples

Styling the spinner per platform

On screen: pulling down reveals a grey spinner, with the caption "Checking for alerts" beneath it on iOS only. Props for the other platform are ignored rather than erroring, so it is safe to set both - but `colors` is an array while `tintColor` is a single value, and swapping them silently does nothing. Pass the control explicitly like this whenever you need any of these.

Example · tsx
import { FlatList, Platform, RefreshControl, StyleSheet, Text } from "react-native";

type Alert = { id: string; text: string };

type AlertListProps = {
  alerts: Alert[];
  refreshing: boolean;
  onRefresh: () => void;
};

export default function AlertList({ alerts, refreshing, onRefresh }: AlertListProps) {
  return (
    <FlatList
      data={alerts}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text style={styles.row}>{item.text}</Text>}
      refreshControl={
        <RefreshControl
          refreshing={refreshing}
          onRefresh={onRefresh}
          // iOS only: one colour, plus an optional caption
          tintColor={Platform.OS === "ios" ? "#444" : undefined}
          title="Checking for alerts"
          titleColor="#666"
          // Android only: an array of colours and an indicator background
          colors={["#444"]}
          progressBackgroundColor="#fff"
        />
      }
    />
  );
}

const styles = StyleSheet.create({
  row: { padding: 16, fontSize: 16 },
});

Stopping at the last page

Two guards doing different jobs: `loading` prevents overlapping requests, and `done` prevents any request at all once the server has run out of rows. A short page is the usual end-of-data signal. Note that `loading` is state here because the footer spinner has to react to it, and it is also in the dependency array - the ref-only version in the main example avoids both.

Example · tsx
import { useCallback, useRef, useState } from "react";
import { ActivityIndicator, FlatList, StyleSheet, Text } from "react-native";

type Row = { id: string; label: string };

type PagedListProps = {
  // returns fewer than `size` items when it has reached the end
  fetchPage: (page: number, size: number) => Promise<Row[]>;
};

const SIZE = 25;

export default function PagedList({ fetchPage }: PagedListProps) {
  const [rows, setRows] = useState<Row[]>([]);
  const [loading, setLoading] = useState(false);
  const page = useRef(0);
  const done = useRef(false);

  const loadMore = useCallback(async () => {
    if (loading || done.current) return; // two separate reasons not to fetch
    setLoading(true);
    const batch = await fetchPage(page.current, SIZE);
    if (batch.length < SIZE) {
      done.current = true; // a short page means there is no next one
    }
    page.current += 1;
    setRows((prev) => [...prev, ...batch]);
    setLoading(false);
  }, [fetchPage, loading]);

  return (
    <FlatList
      data={rows}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text style={styles.row}>{item.label}</Text>}
      onEndReached={loadMore}
      onEndReachedThreshold={0.4}
      ListFooterComponent={loading ? <ActivityIndicator style={styles.footer} /> : null}
    />
  );
}

const styles = StyleSheet.create({
  row: { padding: 16, fontSize: 16 },
  footer: { padding: 24 },
});

Merging a refresh without duplicating rows

Replacing the whole array on refresh is simplest, but it throws away pages the reader already scrolled past. Merging by id keeps them and still applies updates, because a Map set overwrites an existing key while keeping its position. This is also how you avoid the duplicate-key warning when a paged request overlaps a refresh.

Example · tsx
type Row = { id: string; label: string };

// A refresh can return rows you already hold. Last write wins,
// and insertion order is preserved, so new rows keep their place.
function mergeById(existing: Row[], incoming: Row[]): Row[] {
  const byId = new Map<string, Row>();
  for (const row of [...existing, ...incoming]) {
    byId.set(row.id, row);
  }
  return [...byId.values()];
}

const merged = mergeById(
  [
    { id: "1", label: "old one" },
    { id: "2", label: "two" },
  ],
  [
    { id: "1", label: "new one" },
    { id: "3", label: "three" },
  ]
);

console.log(merged.map((row) => row.id));
// Output: [ '1', '2', '3' ]

console.log(merged[0].label);
// Output: new one

Discussion

  • Be the first to comment on this lesson.