+1 (415) 943-4271

Lists That Don't Jank: FlashList v2, FlatList, and LegendList on the New Architecture

Lists That Don't Jank

Every app we get called into has a list somewhere. A feed, an inbox, a product grid, a transaction history. And in nearly every performance audit we run, that list is the single biggest source of the "this feels like a website" complaint that eventually lands in a stakeholder's email.

The good news for 2026 is that the list story on React Native is much better than it was. The New Architecture removed a lot of the bridge-serialisation cost that used to make fast scrolling miserable, and the list libraries have been rewritten to take advantage of it. The bad news is that swapping in a new component and hoping for the best is still not a strategy. Below is the sequence we actually follow.

Step 1: Measure before you swap anything

Do not start by changing components. Start by proving where the frames go.

  • Turn on the in-app performance monitor and scroll the list hard on the slowest device you support, not on a recent flagship and not on the simulator. A mid-range Android device from three years ago is the honest test.
  • Watch the JS thread frame rate and the UI thread frame rate separately. They fail for different reasons and they have different fixes.
  • If the JS thread collapses while scrolling, you are doing too much work per cell: heavy render functions, inline object and arrow allocations, per-item date formatting, unmemoised context reads.
  • If the UI thread collapses while the JS thread is fine, the cells themselves are too expensive to lay out and draw: deep view hierarchies, large uncached images, shadows on Android, blur effects.
  • Record a trace and note the numbers before you change code. "It feels smoother" is not a result you can put in a report.

We have seen plenty of teams migrate a whole screen to a new list library and gain nothing, because the actual cost was a 1200x1200 remote image being decoded per row.

Step 2: Decide whether you even need to leave FlatList

FlatList is not the villain it was in 2019. On the New Architecture, with a well-behaved cell, it is fine for a large class of screens. Stay on FlatList when:

  • the list is bounded and modest (a few hundred items at most),
  • rows are a fixed or near-fixed height,
  • the screen is not the core loop of the product.

Move to a recycling list when:

  • the list is effectively unbounded (infinite feed, chat history, log viewer),
  • rows are heterogeneous and heavy,
  • users scroll fast and far, which is exactly when windowSize tuning stops saving you.

Before migrating, apply the boring FlatList hygiene, because you will need it in any library anyway:

const renderItem = useCallback(({ item }) => <Row item={item} />, []);
const keyExtractor = useCallback((item) => item.id, []);

<FlatList
  data={data}
  renderItem={renderItem}
  keyExtractor={keyExtractor}
  removeClippedSubviews
  initialNumToRender={8}
  maxToRenderPerBatch={8}
  windowSize={7}
/>

And make Row a memoised component that takes primitives or a stable object reference. If Row re-renders whenever the parent does, no list library on earth will save you.

Step 3: What actually changed in FlashList v2

FlashList v2 was rebuilt for the New Architecture, and the headline practical differences from v1 are worth knowing before you migrate:

  • No more estimatedItemSize guessing. v1 leaned heavily on your size estimate and punished a bad one with blank space during fast scrolls. v2 measures on the fly, so the prop that everyone got wrong is no longer the tuning knob it was.
  • Automatic sizing for variable-height rows is much better, which matters for chat and comment threads where every row differs.
  • It targets the New Architecture. If your app is still on the old renderer, check compatibility before you plan the work; "upgrade the list" can quietly become "finish the architecture migration first".
  • Masonry and multi-column layouts are first-class rather than a bolt-on.

A minimal migration looks deceptively small:

import { FlashList } from "@shopify/flash-list";

<FlashList
  data={data}
  renderItem={renderItem}
  keyExtractor={keyExtractor}
  getItemType={(item) => item.kind}
/>

The prop that earns its keep is getItemType. Recycling works by reusing a cell of the same type; if your feed mixes text posts, image posts, and ad slots and you do not declare types, the library recycles a text cell into an image cell and pays for the whole layout change. Declaring types is usually the single highest-value line in a migration.

Step 4: Write cells that are safe to recycle

This is the part teams get wrong, and it is library-agnostic. A recycled cell is the same component instance being handed new props. That has consequences:

  • Never keep per-item state in local useState without keying it. A row that tracks expanded locally will show the wrong row expanded after recycling. Lift that state up, keyed by item id.
  • Guard your effects. An effect that fires on mount only will not fire again when the cell is reused. Depend on item.id, not on [].
  • Reset animated values on item change. A Reanimated shared value left at its end state will make the next recycled row appear already animated.
  • Avoid useEffect-driven data fetching per row. Fetch in the parent, or use a query cache, or you will fire and cancel requests as the user flicks.
  • Keep the hierarchy shallow. Two or three nested views per row instead of eight is often worth more than the library swap.

If you must hold local state, key={item.id} on the row forces a fresh instance, at the cost of some recycling benefit. Use it as a deliberate trade, not as a default.

Step 5: Fix the images, because it is usually the images

In most audits, the cell content is the bottleneck and the content is a photo. Use expo-image (or a caching image component) with explicit dimensions, request a server-side resized variant rather than the full-resolution original, and set a recyclingKey so a recycled cell does not flash the previous row's picture:

<Image
  source={{ uri: item.thumbUrl }}
  recyclingKey={item.id}
  style={{ width: 64, height: 64 }}
  contentFit="cover"
  transition={100}
/>

Thumbnails should be thumbnail-sized on the wire. This one change has rescued more list screens for us than every prop in this article combined.

Step 6: Consider the alternatives honestly

FlashList is not the only option now. LegendList has real traction as a drop-in-ish FlatList replacement with strong variable-height handling and chat-oriented features like maintaining scroll position when content is prepended. For very simple fixed-height lists, a plain ScrollView with a windowing hook or even FlatList with getItemLayout can beat both, because you skip the measurement machinery entirely.

The right answer depends on your data shape. Our rule of thumb:

  • Fixed-height, bounded: FlatList with getItemLayout.
  • Unbounded feed, mixed cell types: FlashList v2 with getItemType.
  • Chat / reversed lists where content is prepended: evaluate LegendList against FlashList on your real data before committing.

Step 7: Lock the win in with a regression check

Performance work rots. Add something to CI so it does not:

  • A Maestro flow that scrolls the list a fixed number of times on a fixed device profile.
  • Frame statistics captured from that run, with a threshold that fails the build on regression.
  • A simple render-count assertion in your unit tests for the row component, so an accidental unmemoised prop shows up as a failing test rather than a support ticket in six months.

The short version

Measure first, on a slow device, and separate JS-thread from UI-thread problems. Keep FlatList when the list is bounded and simple. Move to FlashList v2 for unbounded, heterogeneous lists, and declare getItemType when you do. Write cells that survive recycling: no unkeyed local state, effects that depend on item id, animations that reset. Fix the images. Then put a scroll test in CI so the win survives the next six sprints.

If you have a list screen that is losing you users and you would rather not spend three sprints profiling it, that is exactly the kind of scoped engagement our React Native consultants take on. Get in touch and tell us which screen hurts.