react-native / flatlist / performance
How to make Flatlist faster
React Native’s FlatList is a powerful component for rendering large lists efficiently, but poor optimization can still cause lag, slow scrolling, and unnecessary re-renders. In this blog, I’ll share practical ways to improve FlatList performance and create smoother user experiences.
- Published
- May 4, 2026
- Read
- 6 min

Contents
- 1. Always set keyExtractor
- 2. Add getItemLayout when item height is fixed
- 3. Memoize renderItem with React.memo
- 4. Tune render batch sizes
- 5. Enable removeClippedSubviews
- 6. Never use inline styles or inline functions in renderItem
- 7. Use ListEmptyComponent, not a conditional wrapper
- 8. Paginate with onEndReached — don't load all data up front
- 9. Avoid state updates inside renderItem
- 10. Use FlashList for very large datasets
- The full optimized pattern
React Native's FlatList is the go-to for rendering large lists, but the defaults leave a lot of performance on the table. Lag, dropped frames, and unnecessary re-renders are almost always fixable with a handful of props and component patterns. Here's what actually moves the needle.
1. Always set keyExtractor
Without keyExtractor, React Native falls back to the array index as a key. Index keys break reconciliation when items reorder or get inserted — the virtual DOM diffing can't tell which items changed and re-renders everything.
// Bad — index as key (default)
<FlatList data={data} renderItem={renderItem} />
// Good — stable unique ID
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
/>Use a stable, unique field from your data — id, uuid, anything that won't change when the list updates.
2. Add getItemLayout when item height is fixed
By default, FlatList measures every item on mount to build its scroll offset map. If your rows have a fixed or predictable height, skip the measurements entirely with getItemLayout. This also unlocks scrollToIndex — it can't work without layout info.
const ITEM_HEIGHT = 72
<FlatList
data={data}
keyExtractor={(item) => item.id}
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
renderItem={renderItem}
/>For variable-height rows, skip this prop — an incorrect value will cause items to render in the wrong positions.
3. Memoize renderItem with React.memo
Every time the parent re-renders, a new function reference is created for renderItem and FlatList re-renders all visible rows. Wrap the item component in React.memo and define renderItem outside the component or with useCallback.
// Item component — memoized
const ListItem = React.memo(({ item }: { item: Item }) => (
<View style={styles.row}>
<Text>{item.title}</Text>
</View>
))
// In the parent — stable reference
const renderItem = useCallback(
({ item }: { item: Item }) => <ListItem item={item} />,
[],
)
<FlatList data={data} keyExtractor={(item) => item.id} renderItem={renderItem} />Without React.memo, the item still re-renders even if renderItem reference is stable, because React can't know the props didn't change.
React DevTools profiler will show the re-render waterfall. With memo + useCallback, most items go dark (no re-render) on parent state changes.
4. Tune render batch sizes
Three props control how many items render at once. The defaults are conservative — reasonable for slow devices, but usually worth increasing on modern hardware.
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
initialNumToRender={10} // items rendered on first paint
maxToRenderPerBatch={10} // items rendered per JS frame during scroll
windowSize={5} // multiplier: viewable height × windowSize kept in memory
/>initialNumToRender: Set to exactly what fits on screen — no more. Extra items delay first paint.
maxToRenderPerBatch: Higher = fewer blank flashes when scrolling fast, but heavier JS frames.
windowSize: Default is 21 (10 viewports above + 10 below). Reduce to 5–7 to cut memory on long lists.
5. Enable removeClippedSubviews
On Android especially, off-screen views still sit in the native view hierarchy and eat GPU memory. removeClippedSubviews detaches their native views while keeping the JS component alive.
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
removeClippedSubviews={true}
/>Caveat: can cause blank flashes during very fast scrolling on iOS. Test on device before shipping.
6. Never use inline styles or inline functions in renderItem
Inline objects and functions create new references on every render, defeating memoization.
// Bad — new object every render, breaks React.memo
<View style={{ padding: 16, flex: 1 }}>
// Good — stable reference
const styles = StyleSheet.create({
row: { padding: 16, flex: 1 },
})
<View style={styles.row}>Same rule applies to callbacks passed as props into the item: onPress={() => handlePress(item.id)} inside renderItem creates a new function per item per render. Bind the ID in the item component itself instead.
7. Use ListEmptyComponent, not a conditional wrapper
Wrapping FlatList in a conditional to show an empty state forces a full unmount/remount cycle when data arrives. Use the built-in prop — it renders inside the same scroll context and won't reset scroll position.
const Empty = () => (
<View style={styles.empty}>
<Text>No results found</Text>
</View>
)
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
ListEmptyComponent={Empty}
/>8. Paginate with onEndReached — don't load all data up front
Loading 1000 items at once will choke the JS thread on first render. Fetch the first page, then append as the user approaches the end.
const [data, setData] = useState<Item[]>([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const loadMore = useCallback(async () => {
if (loading) return
setLoading(true)
const next = await fetchPage(page)
setData((prev) => [...prev, ...next])
setPage((p) => p + 1)
setLoading(false)
}, [loading, page])
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
onEndReached={loadMore}
onEndReachedThreshold={0.5} // trigger when 50% from bottom
ListFooterComponent={loading ? <ActivityIndicator /> : null}
/>9. Avoid state updates inside renderItem
Any setState call triggered during a render pass will queue another render pass. If your item component needs local state (e.g., an expanded/collapsed toggle), keep it strictly local inside the memoized item component — don't bubble it up to the list parent.
10. Use FlashList for very large datasets
If you've applied all of the above and scrolling is still janky on large lists, reach for Shopify's FlashList. It's a drop-in replacement that recycles native cell views (like RecyclerView on Android), cuts memory usage by ~50%, and maintains 60fps on lists where FlatList gives up.
import { FlashList } from '@shopify/flash-list'
// Same API as FlatList — swap the import
<FlashList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
estimatedItemSize={72} // required — helps FlashList pre-allocate cells
/>FlashList requires estimatedItemSize instead of getItemLayout. Close is good enough — it corrects on the fly.
On a 2000-item list, FlashList consistently renders 2–3x faster than FlatList on low-end Android devices. The API diff is minimal.
The full optimized pattern
import React, { useCallback, useState } from 'react'
import { FlatList, StyleSheet, Text, View } from 'react-native'
const ITEM_HEIGHT = 72
const ListItem = React.memo(({ item }: { item: Item }) => (
<View style={styles.row}>
<Text style={styles.title}>{item.title}</Text>
</View>
))
export function MyList({ initialData }: { initialData: Item[] }) {
const [data, setData] = useState(initialData)
const [page, setPage] = useState(2)
const [loading, setLoading] = useState(false)
const renderItem = useCallback(
({ item }: { item: Item }) => <ListItem item={item} />,
[],
)
const loadMore = useCallback(async () => {
if (loading) return
setLoading(true)
const next = await fetchPage(page)
setData((prev) => [...prev, ...next])
setPage((p) => p + 1)
setLoading(false)
}, [loading, page])
return (
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
initialNumToRender={12}
maxToRenderPerBatch={10}
windowSize={7}
removeClippedSubviews
onEndReached={loadMore}
onEndReachedThreshold={0.5}
/>
)
}
const styles = StyleSheet.create({
row: { height: ITEM_HEIGHT, paddingHorizontal: 16, justifyContent: 'center' },
title: { fontSize: 16 },
})This covers the 90% case. Profile with Flipper or React DevTools before and after — the frame rate chart will tell you exactly which prop moved the needle for your specific data shape.