Making a List Fast
Stable keys, a memoised renderItem, getItemLayout and window size - and measuring before you change any of them.
getItemLayout={(data, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index })}A FlatList that stutters is usually not FlatList's fault. Four causes account for most of it, and all four are measurable before you touch them.
Measure first
Open the dev menu and turn on the Perf Monitor to watch the JS and UI frame rates while you scroll, and use the React DevTools profiler to see which components re-render. Tuning numbers without a reading is guesswork, and some of these props make scrolling worse if set wrongly.
Stable keys
keyExtractor must return the same string for the same item every render. Returning the index, or a value built from the row's position, makes React tear down and rebuild rows whenever the array shifts.
A renderItem that does not change identity
FlatList re-renders rows when its props change. An inline arrow is a new function on every render, so wrap renderItem in useCallback and wrap the row component in memo. memo compares props shallowly and skips the render when they match; it is wasted effort if the row receives a freshly built object or arrow each time. Note the two arrow forms are the same function:
({ item }) => <Row city={item} />
({ item }) => { return <Row city={item} />; }
getItemLayout and windowing
getItemLayout returns { length, offset, index } and lets FlatList skip measuring rows. initialNumToRender defaults to 10 and windowSize to 21, meaning the viewport plus about ten screens each way. Lowering windowSize cuts memory but shows blank space during fast scrolls.
Example
import { memo, useCallback } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
import type { ListRenderItemInfo } from "react-native";
type City = { id: string; name: string };
type RowProps = { city: City };
// memo skips this render when props are shallow-equal to last time
const Row = memo(function Row({ city }: RowProps) {
return (
<View style={styles.row}>
<Text>{city.name}</Text>
</View>
);
});
const ROW_HEIGHT = 56;
type CityListProps = { cities: City[] };
export default function CityList({ cities }: CityListProps) {
// one stable function identity for the life of the component
const renderItem = useCallback(
({ item }: ListRenderItemInfo<City>) => <Row city={item} />,
[]
);
return (
<FlatList
data={cities}
renderItem={renderItem}
keyExtractor={(item) => item.id}
// correct only because every row is exactly ROW_HEIGHT tall
getItemLayout={(_data, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
initialNumToRender={12} // default is 10
windowSize={11} // default is 21: viewport plus ~10 screens each way
/>
);
}
const styles = StyleSheet.create({
row: { height: ROW_HEIGHT, justifyContent: "center", paddingHorizontal: 16 },
});When to use it
- Diagnose a list that drops frames while scrolling before changing any props.
- Stop every visible row re-rendering when one row's selection changes.
- Give FlatList row geometry so it can jump to an index without measuring.
- Cut the memory a long list holds on low-end Android devices.
More examples
Keeping memo effective with a press handler
On screen: tapping a row makes its label bold and un-bolds the previous one. The handler passed into Row is stable, so only the two rows whose `selected` value changed actually re-render. `renderItem` must list `selectedId` in its dependency array, or rows keep the old selection - a stale closure, not a FlatList bug.
import { memo, useCallback, useState } from "react";
import { FlatList, Pressable, StyleSheet, Text } from "react-native";
import type { ListRenderItemInfo } from "react-native";
type City = { id: string; name: string };
type RowProps = {
city: City;
selected: boolean;
onSelect: (id: string) => void;
};
const Row = memo(function Row({ city, selected, onSelect }: RowProps) {
// this arrow is recreated when Row renders, which is fine: it is not a prop
return (
<Pressable style={styles.row} onPress={() => onSelect(city.id)}>
<Text style={selected && styles.selected}>{city.name}</Text>
</Pressable>
);
});
type CityPickerProps = { cities: City[] };
export default function CityPicker({ cities }: CityPickerProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// stable: passing an inline arrow here would re-render every visible row
const onSelect = useCallback((id: string) => setSelectedId(id), []);
const renderItem = useCallback(
({ item }: ListRenderItemInfo<City>) => (
<Row city={item} selected={item.id === selectedId} onSelect={onSelect} />
),
[selectedId, onSelect]
);
return <FlatList data={cities} renderItem={renderItem} keyExtractor={(item) => item.id} />;
}
const styles = StyleSheet.create({
row: { padding: 16 },
selected: { fontWeight: "700" },
});Finding duplicate keys before they cost you
A plain function, so it belongs in a test or a dev-only check rather than in render. Two pages of results that overlap, or a merge that appends an item already in the list, are the usual sources. Duplicate keys do not crash - they produce rows that appear to shuffle, which is far harder to diagnose.
type Keyed = { id: string };
// Duplicate keys make React reuse the wrong row, which looks like
// rows swapping content as you scroll. Run this over data you do not control.
function duplicateKeys(items: Keyed[]): string[] {
const seen = new Set<string>();
const dupes = new Set<string>();
for (const { id } of items) {
if (seen.has(id)) {
dupes.add(id);
}
seen.add(id);
}
return [...dupes];
}
console.log(duplicateKeys([{ id: "a" }, { id: "b" }, { id: "a" }]));
// Output: [ 'a' ]
console.log(duplicateKeys([{ id: "a" }, { id: "b" }]));
// Output: []Measuring instead of guessing
A crude but honest reading: scroll a fixed distance, note the count, make one change, scroll the same distance again. If the number barely moves, the problem is not re-renders and memoising more will not help. `__DEV__` keeps the logging out of release builds. Delete the counter once you have your answer.
import { useRef } from "react";
import { FlatList, StyleSheet, Text } from "react-native";
import type { ListRenderItemInfo } from "react-native";
type City = { id: string; name: string };
type CountedListProps = { cities: City[] };
export default function CountedList({ cities }: CountedListProps) {
// useRef holds a mutable box: .current survives renders and changing it
// does not trigger one, so counting here is safe
const renders = useRef(0);
function renderItem({ item }: ListRenderItemInfo<City>) {
renders.current += 1;
if (__DEV__) {
// __DEV__ is true in development builds and false in release
console.log("row renders so far", renders.current);
}
return <Text style={styles.row}>{item.name}</Text>;
}
return <FlatList data={cities} renderItem={renderItem} keyExtractor={(item) => item.id} />;
}
const styles = StyleSheet.create({
row: { padding: 16, fontSize: 16 },
});
Discussion