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.

Create a free account to save this page, take notes and track your progress. Sign in

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, and SectionList when 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.

Merge pages without duplicates, then group into sectionsTry it yourself
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

  • ScrollView for long or unbounded lists. Use FlatList/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 keyExtractor IDs; never indexes for changing data.
  • Pagination with onEndReached + a loading guard; pull-to-refresh with refreshing/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.

Was this page helpful?