Your feed scrolls fine with 20 items.
Ship 2,000 and the UI starts hitching, memory climbs, and “just wrap it in a ScrollView” becomes the bug report.
Lists are where most React Native apps feel slow. Not because RN is slow — because the wrong list API (or the right one, misconfigured) forces the JS thread and the UI thread to do work they shouldn’t.
This is the practical guide: which component to pick, how recycling actually works, and the knobs that matter in production.
TL;DR
| Component | Best for | Virtualizes? | Recycles views? | Scale |
|---|---|---|---|---|
ScrollView |
Short, fixed content | ❌ mounts all | ❌ | ~tens of items |
FlatList |
Long homogeneous lists | ✅ | ✅ (limited) | hundreds–low thousands |
SectionList |
Grouped lists (A–Z, dates) | ✅ | ✅ (limited) | hundreds–low thousands |
FlashList (@shopify/flash-list) |
Long lists that must feel native | ✅ | ✅ (aggressive) | thousands+ |
Rule of thumb
- If every child mounts at once → not a list for large data. Use virtualization.
- Prefer FlashList for feeds, catalogs, chats, search results.
- Use FlatList / SectionList when you want zero extra deps or need sticky section headers with RN primitives.
- Use ScrollView only when the content is small and known.
Mental model: what makes a list “slow”
A scrollable screen can be slow for three different reasons. Optimize the right one.
1) Mount cost
Too many React trees created up front
→ blank screen, high JS heap, slow first paint
2) Frame cost (scroll jank)
Too much work per frame while scrolling
→ dropped frames, sticky fingers
3) Memory cost
Too many native views / images retained
→ OOM, background kills, iOS jetsam
Virtualization attacks (1) and (3).
Recycling + lighter rows attack (2).
Image strategy and memoization finish the job.
ScrollView
[ item ][ item ][ item ] ... [ item ] ← all mounted
FlatList / SectionList / FlashList
viewport + overscan window
┌─────────────────────────┐
│ recycled row cells │ ← only these exist in native UI
└─────────────────────────┘
↕ bind new data as you scroll
1. ScrollView — when it’s correct (and when it’s a footgun)
ScrollView renders every child immediately. No windowing. No recycling.
Use it when
- Settings screens, forms, onboarding steps
- Content height is roughly one or two screens
- You need nested layout that isn’t a data-driven list
- Item count is small and stable (say under 20–30 simple rows)
Don’t use it when
-
data.map(...)over API results of unknown length - Chat history, product grids, notification feeds
- Anything that grows with user data
Anti-pattern
// ❌ Looks fine in demos. Dies in production.
<ScrollView>
{products.map(item => (
<ProductCard key={item.id} item={item} />
))}
</ScrollView>
Why it hurts:
- All
ProductCardcomponents mount on open - All images may start loading
- Native view hierarchy is huge
- Scroll is smooth until it isn’t — then memory and JS GC thrash
Nested ScrollView + list = pain
Avoid:
<ScrollView>
<Header />
<FlatList data={items} ... /> {/* nested VirtualizedList warning / broken windowing */}
</ScrollView>
Prefer:
- One list with
ListHeaderComponent/ListFooterComponent - Or FlashList’s similar header/footer APIs
- Or a single scroll surface (don’t nest vertical scrollers)
⚠️ Warning: Nesting a vertical
FlatListinside a verticalScrollViewbreaks windowing. You’ll often get the “VirtualizedLists should never be nested…” warning — and real jank.
2. FlatList — the default virtualized list
FlatList is a convenience wrapper over VirtualizedList. It only mounts items near the viewport (plus a render window), and reuses some native cells as you scroll.
Minimal solid setup
import { FlatList, Pressable, Text } from 'react-native';
import { memo, useCallback } from 'react';
const Row = memo(function Row({ item, onPress }) {
return (
<Pressable onPress={() => onPress(item.id)} style={styles.row}>
<Text>{item.title}</Text>
</Pressable>
);
});
function ProductList({ products, onOpen }) {
const renderItem = useCallback(
({ item }) => <Row item={item} onPress={onOpen} />,
[onOpen],
);
const keyExtractor = useCallback(item => item.id, []);
return (
<FlatList
data={products}
renderItem={renderItem}
keyExtractor={keyExtractor}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews
/>
);
}
Props that actually matter
| Prop | What it does | Guidance |
|---|---|---|
keyExtractor |
Stable identity for recycling | Use a real id, never index alone for mutable lists |
getItemLayout |
Skip measuring if rows are fixed height | Big win when every row is the same height |
initialNumToRender |
First paint batch | Keep close to what’s on screen (~8–12) |
maxToRenderPerBatch |
How many items per JS batch while scrolling | Lower = less JS spike, higher = fewer blanks |
windowSize |
Viewport multiples kept mounted | Default 21 is heavy; try 5–10
|
updateCellsBatchingPeriod |
Delay between batches (ms) | Raise slightly if scroll feels choppy from JS |
removeClippedSubviews |
Detach offscreen native views | Often helps Android; verify no missing views |
ListEmptyComponent |
Empty state | Avoid conditional whole-list unmount flicker |
Fixed-height turbo: getItemLayout
If every row is 72 px tall:
const ITEM_HEIGHT = 72;
const getItemLayout = useCallback(
(_data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[],
);
<FlatList getItemLayout={getItemLayout} /* ... */ />
You skip async layout measurement. Jump-to-index and scroll restoration get cheaper and more accurate.
Variable-height rows? Skip this, or maintain your own offset cache carefully.
The extraData trap
FlatList does a shallow compare on data. Selection state living outside data won’t re-render rows unless you tell it:
<FlatList
data={items}
extraData={selectedId} // force rows to update when selection changes
renderItem={({ item }) => (
<Row item={item} selected={item.id === selectedId} />
)}
/>
Inline functions and anonymous rows
These recreate every parent render and defeat memo:
// ❌ new function + new component type every render
<FlatList
renderItem={({ item }) => <Row item={item} onPress={() => go(item.id)} />}
/>
Prefer stable renderItem, memoized row, and stable handlers (pass id, handle in row, or use a context/ref for callbacks).
Images in FlatList rows
- Size images explicitly (
width/height) - Use a caching library (
react-native-fast-imageor Expo Image) - Don’t decode huge assets into tiny thumbnails without resizing
- Use recycling-aware placeholders so recycled cells don’t flash the wrong image (cancel / version tokens)
3. SectionList — grouped data without fighting FlatList
SectionList is FlatList’s cousin for sectioned data:
[
{ title: 'A', data: [/* contacts */] },
{ title: 'B', data: [/* ... */] },
]
Solid pattern
import { SectionList, Text, View } from 'react-native';
import { memo, useCallback } from 'react';
const ContactRow = memo(function ContactRow({ item }) {
return (
<View style={styles.row}>
<Text>{item.name}</Text>
</View>
);
});
function ContactsScreen({ sections }) {
const renderItem = useCallback(
({ item }) => <ContactRow item={item} />,
[],
);
const renderSectionHeader = useCallback(
({ section: { title } }) => (
<View style={styles.header}>
<Text style={styles.headerText}>{title}</Text>
</View>
),
[],
);
return (
<SectionList
sections={sections}
keyExtractor={item => item.id}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled
initialNumToRender={12}
windowSize={7}
removeClippedSubviews
/>
);
}
SectionList gotchas
- Same optimization rules as FlatList — memo rows, stable keys, tune window props.
- Sticky headers have platform quirks — test Android carefully.
-
Don’t rebuild
sectionsevery render with a fresh array/object graph if data didn’t change — that invalidates list reconciliation. -
Mixed item types (header-like rows inside
data) make recycling harder — keep headers as real section headers. - For very large sectioned datasets (e.g. 10k contacts), consider FlashList + manual sticky headers or a specialized contacts UI.
When SectionList is the wrong tool
- Fake sections by stuffing headers into a flat array and branching in
renderItem— works, but you lose sticky header semantics and complicategetItemLayout - Nested
SectionLists - Tiny static grouped content — a
ScrollViewwith plainViews is simpler
4. FlashList — when FlatList’s recycling isn’t enough
@shopify/flash-list is built for blank-free, high-FPS lists. It recycles views more aggressively and asks you to help with item type and size estimates.
Install
yarn add @shopify/flash-list
# iOS
cd ios && pod install
Baseline migration from FlatList
import { FlashList } from '@shopify/flash-list';
import { memo, useCallback } from 'react';
import { Text, View } from 'react-native';
const Row = memo(function Row({ item }) {
return (
<View style={styles.row}>
<Text>{item.title}</Text>
</View>
);
});
function Feed({ items }) {
const renderItem = useCallback(({ item }) => <Row item={item} />, []);
return (
<FlashList
data={items}
renderItem={renderItem}
keyExtractor={item => item.id}
estimatedItemSize={72} // critical in v1; confirm for your version
/>
);
}
💡 Version note: FlashList API evolved (v1 → v2). Always confirm current required props (
estimatedItemSizevs newer sizing APIs) for the version you install. The ideas stay the same: estimate sizes, separate item types, keep rows pure.
Why FlashList feels faster
| FlatList | FlashList |
|---|---|
| Conservative recycling | Cell recycling tuned like native RecyclerView / UICollectionView
|
| More blank cells under fast fling | Fewer blanks when configured well |
| Measurement churn on variable heights | Size estimates + recycling reduce thrash |
| Easy defaults, easier to misuse | Slightly more setup, better ceiling |
Item types — don’t skip this
If row layouts differ (text-only vs image vs ad), tell FlashList so it recycles like-with-like:
<FlashList
data={items}
renderItem={renderItem}
estimatedItemSize={80}
getItemType={item => item.kind} // 'text' | 'media' | 'ad'
/>
Without this, a tall media cell may be reused for a short text row → layout jumps / wasted work.
Headers, footers, empty states
<FlashList
data={items}
ListHeaderComponent={Header}
ListFooterComponent={isLoading ? FooterSpinner : null}
ListEmptyComponent={Empty}
renderItem={renderItem}
estimatedItemSize={72}
/>
Grid with FlashList
<FlashList
data={products}
numColumns={2}
renderItem={renderItem}
estimatedItemSize={220}
/>
Keep column cell heights predictable. Wildly different cell heights in a grid are where you earn your performance scars.
FlashList as a SectionList alternative
FlashList doesn’t replace SectionList 1:1. Common approach:
- Flatten sections into one array with
{ type: 'header' | 'row', ... } -
getItemTypereturns'header'vs'row' - Implement sticky headers via FlashList sticky props / a custom overlay if you need them
More work than SectionList, but worth it at large scale.
Optimization checklist (all virtualized lists)
Copy/paste PR checklist
Data & identity
keyExtractor / keys from server idsdata / sections every renderlimit / cursor) — UI virtualization ≠ fetching the world
Row components
memo row componentsrenderItem that closes over changing values unnecessarily
Layout
getItemLayout (FlatList / SectionList)ListHeaderComponent instead of ScrollView wrapping a list
JS thread hygiene
renderItem pure and fastInteractionManager.runAfterInteractions for non-urgent work after push
Images & media
Tuning knobs (FlatList / SectionList)
Start conservative, measure, then adjust:
initialNumToRender={8}
maxToRenderPerBatch={8}
windowSize={5}
updateCellsBatchingPeriod={50}
removeClippedSubviews
- See blank areas while flinging → increase
windowSize/ batch size - See JS thread spikes → decrease them and lighten rows
Choosing the right tool — decision flow
Is the content short and fixed?
YES → ScrollView (or plain View)
NO ↓
Do you need sticky section headers with minimal setup?
YES → SectionList (or FlashList + sticky strategy if huge)
NO ↓
Is the list long / fling-heavy / heterogeneous / business-critical FPS?
YES → FlashList
NO → FlatList is fine
Practical defaults for common screens
| Screen | Recommendation |
|---|---|
| Settings | ScrollView |
| Inbox / notifications |
FlashList (or tuned FlatList) |
| Chat messages |
FlashList inverted / maintain visible content |
| Product catalog grid |
FlashList + numColumns
|
| Contacts A–Z |
SectionList until scale hurts, then flatten + FlashList |
| Horizontal carousels |
FlatList / FlashList horizontal; don’t nest badly with parent vertical list |
| Search results | Virtualized list + pagination + cancel in-flight requests |
Nested lists without shooting yourself
Horizontal list inside vertical list is common (Netflix-style rows).
function ShelfRow({ shelf }) {
return (
<View>
<Text>{shelf.title}</Text>
<FlatList
horizontal
data={shelf.items}
keyExtractor={item => item.id}
renderItem={renderShelfItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={4}
windowSize={3}
/>
</View>
);
}
function Home({ shelves }) {
return (
<FlashList
data={shelves}
keyExtractor={s => s.id}
renderItem={({ item }) => <ShelfRow shelf={item} />}
estimatedItemSize={220}
/>
);
}
Rules:
- Different orientations → OK
- Same orientation nested virtualization → almost always wrong
- Memoize
ShelfRow - Don’t give every horizontal list a huge
windowSize
Profiling: prove the win
Guessing is how you ship React.memo theater.
- Enable perf monitor in the dev menu — watch JS / UI FPS while flinging
- Why did you render? / React DevTools — catch rows re-rendering for no reason
- FlashList recycling warnings — fix estimated sizes / item types
- Xcode Instruments / Android Studio Profiler — native memory & overdraw
- Compare:
- time to first contentful list paint
- FPS during fast fling
- memory after scrolling to end and back
A real win looks like: same features, higher floor FPS, lower memory, fewer blank cells.
Production patterns that pay rent
Pagination + list
const onEndReached = useCallback(() => {
if (hasMore && !loadingMore) {
fetchNextPage();
}
}, [hasMore, loadingMore, fetchNextPage]);
<FlashList
data={items}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
ListFooterComponent={loadingMore ? <Spinner /> : null}
estimatedItemSize={72}
renderItem={renderItem}
/>
💡 Tip: Guard
onEndReached— it can fire more than you think.
Optimistic updates & sparse changes
- Update by id with a stable array strategy (e.g. React Query / normalized store)
- Avoid re-creating every row object if nothing changed
- For selection, store
selectedIdonce; derive UI in the row; useextraDataon FlatList
Avoid layout thrash in rows
// ❌ measuring & setting state in every row on mount
useEffect(() => {
rowRef.current?.measureInWindow(/* ... */);
}, []);
Measure once at list level, or use fixed layout. Per-row measurement is how “beautiful” lists become jank machines.
Don’t animate everything
Enter animations on every recycled row re-bind look cool once and expensive forever. Animate mounts sparingly; prefer list-level transitions.
Quick “stop doing this” list
ScrollView+.mapfor feeds- Index keys on dynamic data
- Anonymous
renderItem+ inline arrow props breaking memo - Nested vertical
FlatListinsideScrollView - Fetching 10,000 rows “because FlatList virtualizes” (network + JS memory still pay)
- Heavy Context values changing every keystroke while a list is mounted
- Ignoring
estimatedItemSize/ item types in FlashList - Complex chart/video in every offscreen row with a huge window
Cheat sheet
ScrollView → small, static, non-virtualized content
FlatList → default virtualized list, tune window props
SectionList → virtualized + sticky sections, same tuning mindset
FlashList → best FPS / recycling for real feeds at scale
Always
- stable keys
- memoized rows
- light renderItem
- pagination
- no nested same-orientation VirtualizedLists
Measure
- blank cells → increase window / fix sizes / use FlashList
- JS stutters → shrink batches, lighten rows, memoize
- memory → smaller window, recycle images, fewer heavy subtrees
Closing
Virtualization is not a silver bullet — it’s a contract.
You promise: stable identity, predictable row layout, cheap renders.
The list promises: only a window of items exists at a time.
Break the contract with ScrollView for unbounded data, unstable keys, or bloated rows, and no windowSize tweak will save you.
Start with the simplest component that fits the screen. When the list becomes a product surface people fling through all day — graduate to FlashList, measure FPS, and keep rows boring.
Boring rows scroll fast.
Top comments (0)