December 16, 2022
December 16, 2022
The FlatList
component is often used in React Native apps for rendering lists. It's easy to display a simple list, but using data in an unsuitable structure can lead to unnecessary re-renders and performance issues.
In this blog post, we have an example app with this issue. I invite you to a step-by-step journey from discovering and investigating the problem to resolving it.
Let's imagine an app for collecting anonymous satisfaction surveys from restaurant customers. Users can rate their experiences by sliding multiple sliders. At the bottom, near the "Submit" button, there is an icon that shows the average rate based on your answers. It's updated as you slide any of the sliders.
At the first glance, everything about the app looks great.
You can see the app code and tinker with it on Snack.
It's just a list, we said. We tested the app on a simulator, and it looked correct. What can go wrong? Let's publish it! 🚀
export const HomeScreen: React.FC = () => {const { answers, updateAnswer } = useSurveyAnswers();const renderItem: ListRenderItem<SurveyAnswer> = useCallback(({ item: answer }) => {const setValue = (newValue: number) => {updateAnswer({ id: answer.id, value: newValue });};return <FormListItem answer={answer} setValue={setValue} />;},[]);return (<SafeAreaView style={styles.container}><FlatListcontentContainerStyle={styles.contentContainer}data={answers}ListHeaderComponent={FormHeader}renderItem={renderItem}/><FormFooter style={styles.footer} /></SafeAreaView>);};
So we did it. 🔥
As soon as the first users received the new app and started using it, the first negative reviews started coming. Some users were complaining that the app was slow when sliding.
Fortunately, we have a slow smartphone waiting specifically for such occasions. Quick testing on this device confirms there is a problem. On some devices, UI is freezing, lagging, and unresponsive.
We suspect the issue is somewhere near the list, so we start digging here.
<FlatListcontentContainerStyle={styles.contentContainer}data={answers}ListHeaderComponent={FormHeader}renderItem={renderItem}/>
FlatList
has just 4 props passed in our app. Let's examine them closer to know if they can cause performance issues.
answers
is a list of survey answers objects received from the state and is passed to the data
prop of FlatList
;renderItem
is wrapped with useCallback
with no dependencies, so it will never change once set;contentContainerStyle
and ListHeaderComponent
) are defined once and never change.We know that answers
is the only prop that sometimes changes value. How is it affecting the app's behavior? Let's add two console.logs in src/pages/homeScreen.tsx
to investigate:
export const HomeScreen: React.FC = () => {// ...useEffect(() => {console.log("Data has changed");}, [answers]);const renderItem: ListRenderItem<SurveyAnswer> = useCallback(({ item: answer }) => {// ...console.log("Rendered item: ", answer.question);return <FormListItem answer={answer} setValue={setValue} />;},[]);
Running the app, sliding a single slider, and previewing logs give a hint at what is wrong.
The user changes only a single item value, so intuition tells us that only this item should re-render. Instead, all list items re-render.
Each sliding step results in the whole list re-render.
To further investigate why it is so, we can use React DevTools profiler. It's available in Flipper. Flamegraph tab brings special interest. It contains a timeline of React state changes. We can go step-by-step, and for each step, see a chart with clear indicators:
Green rectangles represent components that have rendered in this selected step. Gray rectangles represent components that have not rendered. Sadly, we don't see much gray here.
Everything green is re-rendering. It's bad.
React re-renders components when props or state change. By default, re-render propagates to all children components. Chart shows the FlatList
component re-rendered because of the data
prop change. It's where we pass answers
.
Here is an example of how the answers
array (data
prop) looks like:
const answers = [{"id": "0","value": 8,"question": "Wide selection in the menu"},{"id": "1","value": 6,"question": "Menu easy to read"},{"id": "2","value": 9,"question": "Order was taken promptly"},]
State in React is immutable, which means we can't update a single item in an array and use the same array again to render the next UI.
If we want to change the rate value
in a single answer, let's say "Menu easy to read" from 6 to 7, we have to create a copy of the old list with a single item different. It results in a new answers
object.
In console logs "Data has changed" represents answers
value change, but we see it each time user updates just one of his answers. It's because whenever user slides one list item slider new answers
list is passed as a new data
prop value, which results in re-rendering the whole FlatList
and all it's children.
However, there is a way to avoid those unnecessary re-renders and save CPU time. It will help slow smartphones to catch up with performance. 🐌
What if we could change the shape of the data
prop passed to FlatList
to have the same value always?
Inside data
, we can store just ids referring to "full" items. That would make it a string[]
list, not a list of complex objects with a value
field. When the user slides the slider and updates the value
, it won't change the item id
. The data
list will stay unchanged.
This will be passed as a data
prop:
const answersIdsList = ["0","1","2",]
Since now data stored in state
has this shape:
{dataIdsList: ["0", "1", "2"], // answersIdsListdataById: {"0": {"value": 8,"question": "Wide selection in the menu"},"1": {"value": 6,"question": "Menu easy to read"},// ...};}
To get the list of items without knowing too many details about item internals, we can use dataIdsList
. This object won't be updated when the details of some items change, which won't cause re-rendering.
To get details of a single object, we can use dataById
. For example, the updated list item component (NormalizedFormListItem
) uses it to get item details while having only id
as an input: state.dataById[id]
.
Old renderItem
needed a full answer
object as a param. It can be simplified:
// before fixesconst renderItem: ListRenderItem<SurveyAnswer> = useCallback(({ item: answer }) => {const setValue = (newValue: number) => {updateAnswer({ id: answer.id, value: newValue });};return <FormListItem answer={answer} setValue={setValue} />;},[]);
The new renderItem
that supports normalized data now as a param needs only item id (answerId
) to render complete NormalizedFormListItem
. There is also no need for useCallback
anymore because we can now move it out of the HomeScreen
component:
const renderItem: ListRenderItem<string> = ({ item: answerId }) => (<NormalizedFormListItem answerId={answerId} />;);
Here is Snack with the updated app code.
This concept of data shape is called normalization. Apart from performance, it improves data structure to eliminate data redundancy. You don't have to write the normalization boilerplate by yourself – libraries are helping with that, but for simplicity, we used no such library in this app.
If you want to know more about normalization and its advantages, we also have another blog post that goes deeper into this topic: Advanced Redux Patterns: Normalisation.
For podcast fans, in the BBS 12: Normalising app state episode, Wojciech and Łukasz discussed benefits, implementation tips, and potential pitfalls of normalizing app state in your React Native mobile app.
We can add console.logs as we did before to see data
changing or list items re-rendering.
Finally, only the changed item is re-rendered
Note that:
data
is now a list of ids, and it's not changed when the user slides the value
of any item.What does it look like in DevTools Flamegraph?
Gray here looks much better
Most rectangles are gray, which means those components didn't re-render. If we compare the total rendering duration, it's re-rendering on single list items updates in approximately 10x less time.
That's a huge improvement. We can call it a success! 🎉
Usually, re-rendering is not an issue in React. It becomes a problem only if components are re-rendering unnecessarily and excessively. A minor state update can result in an avalanche, so it's good to test on slow low-end smartphones. They do not forgive performance issues that are easy to miss on modern fast smartphones.
Having the issue in the app, we debugged and profiled it to find the performance issue source. Then we fixed it by normalizing the list data
state. We were also able to measure improvement and confirm resolving the issue.
Now you are ready to answer those questions:
FlatList
is sometimes re-rendering all list items if just a single item changed?FlatList
item instead of all when it's updated?January 10, 2023: Added a link to our new podcast episode about normalization.
If you liked this post, why don't you subscribe for more content? If you're as old-school as we are, you can just grab the RSS feed of this blog. Or enroll to the course described below!
Alternatively, if audio's more your thing why don't you subscribe to our podcast! We're still figuring out what it's going to be, but already quite a few episodes are waiting for you to check them out.