Flatlist Rendering Techniques

June 9, 2024

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.

Code available here.

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

React Profiler showing 150 millisecond render times

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

React Profiler Results of Baseline Flatlist showing ~120 millisecond render times

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.

React Profiler Results of Baseline Flatlist showing ~60 millisecond render times

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:

React Profiler Results of Baseline Flatlist showing ~120 millisecond render times

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.

React Profiler Results of Baseline Flatlist showing ~2 millisecond render times

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.