Search code examples
react-nativereact-native-flatlist

FlatList is slow when the list is large (100s of items) - state change performance issue


FlatList is slow when the data set is large (100s of items). Each click on an item's checkbox takes around 2 seconds. Actually, the first click on a checkbox takes around a second; then, it increases by 20% till it reaches 2.5 seconds. It stays at 2.5 seconds for any clicks after that. It seems that the bottleneck is changing state. Is there any way to improve the performance? Please, see a simplified code, which demonstrates the issue below.

const TestScreen = () => {
    // Build a testing data set. 
    // In the actual application, the data set is stored in Redux. 
    let contactList = []; 
    for (let i = 0; i < 700; i++) { contactList.push({ mobileNo: i.toString() }); } 

    const [contactPhoneNumberList2, setContactPhoneNumberList2] = React.useState([]);

    const _onPress = async (item) => {
        // The following step takes no time because the number of selected items is very small! 
        const indexOfSelectedItem = contactPhoneNumberList2.findIndex(element => element === item.mobileNo);        

        // Get the current time BEFORE calling the state-changing step
        const now = new Date(); 

        // The user clicked on an already selected item -> let's unselect it (remove it from the list of selected items)
        if (indexOfSelectedItem > -1) {
            let abc = [ ...contactPhoneNumberList2 ]; 
            abc.splice(indexOfSelectedItem, 1);

            // Updating state takes around 2 seconds 
            // await is used below to help identify the bottleneck. It is NOT part of the production code. 
            await setContactPhoneNumberList2(abc); // <<< PROBLEM <<< 
            
            // Get the current time AFTER calling the state-changing step
            const now2 = new Date(); 
            console.log("state-changin took (milliseconds):", now2 - now, "onPress - IF - after state change", ); 
        }
        // The user clicked on an unselected item -> let's select it (add it to the list of selected items)
        else {
            // Updating state takes around 2 seconds 
            // await is used below to help identify the bottleneck. It is NOT part of the production code. 
            await setContactPhoneNumberList2(contactPhoneNumberList2.concat([item.mobileNo])); // <<< PROBLEM <<< 
            
            // Get the current time AFTER calling the state-changing step
            const now2 = new Date(); 
            console.log("state-changin took (milliseconds):", now2 - now, "onPress - IF - after state change", ); 
        }
    }; 

    const _renderItem = ({ item, index, }) => {
        // The following step takes no time because the number of selected items is very small! 
        const itemWasSelected = contactPhoneNumberList2.find(element => element === item.mobileNo);     

        return (
            <ListItem>
                <ListItem.CheckBox checked={itemWasSelected} onPress={() => _onPress(item)}/>
                <ListItem.Content>
                    <ListItem.Title>{item.mobileNo}</ListItem.Title>
                 </ListItem.Content>
            </ListItem>
        );
    };

    return (
        <FlatList
            data={contactList} 

            // just in case, the natural key (item.mobileNo) is repeated, the index is appended to it
            keyExtractor={(item, index) => item.mobileNo + index.toString()} 
        
            renderItem={_renderItem}

            // I tried the following, but none of them seems to helpful, in this case 
            // removeClippedSubviews={false}
            // initialNumToRender={5}
            // maxToRenderPerBatch={10} // good 
            // windowSize={10}

            // getItemLayout={_getItemLayout}
        />
    );  
}; 

export default TestScreen;

Solution

  • This problem isn't specific to FlatList or React Native. The issue is caused by your state change causing every item in the list to re-render on every change.

    There are multiple ways to solve this:

    1. The simplest solution would be to move state down to a deeper component, this would mean state updates are isolated to a single component, so you'll only have one render per state change.

      This likely isn't practical because I assume you probably need access to your selected state in TestScreen in order for it to be used for other purposes.

    2. Memoize the component rendered in renderItem. This will mean that with each state update TestScreen will render, but then only the items that have changed will re-render.

    Here's an example of how to achieve #2 and an Expo Snack example to play with:

    import * as React from 'react';
    import { Text, View, StyleSheet, FlatList, Button } from 'react-native';
    import Constants from 'expo-constants';
    const { useState, useCallback } = React;
    
    const contactList = new Array(700)
      .fill(0)
      .map((_, index) => ({ mobileNo: index.toString() }));
    
    export default function App() {
      const [selected, setSelected] = useState([]);
    
      const handlePress = useCallback((item) => {
        // Using the state setter callback will give you access to the previous state, this has two advantages:
        //   1. You don't need to pass anything to the dependency array of the `useCallback`, which means this
        //      function will remain stable between renders.
        //   2. This will prevent any issues with rapid actions causing lost state because of stale data.
        setSelected((previousState) => {
          const index = previousState.indexOf(item.mobileNo);
    
          if (index !== -1) {
            const cloned = [...previousState];
            cloned.splice(index, 1);
            return cloned;
          } else {
            return [...previousState, item.mobileNo];
          }
        });
      }, []);
    
      return (
        <FlatList
          data={contactList}
          keyExtractor={(item, index) => item.mobileNo + index.toString()}
          // Important: All props being passed to <Item> must be stable/memoized
          renderItem={({ item }) => (
            <Item
              // Only the `checked` property will change for the changed items
              checked={selected.includes(item.mobileNo)}
              onPress={handlePress}
              item={item}
            />
          )}
        />
      );
    }
    
    // Memo will prevent each instance of this component from re-rendering if all of its props stay the same.
    const Item = React.memo((props) => (
      <ListItem>
        <ListItem.CheckBox
          checked={props.checked}
          onPress={() => props.onPress(props.item)}
        />
        <ListItem.Content>
          <ListItem.Title>{props.item.mobileNo}</ListItem.Title>
        </ListItem.Content>
      </ListItem>
    ));
    

    I also noticed in your example you were awaiting the state setter calls. This is not required, React state setters are not asynchronous functions.