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? ScaleScrollView
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
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
/>
);
}
Enter fullscreen mode Exit fullscreen mode
Props that actually matter
Prop What it does GuidancekeyExtractor
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} /* ... */ />
Enter fullscreen mode Exit fullscreen mode
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} />
)}
/>
Enter fullscreen mode Exit fullscreen mode
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)} />}
/>
Enter fullscreen mode Exit fullscreen mode
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: [/* ... */] },
]
Enter fullscreen mode Exit fullscreen mode
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
/>
);
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
/>
);
}
Enter fullscreen mode Exit fullscreen mode
💡 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 nativeRecyclerView / 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'
/>
Enter fullscreen mode Exit fullscreen mode
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}
/>
Enter fullscreen mode Exit fullscreen mode
Grid with FlashList
<FlashList
data={products}
numColumns={2}
renderItem={renderItem}
estimatedItemSize={220}
/>
Enter fullscreen mode Exit fullscreen mode
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 checklistData & identity
- [ ] Stable
keyExtractor/ keys from server ids - [ ] Avoid using array index as key when items reorder/insert
- [ ] Don’t clone
data/sectionsevery render - [ ] Paginate on the server (
limit/cursor) — UI virtualization ≠ fetching the world
Row components
- [ ]
memorow components - [ ] Cheap rows: no heavy hooks work per row on every parent render
- [ ] Avoid anonymous
renderItemthat closes over changing values unnecessarily - [ ] Move press handlers carefully (stable callback or row-local)
- [ ] Don’t put the entire screen state into every row via props
Layout
- [ ] Prefer fixed heights +
getItemLayout(FlatList / SectionList) - [ ] Provide accurate size estimates (FlashList)
- [ ] Separate heterogeneous layouts with item types (FlashList)
- [ ] No nested VirtualizedLists in the same orientation
- [ ] Use
ListHeaderComponentinstead of ScrollView wrapping a list
JS thread hygiene
- [ ] Keep
renderItempure and fast - [ ] Debounce filtering/sorting; do heavy work off the critical path
- [ ] Prefer
InteractionManager.runAfterInteractionsfor non-urgent work after push - [ ] Profile with React DevTools + RN Perf monitor before “optimizing” randomly
Images & media
- [ ] Explicit dimensions
- [ ] Cache and prioritize visible URLs
- [ ] Blurhash / LQIP placeholders beat layout jumps
- [ ] Recycle-safe binding (don’t show previous item’s avatar)
Tuning knobs (FlatList / SectionList)
Start conservative, measure, then adjust:
initialNumToRender={8}
maxToRenderPerBatch={8}
windowSize={5}
updateCellsBatchingPeriod={50}
removeClippedSubviews
Enter fullscreen mode Exit fullscreen mode
- 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
Enter fullscreen mode Exit fullscreen mode
Practical defaults for common screens
Screen Recommendation SettingsScrollView
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}
/>
);
}
Enter fullscreen mode Exit fullscreen mode
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}
/>
Enter fullscreen mode Exit fullscreen mode
💡 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(/* ... */);
}, []);
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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.
답글 남기기