Skip to content
Back to posts

March 16, 2026

React Native Virtualized List Strategies

Practical strategies for building high-performance virtualized lists in React Native, based on patterns that have saved me significant debugging time across multiple production apps.

Start with FlashList, not FlatList

If you’re reaching for FlatList, reconsider. FlatList works fine for simple, small lists, but once you’re dealing with heterogeneous cells, large datasets, or frequent updates, its recycling behavior falls apart. FlashList (from Shopify) uses a RecyclerListView-based approach that recycles views by type, not by index.

You’ll see fewer blank cells during fast scrolls and lower JS thread usage overall. But it is not the only option with this philosophy, LengedList is gaining a lot of traction but I wouldn’t recommend it yet, they had several breaking changes in their API and broken releases, that’s my word of caution.

But to futureproof your code base, I recommend that you wrap FlashList in a single VirtualizedList component that standardizes config across your app. This absorbs the migration cost from FlatList or others in one place.

Wrap it, don’t use it RAW 🧯

Create a wrapper that strips out FlatList-specific props that don’t apply to FlashList (initialNumToRender, maxToRenderPerBatch, windowSize, etc.) and sets sensible defaults:

export const VirtualizedList = <TItem,>({
  // Destructure not useful FlatList props so they don't leak through
  initialNumToRender: _initialNumToRender,
  maxToRenderPerBatch: _maxToRenderPerBatch,
  windowSize: _windowSize,
  removeClippedSubviews: _removeClippedSubviews,
  // ...
  onEndReachedThreshold = 0.5,
  drawDistance = Platform.select({ android: 200, ios: 250 }),
  ...passThroughProps
}: VirtualizedListProps<TItem>) => {
  return (
    <FlashList<TItem>
      contentInsetAdjustmentBehavior="automatic"
      directionalLockEnabled
      scrollEventThrottle={32}
      drawDistance={drawDistance}
      onEndReachedThreshold={onEndReachedThreshold}
      removeClippedSubviews={false}
      {...passThroughProps}
    />
  );
};

This keeps the FlatList-like API your team already knows while running FlashList under the hood. Existing call sites don’t break, they just get faster.

Tune defaults per platform

Don’t assume one set of values works for both iOS and Android. A few worth splitting:

  • drawDistance: Try 250 on iOS and 200 on Android. Android’s rendering pipeline benefits from a slightly smaller draw window to keep frame drops low on mid-range devices.
  • contentContainerStyle: If you’re using a tab bar navigator, you’ll need different bottom padding per platform. iOS typically needs ~96px, Android closer to ~160px, due to differences in how contentInsetAdjustmentBehavior interacts with each platform’s navigation chrome.
  • maxItemsInRecyclePool: Consider capping this at 100 on Android to prevent memory pressure on lower-end devices. On iOS, the default is usually fine.

Use gesture-handler’s ScrollView on iOS, avoid it on Android

This one cost me hours. If you’re using react-native-gesture-handler, you might be tempted to use its ScrollView everywhere. Don’t.

import { Platform, ScrollView as RNScrollView } from 'react-native';
import { ScrollView as GHScrollView } from 'react-native-gesture-handler';

export const ScrollView =
  Platform.OS === 'android' ? RNScrollView : GHScrollView;

On iOS, gesture-handler’s ScrollView is strictly better. It runs gesture recognition on the native thread, which means smoother simultaneous handling of pan, swipe, and scroll gestures. If you have interactive cells (swipe-to-dismiss, long-press menus), gesture-handler avoids the JS bridge bottleneck that causes gesture conflicts with the stock ScrollView.

On Android, it breaks pull-to-refresh. Gesture-handler wraps the native ScrollView in a NativeViewGestureHandler that intercepts ACTION_UP before SwipeRefreshLayout can process it. The refresh indicator gets stuck in its pulling state and onRefresh never fires. This is a known interaction between gesture-handler’s Android implementation and the way SwipeRefreshLayout consumes touch events.

I tried workarounds (custom simultaneousHandlers, waitFor chains) but they all introduced regressions elsewhere in the gesture tree. Platform-splitting the import is the cleanest solution.

Guard against frozen screens

If you’re using react-navigation, be aware that unfocused screens get frozen for performance. If a list on a frozen screen has refreshing={true}, the activity indicator renders into a frozen view, causing visual artifacts when the user navigates back.

Gate the refreshing prop on focus state:

const isFocused = useIsFocused();

<FlashList
  refreshing={isFocused && refreshing}
  // ...
/>

Small detail, big difference in perceived polish. I’d recommend making this the default in your wrapper.

Extract horizontal snap math

FlashList doesn’t support getItemLayout well at the moment, but snap-to-item still works. If you’re building carousels or horizontal lists, extract the layout math into a reusable helper:

export function createHorizontalListLayout({
  itemWidth,
  gap = 0,
  paddingHorizontal = 0,
}: HorizontalListLayoutConfig) {
  const itemWithGap = itemWidth + gap;

  return {
    snapToInterval: itemWithGap,
    decelerationRate: 'fast',
    snapToAlignment: 'start',
  };
}

Spread the result onto your list and you get consistent snapping without reimplementing the math every time.

Final thoughts

FlashList is a clear upgrade over FlatList for non-trivial lists. Wrapping it in a single component keeps platform-specific tuning centralized and makes the migration painless. The gesture-handler ScrollView gotcha is the kind of platform bug that’s easy to miss during development (iOS-only testing is common) and painful to debug in production. Always test pull-to-refresh on a real Android device.