React Native Performance: List Rendering
Practical techniques for keeping large lists smooth in React Native — real-world experience with FlatList, memo, and keyExtractor.
In Looplio, there was a noticeable slowdown when the user switched from the calendar view to the full task list. The list was long; each item contained several components — a color label, date info, and completion status. Jank crept in during scrolling. The phone was powerful, but that didn’t comfort me — I knew it would be worse on lower-end devices.
This became a React Native performance lesson.
The difference between ScrollView and FlatList
The first mistake was using ScrollView for the list. ScrollView renders all child components at once. If the list has 50 items, all 50 are mounted and painted — even if the user can only see 10 of them.
FlatList (and its sibling SectionList) virtualizes rendering: only the items currently visible on screen, plus a small buffer, are rendered. As the user scrolls, items are recycled. For large lists, this is a critical difference.
// Bad: renders all items at once
<ScrollView>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</ScrollView>
// Good: renders only visible items
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <TaskCard task={item} />}
/>
Why keyExtractor matters
Every list item in React needs a unique key so React can tell which items changed and which need to be re-rendered.
The keyExtractor prop tells FlatList how to derive that key. You should use a genuinely unique value like item.id; using index is tempting but leads to incorrect updates when items are added or removed from the list.
<FlatList
data={tasks}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => <TaskCard task={item} />}
/>
Preventing unnecessary re-renders inside components
Even with virtualization, if the parent component re-renders on every scroll event, the renderItem function gets recreated — which causes every visible item in the list to re-render as well.
Two tools come into play here:
React.memo: Wraps the component and skips re-renders when props haven’t changed.
const TaskCard = React.memo(({ task }: { task: Task }) => {
return (
<View style={styles.card}>
<Text style={[styles.title, task.completed && styles.completed]}>
{task.title}
</Text>
<Text style={styles.date}>{formatDate(task.dueDate)}</Text>
</View>
);
});
useCallback: Memoizes the renderItem function so its reference stays stable even when the parent re-renders.
const renderTask = useCallback(
({ item }: { item: Task }) => <TaskCard task={item} />,
[]
);
<FlatList
data={tasks}
keyExtractor={(item) => item.id.toString()}
renderItem={renderTask}
/>;
The dependency array of useCallback matters. If renderTask reads any state from the parent component, you need to include it in the dependencies — otherwise you’ll be closing over a stale value.
Boosting scroll speed with getItemLayout
By default, FlatList measures each item’s height after rendering it. If all your items have a fixed height, you can skip that measurement entirely:
<FlatList
data={tasks}
keyExtractor={(item) => item.id.toString()}
renderItem={renderTask}
getItemLayout={(_, index) => ({
length: TASK_CARD_HEIGHT,
offset: TASK_CARD_HEIGHT * index,
index,
})}
/>
This makes a noticeable difference especially when using scrollToIndex — because FlatList already knows the exact position, it can jump there instantly.
windowSize and initialNumToRender
Two important performance-related props on FlatList:
initialNumToRender: how many items to render on the first pass. Default is 10; lowering it for tall cards reduces initial mount time.windowSize: the size of the render window. Default is 21 (10x the visible area above and below). Reducing it lowers memory usage, but fast scrolling may reveal blank areas.
Don’t blindly tweak these values — test and tune them against your actual screen size and card content.
Results in Looplio
After applying the changes — React.memo, useCallback, getItemLayout — scrolling through a 200-item list became smooth. React Native’s Flipper integration and the JS thread frame rate monitor were useful for testing on a lower-powered device.
Performance optimization is not blind fixing. Measure first to find where the slowness actually is, then fix that. Applying React.memo to every component does not guarantee a performance gain; using it in the wrong place just adds unnecessary comparison overhead. Measuring beats guessing.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.