React 네이티브 목록 성능 — ScrollView, FlatList, SectionList, FlashList

작성자

카테고리:

← 피드로
DEV Community · Amit Kumar · 2026-07-25 개발(SW)

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

  1. If every child mounts at once → not a list for large data. Use virtualization.
  2. Prefer FlashList for feeds, catalogs, chats, search results.
  3. Use FlatList / SectionList when you want zero extra deps or need sticky section headers with RN primitives.
  4. 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 ProductCard components 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 FlatList inside a vertical ScrollView breaks 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 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 510 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-image or 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

  1. Same optimization rules as FlatList — memo rows, stable keys, tune window props.
  2. Sticky headers have platform quirks — test Android carefully.
  3. Don’t rebuild sections every render with a fresh array/object graph if data didn’t change — that invalidates list reconciliation.
  4. Mixed item types (header-like rows inside data) make recycling harder — keep headers as real section headers.
  5. 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 complicate getItemLayout
  • Nested SectionLists
  • Tiny static grouped content — a ScrollView with plain Views 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 (estimatedItemSize vs 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'
/>

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:

  1. Flatten sections into one array with { type: 'header' | 'row', ... }
  2. getItemType returns 'header' vs 'row'
  3. 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

  • [ ] Stable keyExtractor / keys from server ids
  • [ ] Avoid using array index as key when items reorder/insert
  • [ ] Don’t clone data / sections every render
  • [ ] Paginate on the server (limit / cursor) — UI virtualization ≠ fetching the world

Row components

  • [ ] memo row components
  • [ ] Cheap rows: no heavy hooks work per row on every parent render
  • [ ] Avoid anonymous renderItem that 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 ListHeaderComponent instead of ScrollView wrapping a list

JS thread hygiene

  • [ ] Keep renderItem pure and fast
  • [ ] Debounce filtering/sorting; do heavy work off the critical path
  • [ ] Prefer InteractionManager.runAfterInteractions for 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 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}
    />
  );
}

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.

  1. Enable perf monitor in the dev menu — watch JS / UI FPS while flinging
  2. Why did you render? / React DevTools — catch rows re-rendering for no reason
  3. FlashList recycling warnings — fix estimated sizes / item types
  4. Xcode Instruments / Android Studio Profiler — native memory & overdraw
  5. 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 selectedId once; derive UI in the row; use extraData on 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

  1. ScrollView + .map for feeds
  2. Index keys on dynamic data
  3. Anonymous renderItem + inline arrow props breaking memo
  4. Nested vertical FlatList inside ScrollView
  5. Fetching 10,000 rows “because FlatList virtualizes” (network + JS memory still pay)
  6. Heavy Context values changing every keystroke while a list is mounted
  7. Ignoring estimatedItemSize / item types in FlashList
  8. 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.

Further reading

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다