Expo• 5 MIN READ
Kirito

Kirito

6/10/20265 min read

The React Native FlatList That Re-rendered 1,000 Times Per Second

A FlatList with 2,000 items started re-rendering every frame, dropping to 5 FPS. The cause: an inline arrow function passed to renderItem.

The React Native FlatList That Re-rendered 1,000 Times Per Second

"Our app is so slow. Scrolling is janky."

Users were complaining about the product list screen. 2,000 items. Scrolling like a slideshow.

I opened the performance monitor: 5 FPS. CPU at 100%.

The Setup

A simple product list using Expo + React Native:

function ProductList({ products }) { const [selectedId, setSelectedId] = useState(null); const renderProduct = (item) => { return ( <ProductCard product={item} isSelected={item.id === selectedId} onPress={() => setSelectedId(item.id)} /> ); }; return ( <FlatList data={products} renderItem={({ item }) => renderProduct(item)} // ← PROBLEM keyExtractor={(item) => item.id} /> ); }

Looked innocent.

The Problem

Every time the user scrolled, the FlatList would re-render visible items.

But worse: every state change (like selecting a product) caused all 2,000 items to re-render, even the off-screen ones.

Why?

Inline arrow functions in renderItem create a new function on every render.

React Native's FlatList uses React.memo by default for its items. But when renderItem is a new function, the memo comparison fails. Every item re-renders.

With 2,000 items, each re-render triggered:

  • 2,000 function calls
  • 2,000 component instances re-evaluating
  • JavaScript thread blocked for 200ms+
  • UI drops frames

The Investigation

I added logs:

const renderProduct = useCallback((item) => { console.log('Rendering product', item.id); return <ProductCard ... />; }, [selectedId]); // Dependency on selectedId // Still not enough because renderItem is recreated

The log showed 2,000 "Rendering product" lines on every tap.

The Fixes

1. Extract renderItem outside component

// Outside component (stable reference) const renderProduct = ({ item, selectedId, onSelect }) => ( <ProductCard product={item} isSelected={item.id === selectedId} onPress={() => onSelect(item.id)} /> ); function ProductList({ products }) { const [selectedId, setSelectedId] = useState(null); // Use useCallback to memoize the renderItem function const renderItem = useCallback( ({ item }) => renderProduct({ item, selectedId, onSelect: setSelectedId }), [selectedId] ); return ( <FlatList data={products} renderItem={renderItem} keyExtractor={useCallback(item => item.id, [])} /> ); }

2. Use 'getItemLayout' for fixed height items

<FlatList getItemLayout={(data, index) => ({ length: 120, // Item height offset: 120 * index, index, })} // Prevents measurement of off-screen items />

3. Implement 'removeClippedSubviews'

<FlatList removeClippedSubviews={true} // Detach off-screen views windowSize={5} // Only render 5 screens worth of items maxToRenderPerBatch={10} updateCellsBatchingPeriod={50} />

4. Use 'React.memo' on ProductCard

const ProductCard = React.memo(({ product, isSelected, onPress }) => { // Component logic }, (prev, next) => { // Custom comparison return prev.product.id === next.product.id && prev.isSelected === next.isSelected; });

The Ultimate Solution

We refactored to use FlashList from Shopify (much more performant):

expo install @shopify/flash-list
import { FlashList } from '@shopify/flash-list'; function ProductList({ products }) { const [selectedId, setSelectedId] = useState(null); const renderItem = useCallback(({ item }) => { return ( <ProductCard product={item} isSelected={item.id === selectedId} onPress={() => setSelectedId(item.id)} /> ); }, [selectedId]); return ( <FlashList data={products} renderItem={renderItem} estimatedItemSize={120} keyExtractor={item => item.id} /> ); }

FlashList rendered at 60 FPS even with 10,000 items.

The Performance Metrics

Before:

  • FPS: 5-10
  • JS thread: 200ms per interaction
  • Memory: 350MB
  • Time to render initial list: 3.2s

After:

  • FPS: 60
  • JS thread: 16ms per interaction
  • Memory: 120MB
  • Time to render initial list: 0.4s

Commands to Debug FlatList Performance

Enable performance monitor

// In development import PerfMonitor from 'react-native-performance-monitor'; PerfMonitor.start();

Profile with Flipper

# Install Flipper # Then run your app with: npx react-native run-ios --configuration Release

Log render counts

const ProductCard = React.memo((props) => { const renderCount = useRef(0); renderCount.current++; console.log('ProductCard render count:', renderCount.current); return (...); });

What I Learned

  • Never define 'renderItem' inline (always 'useCallback').
  • FlatList re-renders all items when parent state changes unless memoized properly.
  • 'getItemLayout' is essential for performance with many items.
  • FlashList is superior for large lists – switch early.

That 5 FPS disaster taught our team to profile every list that could have more than 100 items. Now we have a rule: "Use FlashList for anything >50 items."