Optimising List Performance
Introduction
Rendering lists is one of the more common pain points of React Native, as the inherent performance limitations of the rendering engine and JS bridge make it difficult to achieve complex, high performing 60 FPS lists. There is still a bunch of optimisations we can make to build performant lists, and it is important to implement each of these to prevent any frame drops of performance issues.
General List Tips
Use virtualised lists - not Array.map
In essence, a virtualised list is a list where items are only rendered when in the viewport. Virtualization massively improves memory consumption and performance of large lists by maintaining a finite render window of active items and replacing all items outside of the render window with appropriately sized blank space.
By rendering large lists using Array.map
RN will be forced to render items even when they are outside the viewport, and this can lead to performance issues in larger lists. To address this, there are core modules exposed that will handle virtualisation out of the box. The primary component you will use is FlatList
.
If you require support for sections check out SectionList - all these tips still apply.
Avoid the use of styled-components
with list elements
styled-components
will repeat style computation for every item separately on each render. This can cause performance issues, particularly on large or style heavy lists. Instead use style objects declared with StyleSheet
, this ensures styles are only computed once per render.
Avoid using flexible reusable components
We have a number of reusable components such as <Box />
, <Card />
and <Icon />
. These components are very large, and designed to handle a wide variety of use cases. As a result they are less performant and contain a lot of styling and logic that our list items may not need. They also rely on styled-components
which hurts their performance. Instead when creating big list elements use core RN elements styled with StyleSheet
.
Optimising FlatList
From the docs, FlatList
is "A performant interface for rendering basic, flat lists". It is important to follow best practices when implementing FlatList
to get the most out of the component.
As a few of these tips will cover, one of the most important things to remember when implementing FlatList
with large and expensive lists is that it is a PureComponent, which is the class component equivalent of React.memo
. This means it will run a shallow equality comparison to determine if it should re-render.
Don't use anonymous inline arrow functions for props
Two key props expected by FlatList
are renderItem
and keyExtractor
. Both of these props receive functions, so it's important to ensure they are properly optimised to prevent performance issues. If either renderItem
or keyExtractor
do not require component state, they can be declared in the outer scope. Otherwise they should be wrapped in useCallback
. This means ensuring props are referentially stable to prevent unnecessarily re-rendering the list.
// BAD
const ListComponent = () => {
return (
<FlatList
data={DATA}
renderItem={({ item }) => <Item item={item} />}
keyExtractor={(item) => item.id}
/>
);
};
// GOOD
const keyExtractor = (item) => item.id;
const renderItem = ({ item }) => <Item item={item} />;
const ListComponent = () => {
return (
<FlatList data={DATA} renderItem={renderItem} keyExtractor={keyExtractor} />
);
};
// ALSO GOOD
const ListComponent = () => {
const keyExtractor = useCallback((item) => item.id, []);
const renderItem = useCallback(({ item }) => <Item item={item} />, []);
return (
<FlatList data={DATA} renderItem={renderItem} keyExtractor={keyExtractor} />
);
};
This ensures a stable reference between renders, meaning the equality comparison won't evaluate the prop to have changed.
While these two props are the main functions declared on any FlatList
implementation, this same rule applies to all function props.
Ensure a stable memory reference for list data
The data
prop expects an array of elements so it's important to ensure that the value passed to it isn't being recreated each render causing the list to re-render. Because JS is comparing the reference of the current and next array, and not the value, even if the items within the data haven't changed it will still fail the equality check. List data should be declared in the outer scope if possible, otherwise it will need to be correctly memoised.
// BAD
const ListComponent = () => {
// This value will be recreated every render
const data = [
{ name: "Steve", id: 123 },
{ name: "Mike", id: 456 },
];
return <FlatList {...props} data={data} />;
};
// GOOD
const data = [
{ name: "Steve", id: 123 },
{ name: "Mike", id: 456 },
];
const ListComponent = () => {
return <FlatList {...props} data={data} />;
};
// ALSO GOOD
const ListComponent = ({ entrantNames }) => {
const data = useMemo(() => {
return entrantNames.map((name) => mappingLogic(name));
}, [entrantNames]);
return <FlatList {...props} data={data} />;
};
In the final example, where data is calculated based on props, it is important to consider the chain of that data from start to finish. If entrantNames
is different each render it will defeat the memoisation of the final data value because useMemo
will run regardless of whether or not the internal values have changed.
Use basic, light components
The more computationally expensive and heavy list components are, the more performance may suffer. Sometimes this is unavoidable, but it doesn't hurt to push back on design a little and suggest simplifying list components to maintain a high standard of performance. Some examples of this might include intense layout animations, complicated render logic or image processing.
Use the getItemLayout
prop
If all list components will have the same height, and it's possible to know that ahead of time, the getItemLayout
prop allows you to provide a fixed height for list items. This means the FlatList
no longer needs to calculate layout asynchronously. This is another instance where it is good to talk to design and see if they can settle on something simpler but better performing.
Use the initialNumToRender
prop
This prop allows you to set an initial number of items for the list to render. Doing this can provide a big performance boost on render, but it's important to make sure it's not set too low and causing blank spots on smaller lists.
Take advantage of component props to prevent list re-renders
It's a common pattern to conditionally render different components, or null
, if the list data is loading or undefined. Here is an example:
const Component = () => {
const { data, loading } = useQuery({
useLazyQueryFn: useRacingLazyQuery,
});
if (data.length === 0) {
return null;
}
if (loading) {
return <LoadingComponent />;
}
return <FlatList {...props} data={data} />;
};
The easy-to-miss issue with this, is that as the component cycles through those various bits of state, you mount and unmount all the different components, including the list. If the component also has a refetch mechanism you will unmount the list, and cascade through those states each time.
Fortunately, the FlatList
component comes with a number of props that take care of these different states, and they should be utilised when possible to ensure the list isn't re-rendering unnecessarily. To improve on the above example we could do something like this:
const Component = () => {
const { data, loading } = useQuery({
useLazyQueryFn: useRacingLazyQuery,
});
return (
<FlatList
{...props}
data={data}
ListEmptyComponent={loading ? LoadingComponent : ListEmptyComponent}
/>
);
};
This is a simplified example, and at times it may take some finessing to achieve this but for the majority of use cases, the props exposed by FlatList
will accomplish the UI you need.
Be sure to read the docs and check out each prop when working on lists.
Be careful when applying memoisation to list components
Forcing React to re-check every list items' props for equality can result in expensive calculations running regularly. While it is good practice to memoise list components, it's important to make sure that memoisation is as optimised as possible by only checking props that will definitely change, and keeping component props as simple as possible.
For a contrived example, imagine you had implemented a custom equality comparison function to check for an object property:
const isEqual = (prevProps, nextProps) => {
const prevValue = expensivelyTraverseDeeplyNestedObject(prevProps);
const nextValue = expensivelyTraverseDeeplyNestedObject(nextProps);
return prevValue === nextValue;
};
const ListItem = React.memo(
(props) => (
<Text>{props.data.nodes[0].races[0].entrants[0].entrantDetails.name}</Text>
),
isEqual
);
In a large list that regularly renders this component, React has to perform that object traversal (which is expensive, because of the name), only to access a primitive value and compare that.
Something like this can be avoided by only passing the essential props, performing expensive computation higher up, and implementing custom equality comparison with optimisation in mind. It isn't always essential to memoise your list item component either, and if your FlatList
is sufficiently optimised that is often enough to achieve a performant list.
Monitor network requests or other external side effects on a per list item basis
An often overlooked issue with list components comes from each component doing too much. As an example:
const ListItem = ({ id, entrantName }) => {
const { data, loading, error } = useQuery({
useLazyQueryFn: useGetEntrantDetails,
variablesFn: () => ({
id,
}),
auth: true,
});
useEffect(() => {
dispatch(actionWithName(entrantName));
if (id) {
trackEvent({ name: "ENTRANT_VIEWED", extraData: { entrantID: id } });
}
}, [entrantName, id]);
if (error) return null;
if (loading) return <LoadingComponent />;
return (
<View>
<Text>{entrantName}</Text>
<Text>{data.venue}</Text>
<Text>{data.seconds_to_start}</Text>
</View>
);
};
const EntrantsList = () => {
const { raceEntrants } = useRaceCardContext();
const renderItem = useCallback(
({ item }) => <ListItem id={item.id} entrantName={item.name} />,
[]
);
return <FlatList {...props} data={raceEntrants} renderItem={renderItem} />;
};
In this contrived example we can see that every item in the list is firing off a graphql query, dispatching an action to the store, and sending an event to analytics. This is an extremely common oversight and often leads to performance issues, as well as extraneous network requests.
There is no one correct solution to this, and it is often only solved from a broader architectural level. It's not uncommon to have to work with designers and backend teams to ensure components can stay lean, and if it is necessary to run side effects that they are optimised as best as possible.
Don't pass JSX as props
List components from React Native accept a number of different component props, for example ListFooterComponent
and ItemSeparatorComponent
. When passing these components it's important to make sure they maintain the same reference between renders.
// BAD
const EntrantsList = () => {
return <FlatList {...props} ListHeaderComponent={<ListHeaderComponent />} />;
};
// GOOD
const ListHeaderComponent = () => <View />;
const EntrantsList = () => {
return <FlatList {...props} ListHeaderComponent={ListHeaderComponent} />;
};
This is because the result of JSX is an instance of React.createElement
which will have a different memory reference each time is is called. When an equality check is performed on the component prop it will fail because it will evaluate <Component /> === <Component />
to be false
, even if the component has stayed the same.
Passing a React element declared in either the outer scope or with useMemo
will prevent the equality comparison failing, because it returns a plain object rather than the result of a call to React.createElement
like a component does.