AniUI Academy

FlatList and Virtualization

What virtualization actually means for FlatList, why keyExtractor matters, and the performance props -- initialNumToRender, windowSize, getItemLayout, onEndReached.

9 min read

You've already seen the basic shape of FlatListdata, renderItem, keyExtractor — from the ScrollView vs. FlatList lesson, and why FlatList wins the moment a list gets long. This lesson goes one level deeper: what "virtualization" is actually doing under the hood, and the props that let you tune it once the default behavior isn't enough.

What virtualization actually means

"Virtualization" sounds abstract, but the idea is concrete: at any given moment, FlatList only keeps rendered (mounted) the items that are near the visible viewport — the ones on screen, plus a small buffer above and below. Everything else in your data array has no corresponding view at all right now. As the user scrolls, items that scroll out of range get their views recycled — reused for whatever new item is scrolling into range — rather than every item in the array getting its own permanent, ever-growing pile of native views.

  1. Step 1

    User scrolls

    The visible viewport shifts to a new range of the data array.

  2. Step 2

    Items leave the viewport

    Rows that scroll out of the visible-plus-buffer range are no longer needed on screen.

  3. Step 3

    Views are recycled

    Instead of being destroyed, their underlying native views are reused for incoming rows.

  4. Step 4

    New items render

    renderItem runs for the newly-visible data, filling the recycled views with the right content.

This is why FlatList stays fast at 10,000 rows where a fully-rendered ScrollView would not.

This is the entire reason FlatList can handle a list of ten thousand rows about as cheaply as a list of ten: the cost is proportional to how many rows are near the viewport, not how many rows exist in data.

Why keyExtractor is load-bearing, not cosmetic

You've used key in plain React lists already, and the reasoning here is the same idea applied to something with real consequences. Because FlatList is reusing views rather than creating a fresh one per item, it needs a reliable way to know which logical item a given recycled view is now supposed to represent. keyExtractor is that signal.

<FlatList
  data={orders}
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => <OrderRow order={item} />}
/>

Get this wrong — say, by keying on array index for a list that reorders or has items inserted in the middle — and you get the classic symptom: a row's visible content briefly doesn't match its data, or a component's internal state (like an expanded/collapsed toggle) ends up attached to the wrong row after a scroll. The fix is the same fix as on the web: key by something stable and unique to the item itself, like a database ID, not by position.

Tuning what gets rendered and when

The defaults are reasonable for most lists, but a few props exist for when they aren't:

  • initialNumToRender — how many items to render on first mount, before any scrolling happens. Lower it for a list whose first screenful is expensive to render; raise it if the default leaves visible blank space on a large device before FlatList catches up.

  • windowSize — how large the rendered "window" around the viewport is, expressed as a multiple of the viewport's own height (both above and below). A larger window means smoother fast-scrolling at the cost of more memory and more work per render.

  • getItemLayout — when every row has a fixed, known size, you can hand FlatList the exact size and position math yourself:

    <FlatList
      data={orders}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <OrderRow order={item} />}
      getItemLayout={(_, index) => ({
        length: ROW_HEIGHT,
        offset: ROW_HEIGHT * index,
        index,
      })}
    />

    Without it, FlatList has to measure items as they render to know where everything sits, which costs time and can cause visible jumpiness when jumping to a specific offset (like scrolling to an item by index). getItemLayout skips that measurement pass entirely, because you've already told it the answer — it only applies cleanly when rows really are a fixed, known size, which rules it out for content like chat messages with variable heights.

onEndReached: infinite scroll

Pagination is the other common tuning point. onEndReached fires once the user has scrolled within a configurable distance (onEndReachedThreshold, as a fraction of the viewport) of the bottom of currently loaded content:

function OrderList() {
  const [orders, setOrders] = useState<Order[]>(initialOrders);
  const [loadingMore, setLoadingMore] = useState(false);
 
  const loadMore = async () => {
    if (loadingMore) return;
    setLoadingMore(true);
    const nextPage = await fetchOrders({ after: orders.at(-1)?.id });
    setOrders((prev) => [...prev, ...nextPage]);
    setLoadingMore(false);
  };
 
  return (
    <FlatList
      data={orders}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <OrderRow order={item} />}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
    />
  );
}

The loadingMore guard matters: without it, onEndReached can fire more than once for the same approach to the bottom (it's a threshold crossing, not a one-shot event), and an unguarded handler will happily fire off several duplicate page-fetches before the first one even resolves.

What to remember

  • Virtualization means only items near the viewport are mounted; scrolled-out views are recycled for incoming data rather than every item staying permanently rendered.
  • A stable, unique keyExtractor is what makes recycling correct — the same reasoning as React's list key, with real bugs (stale content, misapplied state) if it's wrong.
  • initialNumToRender and windowSize tune how much is rendered around the viewport, trading memory/work against scroll smoothness.
  • getItemLayout skips a measurement pass when every row's size is fixed and known up front — it doesn't apply to variable-height content.
  • onEndReached is the standard hook for infinite-scroll pagination; guard it against firing multiple overlapping fetches.

Check yourself

4 questions · pass 3/4 to unlock SectionList and Pull-to-Refresh

up to 50
  1. 1.What does "virtualization" mean in the context of FlatList?

  2. 2.Why does a stable, unique keyExtractor matter for FlatList, for essentially the same reason React's key prop matters for a web list?

  3. 3.What does providing getItemLayout let FlatList skip?

  4. 4.What is onEndReached typically used for?

4 left to answer