In this article, we will take an iterative approach to optimizing a FlatList
component.
For the sake of example, we will use a list of 1000 toggle-able items and measure the impact of our changes in the React Profiler.
Baseline implementation
The baseline implementation offers no optimizations and would be the idiomatic way to implement this kind of thing if it were a non-virtualized list in React.
import * as React from "react";
import { View, Text, TouchableOpacity, FlatList } from "react-native";
type Item = {
id: string;
name: string;
};
let createItems = (): Item[] =>
Array.from({ length: 1000 }).map((_, i) => ({
id: `${i}`,
name: `Item ${i}`,
}));
let data = createItems();
export default function App() {
let [selectedIds, setSelectedIds] = React.useState<string[]>([]);
let toggleId = React.useCallback((id: string) => {
setSelectedIds((prev) => {
if (prev.includes(id)) {
return prev.filter((i) => i !== id);
} else {
return [...prev, id];
}
});
}, []);
let renderItem = React.useCallback(
({ item }: { item }) => {
let { id, name } = item;
let isSelected = selectedIds.includes(id);
return (
<TouchableOpacity
style={{
padding: 8,
flexDirection: "row",
justifyContent: "space-between",
}}
onPress={()=> toggleId(id)}
>
<Text>{name}</Text>
{isSelected && <Text>Selected</Text>}
</TouchableOpacity>
);
},
[selectedIds, toggleId]
);
return (
<View style={{ paddingTop: 64 }}>
<FlatList data={data} renderItem={renderItem} />
</View>
);
}
Baseline results
There is a lot of rerendering happening here!
Toggling items in the list results in a render of 150ms. What can we do to optimize this?
Stabilizing the renderItem
callback
One of the first things that might jump out to you is that the renderItem
function has selectedIds
as a dependency.
This means that every time selectedIds
changes, the renderItem
function will be recreated, causing a rerender of the FlatList
component.
Let's remove that dependency with a context-based approach and see if we get better results.
let SelectedIdsContext = React.createContext<string[]>([]);
export default function App() {
// ...same as before
let renderItem = React.useCallback(
({ item }: { item }) => {
return <FlatlistRow {...item} toggleId={toggleId} />;
},
[toggleId]
);
return (
<SelectedIdsContext.Provider value={selectedIds}>
<View style={{ paddingTop: 64 }}>
<FlatList data={data} renderItem={renderItem} />
</View>
</SelectedIdsContext.Provider>
);
}
function FlatlistRow({
id,
name,
toggleId,
}: {
id: string;
name: string;
toggleId: (id: string) => void;
}) {
let selectedIds = React.useContext(SelectedIdsContext);
let isSelected = selectedIds.includes(id);
return (
<TouchableOpacity
style={{
padding: 8,
flexDirection: "row",
justifyContent: "space-between",
}}
onPress={()=> toggleId(id)}
>
<Text>{name}</Text>
{isSelected && <Text>Selected</Text>}
</TouchableOpacity>
);
}
Results of stabilizing the renderItem
callback
A bit less rendering happening here, but the improvement isn't as much as we'd like
The context-based approach prevents some subtrees from rerendering, but overall the numbers are not much different from the baseline. Each item in the list is still rerendering on every change because we're using context.
Using React.memo
Let's try a different approach.
We'll remove the context we just added and wrap our FlatlistRow
in a React.memo
to prevent rerenders.
Note:
When using React.memo
you'll need to flatten props as much as possible:
- only strings and numbers
- no objects
- stable functions
If a prop is an object, or the function changes on each render, then React.memo
will have no impact.
export default function App() {
// ...same as before
let renderItem = React.useCallback(
({ item }: { item }) => {
let isSelected = selectedIds.includes(item.id);
return (
<FlatlistRow {...item} isSelected={isSelected} toggleId={toggleId} />
);
},
[toggleId, selectedIds]
);
return (
<View style={{ paddingTop: 64 }}>
<FlatList data={data} renderItem={renderItem} />
</View>
);
}
const FlatlistRow = React.memo(function FlatlistRow({
id,
name,
isSelected,
toggleId,
}: {
id: string;
name: string;
isSelected: boolean;
toggleId: (id: string) => void;
}) {
return (
<TouchableOpacity
style={{
padding: 8,
flexDirection: "row",
justifyContent: "space-between",
}}
onPress={()=> toggleId(id)}
>
<Text>{name}</Text>
{isSelected && <Text>Selected</Text>}
</TouchableOpacity>
);
});
Results of using React.memo
The render times are a lot better at ~60ms. Interestingly, the render trees look inverted from our previous example. Now all of our leaf node components are memoized, and only the one that is toggled is fully rerendered.
Memoized FlatlistRow components improves performance by 60ms!
Why don't we try combining our memoization and context based approach to see if we can get even better results? The resulting flamegraph looks a lot like our first one:
Using context with memoized components has negative impact on performance
This is because context is breaking the memoization of our FlatlistRow
components.
If we want to combine these approaches we will need a way to use context without breaking memoization.
Using context selectors
Context selectors solve our problem by providing a way to subscribe to a specific part of the context. This means all of our items won't rerender when the context changes, just the one we want.
Note: Since context selectors are not part of React core, we will use the zustand
package.
import * as React from "react";
import { View, Text, TouchableOpacity, FlatList } from "react-native";
import { create } from "zustand";
type SelectedIdsStore = {
selectedIds: Record<string, boolean>;
};
let useSelectedIdsStore = create<SelectedIdsStore>((set) => ({
selectedIds: {},
}));
let useIsItemSelected = (id: string) =>
useSelectedIdsStore((state) => state.selectedIds[id]);
let toggleId = (id: string) =>
useSelectedIdsStore.setState((state) => {
return {
selectedIds: {
...state.selectedIds,
[id]: !state.selectedIds[id],
},
};
});
export default function App() {
let renderItem = React.useCallback(({ item }: { item: Item }) => {
return <FlatlistRow {...item} toggleId={toggleId} />;
}, []);
return (
<View style={{ paddingTop: 64 }}>
<FlatList data={data} renderItem={renderItem} />
</View>
);
}
const FlatlistRow = React.memo(function FlatlistRow({
id,
name,
toggleId,
}: {
id: string;
name: string;
toggleId: (id: string) => void;
}) {
let isSelected = useIsItemSelected(id);
return (
<TouchableOpacity
style={{
padding: 8,
flexDirection: "row",
justifyContent: "space-between",
}}
onPress={()=> toggleId(id)}
>
<Text>{name}</Text>
{isSelected && <Text>Selected</Text>}
</TouchableOpacity>
);
});
Results of using context selectors
These are the best results yet with render times of 2ms. Visually it's really clear that the only thing that is rerendering is the item that is toggled.
Only the toggled item is rerendered in ~2ms - blazing!
Wrapping up
At this point our implementation is pretty much fully optimized. The render times are down substantially and rerenders are isolated to a minimal set of components.
Here are some key takeaways:
- Using context might seem like a good idea at first, but it can actually hurt performance because it makes it really easy to break memoization.
React.memo
for row components is the most important step to optimizing a Flatlist because it reduces rerendering.- Context selectors let us use context without breaking memoization.
For brownfield apps, it might be a much larger lift to reimplement a list like this.
The prevalence of hook based APIs make it really easy to break memoization like we did in our example above.
For these cases, the windowSize
prop provides the easiest win for performance by reducing the number of virtualized items that are rerendering.
Adjusting this value to 5 instead of the default will bring down render times without needing to adjust implementation.