ScrollView vs FlatList — When to Use Which
Choosing the wrong list component is one of the most common React Native performance mistakes. The rule is simple: if you don't know the size of the list at render time, always use FlatList.
The Core Distinction
- **ScrollView**: renders ALL children immediately, even off-screen. Fine for ≤15 items or screen-sized layouts (forms, detail pages). Never use for dynamic lists
- **FlatList**: virtualizes the list — only renders items in or near the viewport. Recycles view cells. Mandatory for any list where the length is unknown or could exceed ~20 items
- **SectionList**: FlatList with section headers — for contacts, grouped settings, categorized feeds
- `ScrollView` inside `FlatList` (or vice versa) destroys virtualization — React Native cannot calculate item heights when FlatList is inside a ScrollView
Production FlatList Setup
import { FlatList, View, Text, StyleSheet, ActivityIndicator } from 'react-native';
interface Post { id: string; title: string; body: string; author: string; }
// ---- Memoized item component (critical for performance) ----
const PostItem = React.memo(function PostItem({ item }: { item: Post }) {
return (
<View style={styles.card}>
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
<Text style={styles.body} numberOfLines={3}>{item.body}</Text>
<Text style={styles.author}>{item.author}</Text>
</View>
);
});
function PostFeed() {
const [posts, setPosts] = React.useState<Post[]>([]);
const [loading, setLoading] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const [page, setPage] = React.useState(1);
async function fetchPosts(p: number) {
setLoading(true);
const res = await fetch(`https://jsonplaceholder.typicode.com/posts?_page=${p}&_limit=10`);
const data: Post[] = await res.json();
// Assign author since API doesn't have it
const enriched = data.map(d => ({ ...d, author: 'User ' + d.id }));
setPosts(prev => p === 1 ? enriched : [...prev, ...enriched]);
setLoading(false);
}
React.useEffect(() => { fetchPosts(1); }, []);
async function onRefresh() {
setRefreshing(true);
setPage(1);
await fetchPosts(1);
setRefreshing(false);
}
function onEndReached() {
if (!loading) {
const nextPage = page + 1;
setPage(nextPage);
fetchPosts(nextPage);
}
}
return (
<FlatList
data={posts}
keyExtractor={item => item.id} // MUST be stable, unique string
renderItem={({ item }) => <PostItem item={item} />}
// ---- Performance props ----
initialNumToRender={8} // items rendered on first paint
maxToRenderPerBatch={10} // items rendered per JS batch
windowSize={5} // viewport × 5 kept in memory
removeClippedSubviews={true} // Android: unmount off-screen views
getItemLayout={(_data, index) => ({ // skip dynamic measurement if height is fixed
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
// ---- Pull to refresh ----
refreshing={refreshing}
onRefresh={onRefresh}
// ---- Infinite scroll ----
onEndReached={onEndReached}
onEndReachedThreshold={0.3} // trigger at 30% from bottom
// ---- Decorators ----
ListHeaderComponent={<Text style={styles.header}>Latest Posts</Text>}
ListFooterComponent={loading ? <ActivityIndicator style={{ margin: 16 }} /> : null}
ListEmptyComponent={<Text style={styles.empty}>No posts yet.</Text>}
contentContainerStyle={{ padding: 16, gap: 12 }}
showsVerticalScrollIndicator={false}
/>
);
}
const ITEM_HEIGHT = 110;
const styles = StyleSheet.create({
card: { backgroundColor: '#1e293b', borderRadius: 12, padding: 16, height: ITEM_HEIGHT },
title: { fontSize: 15, fontWeight: '700', color: '#f1f5f9', marginBottom: 4 },
body: { fontSize: 13, color: '#94a3b8', lineHeight: 18, marginBottom: 6 },
author: { fontSize: 12, color: '#6366f1' },
header: { fontSize: 20, fontWeight: '700', color: '#f1f5f9', marginBottom: 12 },
empty: { textAlign: 'center', color: '#64748b', marginTop: 60 },
});Common Mistakes
- Wrapping FlatList inside ScrollView — FlatList needs full height to calculate visible window. Inside a ScrollView it renders all items (virtualization completely disabled)
- Using inline `renderItem` → `({ item }) => <PostItem item={item} />` creates a new function reference every render, causing every item to re-render. Extract to a named function or memoize with useCallback
- Not setting `keyExtractor` — React Native falls back to the array index, causing incorrect recycling behavior when items are reordered or deleted
- Missing `getItemLayout` for fixed-height lists — without it, FlatList must measure every item's height before it can scroll to a specific index or position
Tip
Tip
Practice ScrollView vs FlatList When to Use Which in small, isolated examples before integrating into larger projects. Breaking concepts into small experiments builds genuine understanding faster than reading alone.
React Native bridges JavaScript and native platform code
Practice Task
Note
Practice Task — (1) Write a working example of ScrollView vs FlatList When to Use Which from scratch without looking at notes. (2) Modify it to handle an edge case (empty input, null value, or error state). (3) Share your solution in the Priygop community for feedback.
Quick Quiz
Common Mistake
Warning
A common mistake with ScrollView vs FlatList When to Use Which is skipping edge case testing — empty inputs, null values, and unexpected data types. Always validate boundary conditions to write robust, production-ready react native code.
Key Takeaways
- Choosing the wrong list component is one of the most common React Native performance mistakes.
- *ScrollView**: renders ALL children immediately, even off-screen. Fine for ≤15 items or screen-sized layouts (forms, detail pages). Never use for dynamic lists
- *FlatList**: virtualizes the list — only renders items in or near the viewport. Recycles view cells. Mandatory for any list where the length is unknown or could exceed ~20 items
- *SectionList**: FlatList with section headers — for contacts, grouped settings, categorized feeds