Why not just map over an array?
Because .map() inside a ScrollView mounts every single row before the user sees anything. Twenty rows is fine. Two thousand rows is a frozen screen and, on Android, sometimes a crash. FlatList is virtualized: it renders what fits plus a buffer, and drops rows as they leave the window.
The corollary is the rule people break most often: never nest a FlatList inside a ScrollViewalong the same scroll axis. The ScrollView hands the list unbounded height, the list concludes everything is visible, and virtualization silently turns itself off — you get the performance of a map with the complexity of a list. If you need content above the list, that’s what ListHeaderComponent is for.
The props worth knowing
| Prop | What it does | What you get without it |
|---|---|---|
keyExtractor | Stable identity per row | Rows re-mount and lose state on reorder |
ListEmptyComponent | What shows when data is empty | A blank screen users read as broken |
ItemSeparatorComponent | Divider between rows only | A stray line after the last row |
ListHeaderComponent | Search bar, filters, stats | A header that will not scroll away |
refreshing / onRefresh | Native pull-to-refresh | Users pull anyway and nothing happens |
onEndReached | Load the next page | Manual "load more" buttons |
getItemLayout | Skip measurement for fixed-height rows | Slower scrollToIndex on long lists |
initialNumToRender | Rows drawn on first paint | A slower first frame than you need |
That’s the whole list. The tuning props people reach for first — windowSize, maxToRenderPerBatch, removeClippedSubviews — are last resorts, and setting them blindly usually trades blank space during scrolling for a marginally faster mount. Fix rendering first.
A complete list screen
Search header, empty state, pull-to-refresh, and pagination — the four things a real list screen always grows:
import { memo, useCallback, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
// 1. The row lives outside the screen and is memoized.
// Inline row components re-render on every parent state change.
const JobRow = memo(function JobRow({ job, onPress }) {
return (
<Pressable style={styles.row} onPress={() => onPress(job.id)}>
<Text style={styles.title} numberOfLines={1}>{job.title}</Text>
<Text style={styles.meta}>{job.company} · {job.location}</Text>
</Pressable>
);
});
export default function JobsScreen({ navigation }) {
const [jobs, setJobs] = useState([]);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(1);
const [reachedEnd, setReachedEnd] = useState(false);
const openJob = useCallback((id) => navigation.navigate('Job', { id }), [navigation]);
// 2. Stable function identity — not an inline arrow in the JSX
const renderItem = useCallback(
({ item }) => <JobRow job={item} onPress={openJob} />,
[openJob],
);
const onRefresh = useCallback(async () => {
setRefreshing(true);
const first = await api.jobs({ page: 1 });
setJobs(first);
setPage(1);
setReachedEnd(false);
setRefreshing(false);
}, []);
const loadMore = useCallback(async () => {
// 3. The guard: onEndReached fires repeatedly while scrolling
if (loadingMore || refreshing || reachedEnd) return;
setLoadingMore(true);
const next = await api.jobs({ page: page + 1 });
if (next.length === 0) setReachedEnd(true);
setJobs((prev) => [...prev, ...next]);
setPage((p) => p + 1);
setLoadingMore(false);
}, [loadingMore, refreshing, reachedEnd, page]);
return (
<FlatList
data={jobs}
renderItem={renderItem}
keyExtractor={(item) => item.id} // 4. id, never the index
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListHeaderComponent={<SearchBar onQuery={onRefresh} />}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyText}>
{refreshing ? 'Loading jobs…' : 'No jobs match that search yet.'}
</Text>
</View>
}
ListFooterComponent={loadingMore ? <Spinner /> : null}
refreshing={refreshing}
onRefresh={onRefresh}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
contentContainerStyle={jobs.length === 0 && { flexGrow: 1 }}
/>
);
}
const styles = StyleSheet.create({
row: { paddingVertical: 14, paddingHorizontal: 16, gap: 4 },
title: { color: '#fff', fontSize: 16, fontWeight: '600' },
meta: { color: 'rgba(255,255,255,0.6)', fontSize: 13 },
separator: { height: 1, backgroundColor: 'rgba(255,255,255,0.08)' },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
emptyText: { color: 'rgba(255,255,255,0.6)', textAlign: 'center' },
});The last line is a detail worth stealing: contentContainerStyle with flexGrow: 1only while empty. Without it your empty state clings to the top of the screen instead of centring, which is the difference between “designed” and “unfinished” on an otherwise identical screen.
The three real causes of jank
- Rows that re-render for no reason. An inline
renderItem={({ item }) => <Row … />}is a new function every render, so every visible row rebuilds every time any state on the screen changes — including the search text you’re typing. Memoize the row, and giverenderItema stable identity withuseCallback. - Full-size images in thumbnails. A 4000px JPEG decoded into a 64px avatar costs real memory and a frame. Serve resized images from your backend or CDN — this is usually a bigger win than every list prop combined.
- Index as key.
keyExtractor={(_, i) => String(i)}works right up until the data reorders or an item is deleted, at which point rows keep the wrong state — a checkbox stays ticked on the row that replaced it. Use the record’s id.
Measure before and after in release mode on the cheapest Android you have. Dev builds run unoptimised JavaScript, so a list that stutters in Expo Go may well be fine in production — and one that’s smooth on your iPhone can still be unusable on a mid-range Android.
When to switch to FlashList
@shopify/flash-list is a drop-in-shaped replacement that recycles row views instead of unmounting and remounting them. On long lists with rich rows — a feed, a chat, a product catalogue — it holds up noticeably better during fast scrolling on Android, where FlatList is likelier to show blank space:
npx expo install @shopify/flash-list
import { FlashList } from '@shopify/flash-list';
<FlashList data={jobs} renderItem={renderItem} keyExtractor={(i) => i.id} />It’s a native module, so it needs a development build rather than Expo Go — the same one-time cost as any other native dependency. The honest guidance: start with FlatList, and switch when a specific screen measurably struggles. Adding a dependency for a hundred-row settings list is optimisation theatre.
The shortcut: generate the screen, keep the judgement
A list screen is the same twelve props every time, wrapped around a row layout that’s different every time. The props are memorised work; the row — what the user actually reads at a glance — is design.
Describe the screen in ShipNative — “a feed of local jobs with a search bar, pull-to-refresh and infinite scroll” — and it generates the FlatList with memoized rows, an empty state and pagination already wired, running on your phone so you can scroll it with a thumb. Then export the whole Expo project and keep the code.
Build your list app free
Describe your app in one sentence and scroll it on your own phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.