How to avoid react-native FlatList re-render every time I select/de-select a row

Viewed 5314

See Reproducible demo or code.

I try to build a simple selectable list upon react-native FlatList. The feature is simple: each row in the FlatList is selectable. If a row is not yet selected, clicking the row will select it; If the row is already selected, clicking the row will de-select it.

The problem I am faced with is every time I click a row, all rows are re-rendered, which can be told from the log (such as "rendering item id=cameron.nguyen@example.com, selected=false"). I want to avoid the re-render of unchanged rows because the re-render can be expensive (in the case when I want to load very large images, or when the list is very long), but didn't figure out how to. I have tried both <MomoizedItem /> and <MemoizedItem2 /> by leveraging React.memo, but the former does not change the re-render behavior at all, while the latter makes the app behaves very weird, you can try by replacing <Item /> with one of them to see the effect. I also tried to use onClickCallBack over onClick, but it does not help either.

Am I using React.memo or React.useCallBack incorrectly? What can I do to meet the need? Thank you.

In case the code link expires, paste the code below:

import React, { memo, useEffect, useState } from "react";
import { SafeAreaView, FlatList, StyleSheet } from "react-native";
import Constants from "expo-constants";
import { Set } from "immutable";
import { Button, ListItem } from "react-native-elements";
import axios from "axios";

const Item = ({ id, title, avatarUrl, selected, onClick }) => {
  console.log(`rendering item id=${id}, selected=${selected}`);
  return (
    <ListItem
      title={title}
      leftAvatar={{ source: { uri: avatarUrl } }}
      containerStyle={[
        styles.item,
        { backgroundColor: selected ? "#6e3b6e" : "#f9c2ff" }
      ]}
      underlayColor="transparent"
      onPress={() => onClick(id)}
    />
  );
};
function itemEq(prevItem, nextItem) {
  return prevItem.id === nextItem.id && prevItem.selected === nextItem.selected;
}

// Does not make a difference, every time a row is clicked, all rows are re-rendered
const MemoizedItem = memo(Item);
// Make some difference but the behavior looks very weird. Try click around and see the log
const MemoizedItem2 = memo(Item, itemEq);

const Items = ({ data, selectedItems, onClick }) => {
  console.log("rendering items");
  // Replace <Item /> with <MemoizedItem /> or <MemoizedItem2 /> to see effect
  const _renderItem = ({ item }) => (
    <Item
      id={item.email}
      title={`${item.name.title} ${item.name.first} ${item.name.last}`}
      avatarUrl={item.picture.thumbnail}
      selected={selectedItems.has(item.email)}
      onClick={onClick}
    />
  );
  return (
    <FlatList
      data={data}
      renderItem={_renderItem}
      keyExtractor={item => item.email}
      extraData={selectedItems}
    />
  );
};

const App = () => {
  const [items, setItems] = useState([]);
  const [selectedItems, setSelectedItems] = useState(Set());

  useEffect(() => {
    const fetchData = async () => {
      console.log("fetching data");
      // Read 5 random users back
      // Each user is like this:
      // {
      //   "gender":"male",
      //     "name":{
      //   "title":"Mr",
      //       "first":"Harley",
      //       "last":"Zhang"
      // },
      //   "location":{
      //   "street":{
      //     "number":6470,
      //         "name":"Buckleys Road"
      //   },
      //   "city":"Palmerston North",
      //       "state":"Manawatu-Wanganui",
      //       "country":"New Zealand",
      //       "postcode":90911,
      //       "coordinates":{
      //     "latitude":"66.2907",
      //         "longitude":"-18.0881"
      //   },
      //   "timezone":{
      //     "offset":"+8:00",
      //         "description":"Beijing, Perth, Singapore, Hong Kong"
      //   }
      // },
      //   "email":"harley.zhang@example.com",
      //     "login":{
      //   "uuid":"6fda195e-3e63-476c-84d0-7c577c7b74f9",
      //       "username":"smallbear541",
      //       "password":"daisy1",
      //       "salt":"p6AmByUq",
      //       "md5":"0358f2385a9936369adc89b9233f037b",
      //       "sha1":"8decc817cf32ca6e58814502bb3e54152208c5b5",
      //       "sha256":"96ff7627348250646edd31238504271840a0cb6aaac293782f7eec1a6f884c07"
      // },
      //   "dob":{
      //   "date":"1987-12-07T13:00:15.244Z",
      //       "age":33
      // },
      //   "registered":{
      //   "date":"2008-01-23T19:33:01.672Z",
      //       "age":12
      // },
      //   "phone":"(474)-743-9612",
      //     "cell":"(539)-021-1315",
      //     "id":{
      //   "name":"",
      //       "value":null
      // },
      //   "picture":{
      //   "large":"https://randomuser.me/api/portraits/men/49.jpg",
      //       "medium":"https://randomuser.me/api/portraits/med/men/49.jpg",
      //       "thumbnail":"https://randomuser.me/api/portraits/thumb/men/49.jpg"
      // },
      //   "nat":"NZ"
      // }
      const results = await axios("https://randomuser.me/api/?results=5");
      setItems(results.data.results);
    };
    fetchData();
  }, []);

  const onClick = id => {
    const newSelectedItems = selectedItems.has(id)
        ? selectedItems.delete(id)
        : selectedItems.add(id);

    console.log(`selected items=${JSON.stringify(selectedItems, null, 2)}`);
    console.log(
        `new selected items=${JSON.stringify(newSelectedItems, null, 2)}`
    );
    setSelectedItems(newSelectedItems);
  }

  // Does not help
  const onClickUseCallBack = React.useCallback(
    id => {
      const newSelectedItems = selectedItems.has(id)
        ? selectedItems.delete(id)
        : selectedItems.add(id);

      console.log(`selected items=${JSON.stringify(selectedItems, null, 2)}`);
      console.log(
        `new selected items=${JSON.stringify(newSelectedItems, null, 2)}`
      );
      setSelectedItems(newSelectedItems);
    },
    [selectedItems]
  );

  return (
    <SafeAreaView style={styles.container}>
      <Items data={items} selectedItems={selectedItems} onClick={onClick} />
      <Button
        title="Print"
        onPress={() => console.log(`Printing selected items ${selectedItems}`)}
      />
    </SafeAreaView>
  );
};

export default App;

const styles = StyleSheet.create({
  container: {
    flex: 1,
    marginTop: Constants.statusBarHeight,
    marginHorizontal: 16
  },
  item: {
    backgroundColor: "#f9c2ff",
    padding: 20,
    marginVertical: 8
  }
});

expo package.json

{
  "main": "node_modules/expo/AppEntry.js",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "eject": "expo eject"
  },
  "dependencies": {
    "axios": "^0.19.2",
    "expo": "~36.0.0",
    "immutable": "^4.0.0-rc.12",
    "react": "~16.9.0",
    "react-dom": "~16.9.0",
    "react-native": "https://github.com/expo/react-native/archive/sdk-36.0.0.tar.gz",
    "react-native-elements": "^1.2.7",
    "react-native-web": "~0.11.7"
  },
  "devDependencies": {
    "@babel/core": "^7.0.0",
    "babel-preset-expo": "~8.0.0"
  },
  "private": true
}
1 Answers

MomoizedItem + onClickUseCallBack is a good start.

The re-rendering happens because of how onClickUseCallBack is implemented. See, you have selectedItems as the second parameter to useCallback, every time you select/deselect an item, selectedItems change, which causes a new onClickUseCallBack to be created an then passed to every item, which break memo and causes every item to re-render.

To fix this, you need to remove selectedItems from the second parameter of useCallback, then to avoid having stale state value (due to how closure works), use the functional form of the state setter to have fresh value.

  const onClickUseCallBack = React.useCallback(
    id => {
      setSelectedItems((selectedItems) => {
        const newSelectedItems = selectedItems.has(id)
          ? selectedItems.delete(id)
          : selectedItems.add(id);

        return newSelectedItems
      });
    },
    []
  );

Demo

https://snack.expo.io/HJXkV!Q48

Complete code

import React, { memo, useEffect, useState } from "react";
import { SafeAreaView, FlatList, StyleSheet } from "react-native";
import Constants from "expo-constants";
import { Set } from "immutable";
import { Button, ListItem } from "react-native-elements";
import axios from "axios";

const Item = ({ id, title, avatarUrl, selected, onClick }) => {
  console.log(`rendering item id=${id}, selected=${selected}`);
  return (
    <ListItem
      title={title}
      leftAvatar={{ source: { uri: avatarUrl } }}
      containerStyle={[
        styles.item,
        { backgroundColor: selected ? "#6e3b6e" : "#f9c2ff" }
      ]}
      underlayColor="transparent"
      onPress={() => onClick(id)}
    />
  );
};
function itemEq(prevItem, nextItem) {
  return prevItem.id === nextItem.id && prevItem.selected === nextItem.selected;
}

// Does not make a difference, every time a row is clicked, all rows are re-rendered
const MemoizedItem = memo(Item);
// Make some difference but the behavior looks very weird. Try click around and see the log
const MemoizedItem2 = memo(Item, itemEq);

const Items = ({ data, selectedItems, onClick }) => {
  console.log("rendering items");
  // Replace <Item /> with <MemoizedItem /> or <MemoizedItem2 /> to see effect
  const _renderItem = ({ item }) => (
    <MemoizedItem
      id={item.email}
      title={`${item.name.title} ${item.name.first} ${item.name.last}`}
      avatarUrl={item.picture.thumbnail}
      selected={selectedItems.has(item.email)}
      onClick={onClick}
    />
  );
  return (
    <FlatList
      data={data}
      renderItem={_renderItem}
      keyExtractor={item => item.email}
      extraData={selectedItems}
    />
  );
};

const App = () => {
  const [items, setItems] = useState([]);
  const [selectedItems, setSelectedItems] = useState(Set());

  useEffect(() => {
    const fetchData = async () => {
      console.log("fetching data");
      // Read 5 random users back
      // Each user is like this:
      // {
      //   "gender":"male",
      //     "name":{
      //   "title":"Mr",
      //       "first":"Harley",
      //       "last":"Zhang"
      // },
      //   "location":{
      //   "street":{
      //     "number":6470,
      //         "name":"Buckleys Road"
      //   },
      //   "city":"Palmerston North",
      //       "state":"Manawatu-Wanganui",
      //       "country":"New Zealand",
      //       "postcode":90911,
      //       "coordinates":{
      //     "latitude":"66.2907",
      //         "longitude":"-18.0881"
      //   },
      //   "timezone":{
      //     "offset":"+8:00",
      //         "description":"Beijing, Perth, Singapore, Hong Kong"
      //   }
      // },
      //   "email":"harley.zhang@example.com",
      //     "login":{
      //   "uuid":"6fda195e-3e63-476c-84d0-7c577c7b74f9",
      //       "username":"smallbear541",
      //       "password":"daisy1",
      //       "salt":"p6AmByUq",
      //       "md5":"0358f2385a9936369adc89b9233f037b",
      //       "sha1":"8decc817cf32ca6e58814502bb3e54152208c5b5",
      //       "sha256":"96ff7627348250646edd31238504271840a0cb6aaac293782f7eec1a6f884c07"
      // },
      //   "dob":{
      //   "date":"1987-12-07T13:00:15.244Z",
      //       "age":33
      // },
      //   "registered":{
      //   "date":"2008-01-23T19:33:01.672Z",
      //       "age":12
      // },
      //   "phone":"(474)-743-9612",
      //     "cell":"(539)-021-1315",
      //     "id":{
      //   "name":"",
      //       "value":null
      // },
      //   "picture":{
      //   "large":"https://randomuser.me/api/portraits/men/49.jpg",
      //       "medium":"https://randomuser.me/api/portraits/med/men/49.jpg",
      //       "thumbnail":"https://randomuser.me/api/portraits/thumb/men/49.jpg"
      // },
      //   "nat":"NZ"
      // }
      const results = await axios("https://randomuser.me/api/?results=5");
      setItems(results.data.results);
    };
    fetchData();
  }, []);

  // Does not help
  const onClickUseCallBack = React.useCallback(
    id => {
      setSelectedItems((selectedItems) => {
        const newSelectedItems = selectedItems.has(id)
          ? selectedItems.delete(id)
          : selectedItems.add(id);

        return newSelectedItems
      });
    },
    []
  );

  return (
    <SafeAreaView style={styles.container}>
      <Items data={items} selectedItems={selectedItems} onClick={onClickUseCallBack} />
      <Button
        title="Print"
        onPress={() => console.log(`Printing selected items ${selectedItems}`)}
      />
    </SafeAreaView>
  );
};

export default App;

const styles = StyleSheet.create({
  container: {
    flex: 1,
    marginTop: Constants.statusBarHeight,
    marginHorizontal: 16
  },
  item: {
    backgroundColor: "#f9c2ff",
    padding: 20,
    marginVertical: 8
  }
});
Related