← All articles

Article / React Native / Performance

React Native FlatList performance: find the cause before tuning props

Diagnose slow React Native lists, build predictable rows, and measure rendering, scrolling, and memory without copying a magic configuration.

Paper rows pass through a graphite phone frame beside a brass measuring caliper.
Keep the visible work small, and measure the cost before changing the window.

A product list scrolls smoothly until someone selects an item. A feed responds to taps but flashes empty space during a fast fling. Another screen works well on a simulator and stutters on an older Android phone.

Those are three different problems. Applying the same FlatList configuration to all three can trade one problem for another.

This guide walks through a list with selectable rows, shows where unnecessary rendering comes from, and explains how to test a change. You will need an existing React Native application and familiarity with components, props, and state. The example uses React Native's built-in FlatList; you do not need to install another list library.

Start with one reproducible symptom

Write down the action that feels slow before opening the component. “The list is laggy” gives you too many possible causes. “Selecting one product pauses the selection feedback while the screen is otherwise stationary” gives you an interaction to record and repeat.

Use a small investigation note:

text
Screen: Product picker
Data: The same saved set of products for every run
Device: Record model, OS, and build mode
Action: Open screen, wait for loading, select one visible row
Observed problem: Selection feedback is delayed
Question: Which components render during this selection?
Change under test: None yet

Keep network loading out of this first reproduction if the problem occurs after loading. A saved fixture makes comparison easier because changing server responses cannot silently change the work being measured. Keep the fixture realistic: short and long titles, missing thumbnails if the real screen has images, and enough rows to reproduce the problem.

The number of records alone does not describe the workload. A thousand inexpensive text rows and a thousand rows with several large images, nested effects, and rich formatting are different experiments.

Separate rendering work from scrolling work

Use React Native DevTools to inspect React activity in a development build. Then validate perceived responsiveness in a release build on a representative device. Development diagnostics are useful for finding causes, but development overhead makes them unsuitable as final performance evidence. See the official debugging tools and performance overview.

A practical starting point is to classify the symptom:

Symptom First question Useful experiment
Selecting one row feels slow Do unrelated rows render? Record that selection in the React profiler
Fast scrolling exposes blank space Can the list prepare cells quickly enough? Simplify row content before changing batch settings
Taps wait while data is transformed Is synchronous JavaScript blocking interaction? Measure the transformation separately from rendering
Images appear late or cause spikes Are image dimensions and source sizes appropriate? Repeat with a small local image fixture
Memory grows while browsing What remains allocated after leaving the screen? Inspect repeated navigation and retained resources

This classification is a working hypothesis. It is not a diagnosis by itself. For example, blank space can come from slow row preparation, and slow row preparation can come from expensive application code. Increasing the render window may hide the symptom on your test device while increasing memory use elsewhere.

Record a short trace, make one change, and repeat the same action. If you change row structure, image handling, and window settings together, you will not know which change mattered.

Build a row with a small, explicit interface

Here is a complete selectable list component. It deliberately uses a fixed-height, single-line presentation so the layout calculation later in the article has an exact meaning. Product IDs must be unique strings, and each product must have a name and a preformatted priceLabel.

jsx
import { memo, useCallback, useState } from 'react';
import {
  FlatList,
  Pressable,
  StyleSheet,
  Text,
  View,
} from 'react-native';

const ROW_HEIGHT = 88;
const SEPARATOR_HEIGHT = 1;

const ProductRow = memo(function ProductRow({
  id,
  name,
  priceLabel,
  selected,
  onSelect,
}) {
  return (
    <Pressable
      accessibilityRole="button"
      accessibilityLabel={`${name}, ${priceLabel}`}
      accessibilityState={{ selected }}
      onPress={() => onSelect(id)}
      style={({ pressed }) => [
        styles.row,
        selected && styles.selected,
        pressed && styles.pressed,
      ]}
    >
      <View style={styles.copy}>
        <Text numberOfLines={1} style={styles.name}>{name}</Text>
        <Text numberOfLines={1} style={styles.price}>{priceLabel}</Text>
      </View>
      <Text style={styles.action}>{selected ? 'Selected' : 'Select'}</Text>
    </Pressable>
  );
});

function Separator() {
  return <View style={styles.separator} />;
}

function keyExtractor(item) {
  return item.id;
}

function getItemLayout(_data, index) {
  return {
    length: ROW_HEIGHT,
    offset: (ROW_HEIGHT + SEPARATOR_HEIGHT) * index,
    index,
  };
}

export default function ProductList({ products }) {
  const [selectedId, setSelectedId] = useState(null);
  const selectProduct = useCallback(id => setSelectedId(id), []);
  const renderItem = useCallback(({ item }) => (
    <ProductRow
      id={item.id}
      name={item.name}
      priceLabel={item.priceLabel}
      selected={item.id === selectedId}
      onSelect={selectProduct}
    />
  ), [selectedId, selectProduct]);

  return (
    <FlatList
      data={products}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      extraData={selectedId}
      ItemSeparatorComponent={Separator}
      getItemLayout={getItemLayout}
      ListEmptyComponent={<Text style={styles.empty}>No products found.</Text>}
    />
  );
}

const styles = StyleSheet.create({
  row: {
    height: ROW_HEIGHT,
    paddingHorizontal: 16,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
    backgroundColor: '#fff',
  },
  copy: { flex: 1, minWidth: 0 },
  name: { fontSize: 16, color: '#202020' },
  price: { marginTop: 4, fontSize: 14, color: '#555' },
  action: { fontSize: 13, color: '#202020' },
  selected: { backgroundColor: '#e9f3ec' },
  pressed: { opacity: 0.7 },
  separator: { height: SEPARATOR_HEIGHT, backgroundColor: '#ddd' },
  empty: { padding: 16 },
});

The row receives the values it displays, a selection boolean, and one callback. It does not receive the whole screen state or an object containing unrelated settings. When selection moves between two products, the previous and new selections receive changed selected props. The remaining rows can keep equivalent props.

renderItem changes when selectedId changes. That is intentional: it needs the current selection. The useful boundary is the memoized row, where unchanged props can avoid repeated component work. An inline onPress inside that row does not defeat the row's own memo boundary; it is created when the row actually renders.

extraData makes the list's dependency on selection explicit. FlatList uses shallow prop comparison, so state used to render rows must be represented in props that change. Here renderItem also changes, but keeping the selection dependency visible makes the design easier to follow. FlatList reference

Understand what memoization does and does not fix

memo compares props using Object.is by default. A fresh object or function is a changed prop even when its contents look the same. Local state and context updates can still render a memoized component. React also treats memoization as an optimization rather than a correctness guarantee. React memo reference

For this example, replacing onSelect={selectProduct} with onSelect={id => selectProduct(id)} inside renderItem would give each row a newly created callback whenever that render function runs. Passing settings={{ selectedId }} would similarly introduce a new object and make every row depend on the entire selection value.

Do not compensate with a comparator that ignores changed callbacks or displayed fields. That can preserve stale behavior. Start with a small prop interface and profile the result. If your project uses React Compiler, inspect the optimization it already provides before adding manual memoization everywhere.

Stable keys solve a different problem. They identify which item a row represents as data changes. An array index is a poor identity for a list that can reorder, insert, or remove products. A stable product ID keeps identity connected to the product. Keys do not make expensive row logic inexpensive.

The parent should also avoid rebuilding product objects on every unrelated update. Perform necessary normalization when the input data changes. If you derive a filtered array during rendering, use memoization only when measurement justifies it, and include every input in its dependencies. Mutating the original array to keep its reference stable is not a performance fix; it can prevent the UI from noticing real changes.

Only calculate layout when the dimensions are true

The example's first row starts at offset zero. The second starts after one 88-point row and one 1-point separator, at 89. The third starts at 178. length describes the row; the offset includes preceding separators.

This works because the component defines those dimensions. It has no list header or vertical content padding to account for, and the visible text is constrained to one line per field. If you add a header, margins, different row types, or expanded descriptions, revisit the calculation. Returning a convenient constant for variable-height rows gives the list incorrect information.

There is also a product decision hidden in fixed height. Large text settings and translations may need more space than this compact presentation allows. Test them. If users need full multiline content, allow flexible rows and remove getItemLayout unless you have an accurate alternative. Do not disable font scaling merely to preserve this optimization.

For a real product picker, a truncated title might be acceptable if the full title is available to assistive technology and in the product detail view. For messages, medical instructions, or content whose wording must be read in full, truncation may be the wrong design. Layout optimization follows the content requirement.

Tune one list setting against one observed problem

After reducing unnecessary row work, consider the rendering window and batch settings. Their values describe tradeoffs, not quality levels. React Native documents these options in its FlatList configuration guide.

Setting What to investigate before changing it
initialNumToRender Does the initial batch cover the visible screen for your actual row size?
maxToRenderPerBatch Would preparing more rows together reduce blanks, or delay interaction?
updateCellsBatchingPeriod Is the delay between batches appropriate for the observed scrolling pattern?
windowSize Would more offscreen coverage help enough to justify additional mounted content?
removeClippedSubviews Does detaching offscreen native views help on this platform without visual defects?

windowSize is measured in viewport lengths, not rows. Avoid treating it as an item count. Also avoid assuming that clipping offscreen views frees all associated memory; detaching a view is not the same as deallocating it.

Keep a small experiment log with the original value, changed value, device, repeated action, and observed tradeoff. If a larger batch removes blanks but makes taps noticeably late, the experiment has revealed a cost. It has not produced a universally better configuration.

Check the work outside the row component

A profiler trace with inexpensive React rendering does not rule out a slow screen. Image decoding, oversized assets, native layout, synchronous parsing, and application side effects still deserve attention.

If each thumbnail displays at a small size, inspect whether the server is sending a much larger source than necessary. Give images known display dimensions so loading does not repeatedly change layout. Test cold and warm loads separately; a cached image can hide work that new users experience.

Look for subscriptions, timers, or fetches inside rows. Virtualization means rows may leave the rendered window and later return. Business data and persistent selection should not depend on a row staying mounted forever. Keep durable state at the appropriate screen or data layer, and clean up resources when their owner unmounts.

If filtering a large dataset blocks input before the list even receives its props, changing FlatList batch sizes cannot fix that transformation. Measure the filter, reduce repeated work, and consider whether the data should be filtered elsewhere. Choose the next change based on the trace, not the component name nearest the symptom.

Verify the change with a repeatable matrix

Repeat the original action on the same data and device. Then check the cases that the optimization could have made worse.

Check What to look for
Select, deselect through your product flow, and change selection Correct feedback and no stale row state
Fling down, then reverse direction Blank regions, delayed images, and lost selection
Insert, remove, and reorder products Identity follows IDs rather than positions
Use large text and long translated labels Readable content and usable controls
Leave and reopen the screen several times Cleanup, restored state, and unexpected retained work
Test representative Android and iOS devices Platform-specific clipping or layout differences

Record observations separately from measurements. “Taps felt more responsive on this device” is a useful observation. A claim about reduced render duration needs an actual trace. A claim about memory needs an appropriate measurement. This example includes no invented benchmark or promised frame-rate gain.

Keep the smallest change that addresses the reproduced problem. If the trace still points to the list implementation after row and data work are under control, compare another list implementation using this same fixture and interaction matrix. That gives you a reason to migrate and a way to tell whether the migration helped.

End of note

← Back to articles

Search articles and tips

Search in