Refreshing and Paging
Pull-to-refresh with RefreshControl, infinite scroll with onEndReached, and the guard that stops it firing twice.
<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
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.
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.
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.
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