FlashList Performance in React Native: architecture recommendations for high-performance apps

Posted in react-native, performance, flashlist, android on April 6, 2026 by Hemanta Sapkota ‐ 8 min read

FlashList Performance in React Native: architecture recommendations for high-performance apps

We recently optimized a React Native curriculum screen backed by FlashList. The interesting part was not a magic prop or a library upgrade. The biggest wins came from correcting architectural mistakes we had made ourselves: too much async work inside rows, too much mounted content inside accordions, and too many row-level responsibilities. This tutorial walks through those mistakes, the refactor that fixed them, and the FlashList design rules we now use for high-performance mobile apps.

Why this case study matters

FlashList is fast, but it is not a free pass. If each row does expensive work, mounts large hidden subtrees, or owns too much state, the list can still feel slow on real devices. That is exactly what happened to us on a tutorial curriculum screen in a production React Native app.

The screen looked straightforward:

  • a list of modules
  • per-module progress indicators
  • locked and unlocked states
  • expandable lesson rows
  • auto-expansion of the next recommended module

On paper, this is a perfect FlashList use case. In practice, our first version pushed too much logic into the row layer, especially on Android where layout and repeated expansion work became noticeably more expensive.

The fix was less about “tuning FlashList” and more about giving FlashList a better job to do.

The original mistakes

Mistake 1: Doing async progress work inside list rows

Our first implementation let individual accordion rows and lesson rows resolve their own completion state. That meant the list was not just rendering rows. It was also coordinating a lot of per-row async work.

This is the most common FlashList mistake I see in real apps: a row becomes a mini application.

Typical symptoms:

  • rows render a loading spinner before they can render real state
  • list items re-render after async completion arrives
  • focused screens trigger repeated work as the user navigates back and forth
  • row recycling becomes less useful because every recycled cell has expensive setup work

The problem is not only network or storage latency. It is also the amount of React and layout churn introduced when dozens of rows independently update after mount.

Mistake 2: Treating hidden accordion content as cheap

We were using accordion-style rows for modules. The original implementation measured content height, animated the container height, and allowed each item to own its own expansion logic. That sounds reasonable, but hidden content is not free.

When a row contains many child elements:

  • layout measurement gets more expensive
  • animation work increases
  • recycled cells become heavier
  • scroll-to-index behavior becomes less predictable
  • virtualization has a harder time estimating what is really on screen

In other words, the list item stopped being a small card and started behaving like a complex screen embedded inside another screen.

Mistake 3: Letting multiple rows compete for state ownership

Our original version made row-level components responsible for too much:

  • expansion state
  • completion state
  • lock state
  • auto-expansion logic
  • row measurement

That distributed state made the list more dynamic than it needed to be. FlashList works best when the parent owns list orchestration and rows stay mostly presentational.

Mistake 4: Trying to tune virtualization before simplifying row cost

It is tempting to jump straight to:

  • estimatedItemSize
  • drawDistance
  • removeClippedSubviews
  • memoization everywhere

Those props matter, but they are multipliers. If a row is architecturally expensive, virtualization tuning only helps a little. We found that reducing row work gave us a larger win than prop-tweaking alone.

What we changed

1. We centralized curriculum progress calculation

Instead of asking each module row or lesson row to discover progress independently, we moved that work into a single function that resolves curriculum progress ahead of row rendering.

Conceptually, the list now receives:

  • module progress
  • lesson completion state
  • locked state

as plain data.

This matters because FlashList rows become cheap when they are mostly a projection of already-prepared state.

Example shape:

type TutorialCurriculumProgress = {
  modules: Record<string, TutorialCurriculumModuleProgress>;
  lessons: Record<string, boolean>;
};

This was one of the biggest wins in the refactor. We replaced many row-level checks with one coordinated pass, then handed rows the final answer.

2. We limited expansion to a single module

This sounds small, but it is a major architectural simplification.

Instead of allowing every module to manage its own accordion lifecycle, the list owns one expandedModuleName. That means:

  • only one heavy subtree is mounted at a time
  • scroll positioning is easier
  • the UI stays predictable
  • Android has less expansion and relayout work to do

For learning flows and onboarding flows, single-expand behavior is often better UX anyway. The user has one recommended next step, not five half-open branches.

3. We stopped rendering lesson rows when a module is collapsed

Collapsed content should usually be unmounted, not just hidden.

That keeps the active render tree smaller and reduces the amount of work FlashList has to carry around for offscreen cells. This change matters even more when a row contains nested touchables, icons, progress indicators, and status badges.

4. We pushed row components toward presentation, not orchestration

After the refactor, row components became simpler:

  • they receive progress
  • they receive expanded
  • they receive lesson completion data
  • they call an onToggle

That is a healthier contract for any virtualized list item.

5. We used FlashList props as support, not as the main fix

We still kept a realistic estimatedItemSize and tuned draw distance conservatively, but only after simplifying the item model.

That is the right order:

  1. shrink row cost
  2. shrink mounted subtree size
  3. centralize state
  4. then tune virtualization

Why this worked

The winning change was not “FlashList is faster than FlatList.” The winning change was that the list became more deterministic.

Before:

  • rows mounted in an incomplete state
  • rows fetched and derived their own status
  • rows owned animation and expansion
  • rows could mount a large hidden subtree

After:

  • parent computes progress once
  • parent owns expansion
  • rows render mostly from plain data
  • only one accordion subtree is mounted

That is a better architecture for any virtualized list, regardless of library.

General FlashList architecture recommendations for high-performance apps

These are the guidelines we now recommend when designing FlashList-based screens.

1. Treat each row as a pure projection of prepared state

If a row needs to:

  • query storage
  • run expensive selectors
  • calculate lock chains
  • resolve completion status
  • fetch remote data

you are probably pushing too much work into the item layer.

Prefer a parent controller or a screen-level hook that prepares all row state first, then passes rows only what they need to display.

2. Keep the number of mounted subtrees low

Rows that contain accordions, charts, editors, webviews, or nested lists should not stay mounted unless they are active.

Ask yourself:

  • can collapsed sections be unmounted entirely?
  • can only one section be expanded at a time?
  • can the expensive part be navigated into instead of embedded?

This single decision often matters more than any one FlashList prop.

3. Design for predictable heights where possible

FlashList performs best when it can make good assumptions.

If your items have wildly dynamic heights:

  • keep the collapsed state compact and predictable
  • avoid animating many rows at once
  • keep expansion content narrow in scope
  • provide a reasonable estimatedItemSize

Do not expect the list to rescue a layout that is fundamentally hard to estimate.

4. Keep list ownership at the parent level

The parent list component should usually own:

  • which item is expanded
  • selection state
  • loading state
  • filtered data
  • sort order
  • derived metadata used by multiple rows

Rows should mostly render and emit events.

5. Be careful with row-level effects

useEffect, useFocusEffect, subscriptions, and async storage reads inside rows are all red flags in a high-volume list.

Sometimes they are necessary, but they should feel exceptional, not normal.

If you see repeated patterns like “every row checks something on focus,” move that logic upward.

6. Use extraData intentionally

extraData is useful, but it is also easy to abuse. If you pass a large changing object on every render, you can accidentally invalidate the performance benefits you were trying to unlock.

Pass only the state that truly changes row rendering.

Good examples:

  • expanded item id
  • loading flag
  • a small version counter
  • stable maps keyed by item id

7. Keep renderItem stable, but do not obsess over micro-optimizations first

Yes, useCallback helps. Yes, stable keys matter.

But stable callbacks cannot compensate for a row that mounts a webview, runs async effects, and measures dynamic content every time it enters the viewport.

Architecture first. Callback polish second.

8. Separate “summary list view” from “detail interaction view”

If a row starts doing too much, ask whether the list should only show the summary and navigate to a dedicated detail screen for the heavier interaction.

This pattern is especially valuable for:

  • onboarding curricula
  • lesson trees
  • shopping feeds with rich item detail
  • analytics dashboards
  • messaging inboxes with previews

FlashList is strongest when each row is lightweight and the heavy interaction happens one level deeper.

9. Test on Android early

iOS can hide list problems for longer than Android does. If your list contains dynamic height, touchable nesting, progress indicators, and animation, test on a mid-range Android device early in development.

If Android feels slightly off in development, it often becomes clearly wrong once the screen gets more data.

10. Upgrade libraries, but do not rely on upgrades alone

We also updated FlashList during this work, which is worth doing. But dependency upgrades are not a replacement for better list architecture.

If your row model is expensive, the upgrade may reduce pain without removing the root cause.

A practical checklist for FlashList screens

Before shipping a list-heavy screen, ask:

  1. Does every row render from already-prepared data?
  2. Does any row perform its own async fetch or storage read?
  3. Can collapsed heavy content be fully unmounted?
  4. Is expansion state owned centrally?
  5. Is estimatedItemSize realistic?
  6. Are item heights reasonably predictable in the common case?
  7. Does Android still feel smooth with real production data volume?
  8. If a row is complex, should it become a separate screen?

If you can answer those well, FlashList usually performs very well.

Final takeaway

The most useful lesson from this optimization was that high-performance list screens are usually won or lost in architecture, not in props.

FlashList works best when:

  • rows are cheap
  • state is centralized
  • mounted content is limited
  • the list has predictable structure

Our mistakes were common ones, which is exactly why they are worth documenting. The fix was not glamorous, but it was effective: move logic up, keep rows light, and make the list responsible for orchestration instead of forcing every row to think for itself.

comments powered by Disqus