Mobile Apps with React Native and Expo · Foundations: Screens, Styles, Lists and Navigation
Showing Long Lists with FlatList
Render thousands of items smoothly with FlatList: it only draws what's on screen. Add stable keys, pull-to-refresh and infinite scrolling, and avoid the mistakes that make lists stutter.
What you'll build
A contacts list with hundreds of entries that scrolls smoothly, refreshes when pulled down, and loads the next page when you near the bottom.
The idea
A ScrollView renders all of its children at once. With 2,000 contacts that's 2,000 rows in memory and a slow first render. FlatList renders only the rows near the screen (plus a small buffer) and recycles work as you scroll; that's called virtualisation.
What FlatList needs from you:
data: the array of items,renderItem: how to draw one item,keyExtractor: a stable, unique key per item (an ID, never the array index if items can move),- optionally
onEndReached(load more),refreshing+onRefresh(pull to refresh),ListEmptyComponent, andSectionListwhen you need headers (A, B, C…).
The code
import { useCallback, useState } from "react";
import { ActivityIndicator, FlatList, Pressable, Text, View } from "react-native";
type Contact = { id: string; name: string; phone: string };
function Row({ item, onPress }: { item: Contact; onPress: (id: string) => void }) {
return (
<Pressable onPress={() => onPress(item.id)} style={{ padding: 16 }}>
<Text style={{ fontSize: 16 }}>{item.name}</Text>
<Text style={{ color: "#71717a" }}>{item.phone}</Text>
</Pressable>
);
}
export function Contacts({ fetchPage }: { fetchPage: (page: number) => Promise<Contact[]> }) {
const [items, setItems] = useState<Contact[]>([]);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [done, setDone] = useState(false);
const loadMore = useCallback(async () => {
if (loading || done) return; // onEndReached can fire several times
setLoading(true);
const next = await fetchPage(page);
setItems((prev) => [...prev, ...next.filter((c) => !prev.some((p) => p.id === c.id))]);
setPage((p) => p + 1);
setDone(next.length === 0);
setLoading(false);
}, [loading, done, page, fetchPage]);
const refresh = useCallback(async () => {
setRefreshing(true);
setItems(await fetchPage(0));
setPage(1);
setDone(false);
setRefreshing(false);
}, [fetchPage]);
const open = useCallback((id: string) => console.log("open", id), []);
return (
<FlatList
data={items}
keyExtractor={(c) => c.id}
renderItem={({ item }) => <Row item={item} onPress={open} />}
ItemSeparatorComponent={() => <View style={{ height: 1, backgroundColor: "#e4e4e7" }} />}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
refreshing={refreshing}
onRefresh={refresh}
ListFooterComponent={loading ? <ActivityIndicator style={{ margin: 16 }} /> : null}
ListEmptyComponent={!loading ? <Text style={{ padding: 16 }}>No contacts yet.</Text> : null}
/>
);
}
Try the logic
Two pieces of list logic are worth testing on their own: merging pages without duplicates (APIs often return an item twice when new data arrives between pages), and grouping into alphabetical sections for a SectionList.
type Contact = { id: string; name: string };
function mergePage(existing: Contact[], incoming: Contact[]): Contact[] {
const seen = new Set(existing.map((c) => c.id));
return [...existing, ...incoming.filter((c) => !seen.has(c.id))];
}
function toSections(contacts: Contact[]): { title: string; data: Contact[] }[] {
const groups = new Map<string, Contact[]>();
for (const c of [...contacts].sort((a, b) => a.name.localeCompare(b.name))) {
const letter = c.name[0].toUpperCase();
if (!groups.has(letter)) groups.set(letter, []);
groups.get(letter)!.push(c);
}
return [...groups].map(([title, data]) => ({ title, data }));
}
const page1 = [{ id: "1", name: "Ana" }, { id: "2", name: "ben" }, { id: "3", name: "Cleo" }];
const page2 = [{ id: "3", name: "Cleo" }, { id: "4", name: "Bea" }, { id: "5", name: "Amir" }]; // "3" again
const all = mergePage(page1, page2);
console.log("ids:", all.map((c) => c.id).join(", "));
for (const section of toSections(all)) {
console.log(section.title, "->", section.data.map((c) => c.name).join(", "));
}
Output
ids: 1, 2, 3, 4, 5 A -> Amir, Ana B -> Bea, ben C -> Cleo
Common mistakes
ScrollViewfor long or unbounded lists. UseFlatList/SectionList(or a faster drop-in like FlashList for very large lists).- Index as key. When items are inserted or removed, rows get the wrong state; use IDs.
- Not guarding
onEndReached. It can fire repeatedly; skip if already loading or finished. - Heavy
renderItem. Keep rows simple, memoise row components, and avoid creating new functions per row when it causes re-renders. - Images at full resolution in rows. Request thumbnails sized for the row.
Key points
- FlatList virtualises: only nearby rows are rendered.
- Provide stable
keyExtractorIDs; never indexes for changing data. - Pagination with
onEndReached+ a loading guard; pull-to-refresh withrefreshing/onRefresh. - Keep merge/grouping logic in testable functions.
Further reading: FlatList, Optimising FlatList.
Practice questions
Why is FlatList better than ScrollView for long lists?
Answer
ScrollView renders every child at once, using memory and slowing the first render. FlatList virtualises: it renders only the items near the visible area and adds or removes rows as the user scrolls.
Why shouldn't you use the array index as a list key when items can change?
Answer
Keys tell React which row is which. If an item is inserted or removed, indexes shift, so React reuses the wrong rows: state, animations or input values end up on the wrong item. Stable IDs avoid this.
Open in the app to mark lessons complete, take notes and track your progress.