← Blog

Why My Home List Rendered Everything at Launch

9 min read
Watch the walkthrough on YouTube

One item in a home feed can contain a banner carousel, a category grid, or an entire product rail. A setting that looks reasonable for small rows can therefore request most of a section-based feed at once.

Keep initial work small, then diagnose unnecessary row renders before optimizing them.

This is Lists lesson 1 of 3. We cover initial mounting and later row updates together. Next comes reliable pagination, followed by safe list navigation. The previous lesson completed Search.

The video is an illustration with sample data, sample logs, and English synthetic narration. The code below is a reviewed integration example, not executed or device-tested. We do not claim a measured speedup, a frame-rate improvement, or an exact count of mounted native views.

An outer item can be a whole section

The source home screen documents roughly eleven sections and changes initialNumToRender from 20 to 3. The actual checked-in configuration uses windowSize={7} and maxToRenderPerBatch={4} alongside that initial count. Git blame attributes the configuration change and explanation to Ibrahim Fathi in commit e8b11503.

Our schematic uses twelve sample sections. With an initial item target of twenty, the list can request every section in that dataset. A smaller target can reduce the amount requested initially. The highlighted boxes in the video represent that initial target. They are not a profiler recording, and they do not show a permanent mounted-item limit.

We deliberately avoid repeating the source comment’s stronger claim that virtualization was entirely disabled. Configuring a large initial render does not globally turn FlatList into a nonvirtualized list.

Separate the three controls

Setting What the viewer should distinguish
initialNumToRender Initial item count; choose enough content for the visible area
windowSize Render region measured in viewport units rather than rows
maxToRenderPerBatch Additional items prepared in a batch

The case-study values are:

<FlatList
  data={sections}
  renderItem={renderSection}
  initialNumToRender={3}
  windowSize={7}
  maxToRenderPerBatch={4}
/>

sections and renderSection are application-supplied values here. The source also sets updateCellsBatchingPeriod={50}; that interval is a separate control. These values are not recommendations for every list.

A window of seven represents roughly the visible viewport plus three above and three below when content is available. Near the beginning, there may be nothing above. Smaller windows can use less memory but expose blank areas; larger batches can fill sooner but occupy JavaScript longer. A low initial count can also leave the first screen underfilled. React Native documents these configuration tradeoffs.

Three tall sections and three small product rows are different layouts. Calibrate for real content, screen sizes and text settings. Later batches and the render window can add more items beyond the initial target. Initial items may also be retained for scroll-to-top behavior; see the FlatList reference.

A later row render is a separate question

Suppose products are already visible and the user edits an unrelated note above the list. Lowering the initial count will not explain why a particular row runs again.

Inspect what the row receives. A new callback or newly allocated object can change a prop’s identity even if its purpose or displayed content appears unchanged. A row can also update for its own state, a context it consumes, or a store subscription. The surrounding list may already avoid some updates, so do not assume that every parent update reaches every row.

The existing project logger records render attempts and changed prop keys. It is gated by __DEV__ and an enable flag that is currently false. The sample video uses its style to illustrate a changed onPress reference; these lines are not captured runtime output.

[Render #1 — mount] ProductRow
[Render #2] ProductRow — changed: [onPress]

This identifies something to investigate. It does not measure render duration. Development checks can repeat rendering, and attempts can differ from committed UI updates. An empty changed-props list does not prove that internal state was the cause, even though the original logger’s fallback label suggests that.

The published diagnostic below is an adaptation: it compares props in an effect after commits and calls its Hooks unconditionally. I labelled it Commit effect rather than Render on purpose, and development effect replays can add entries. It cannot reveal abandoned render attempts or establish why a render occurred. Remove diagnostic logging before measuring release behavior.

Fix the specific unstable boundary

In the before example, each parent render creates a new function for the memoized row’s onPress prop. In the after example, the row receives a stable onOpen function and attaches its own product ID when pressed. The parent’s renderItem is stable too.

A memoized component compares props and can usually avoid an unrelated parent-driven render when they are unchanged. Its own state and consumed context can still update it. Memoization is an optimization. It guarantees nothing about correctness. React’s memo reference describes these limits.

The callback depends on the supplied onSelect, so its dependency list includes that function. If onSelect changes upstream, this callback changes too. Removing that dependency merely to force stability could capture outdated behavior. The useCallback reference explains that dependency contract.

An arrow function inside the row is not automatically a bug. Here it is created when the row itself renders, rather than arriving as a new prop from every unrelated parent update. Likewise, stable keys preserve item identity across changes; they do not memoize a component by themselves.

Preserve real changes

Keep the references of products that did not change. When a price changes, create a new object for that product and a new array containing it. Mutating the existing object in place can hide the update from shallow comparisons. Rebuilding every object for an unrelated note edit creates the opposite problem: apparently changed props without changed product content.

Use the store selectors appropriate to your state library. A row should subscribe to what it needs, rather than the entire store without a reason. The exact subscription behavior depends on that library and selector equality rules.

Before/after integration example

Use one exported screen at a time with the same dataset. Supply initialItems based on the height of these product rows; do not copy the three-section count into a short-row list. The app supplies products and onSelect. Strings need the app’s localization layer. This example does not include navigation or fetch setup.

// Reviewed React Native integration example, not device-tested.
// onSelect and products are supplied by the app; strings need localization.
import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
import {FlatList, ListRenderItemInfo, Pressable, Text, TextInput, View} from 'react-native';

type Product = Readonly<{id: string; name: string; priceLabel: string}>;
type RowsProps = {
  products: readonly Product[];
  onSelect: (id: string) => void;
  initialItems: number; // Calibrate for these rows and the actual viewport.
  debug?: boolean;
};

// Teaching adaptation: compare props after commits, with unconditional Hooks.
// The source logger counts render attempts; this diagnostic has different timing.
// Effect replays in development can add entries. This does not measure cost.
function useCommittedPropLogger(
  label: string,
  props: Record<string, unknown>,
  enabled: boolean,
) {
  const previous = useRef<Record<string, unknown> | null>(null);
  const count = useRef(0);
  useEffect(() => {
    if (!__DEV__ || !enabled) {
      previous.current = null;
      count.current = 0;
      return;
    }
    const prior = previous.current;
    const keys = new Set([...Object.keys(prior ?? {}), ...Object.keys(props)]);
    const changed = prior
      ? [...keys].filter(key =>
          Object.prototype.hasOwnProperty.call(prior, key) !==
          Object.prototype.hasOwnProperty.call(props, key) ||
          !Object.is(prior[key], props[key]))
      : [];
    count.current += 1;
    console.debug(`[Commit effect #${count.current}] ${label}`, {
      changed,
      note: prior ? 'No changed props does not identify the cause.' : 'First observed effect.',
    });
    previous.current = {...props};
  }); // Intentionally inspect every commit. Keep this diagnostic temporary.
}

const BeforeRow = memo(function BeforeRow({product, onPress, debug}: {
  product: Product;
  onPress: () => void;
  debug: boolean;
}) {
  useCommittedPropLogger(`BeforeRow:${product.id}`, {product, onPress}, debug);
  return (
    <Pressable accessibilityRole="button" onPress={onPress}>
      <Text>{product.name}</Text><Text>{product.priceLabel}</Text>
    </Pressable>
  );
});

const MemoRow = memo(function MemoRow({product, onOpen, debug}: {
  product: Product;
  onOpen: (id: string) => void;
  debug: boolean;
}) {
  useCommittedPropLogger(`MemoRow:${product.id}`, {product, onOpen}, debug);
  return (
    // This function is created inside the row only when the row renders.
    <Pressable accessibilityRole="button" onPress={() => onOpen(product.id)}>
      <Text>{product.name}</Text><Text>{product.priceLabel}</Text>
    </Pressable>
  );
});

const keyExtractor = (product: Product) => product.id;

export function BeforeRows({products, onSelect, initialItems, debug = false}: RowsProps) {
  const [note, setNote] = useState('');
  return (
    <View style={{flex: 1}}>
      <TextInput value={note} onChangeText={setNote} accessibilityLabel="Unrelated note" />
      <FlatList data={products} keyExtractor={keyExtractor}
        initialNumToRender={initialItems}
        renderItem={({item}) => (
          <BeforeRow product={item} debug={debug} onPress={() => onSelect(item.id)} />
        )} />
    </View>
  );
}

export function AfterRows({products, onSelect, initialItems, debug = false}: RowsProps) {
  const [note, setNote] = useState('');
  // Correct dependencies; if the app supplies a new onSelect, this must change.
  const openProduct = useCallback((id: string) => onSelect(id), [onSelect]);
  const renderItem = useCallback(({item}: ListRenderItemInfo<Product>) => (
    <MemoRow product={item} onOpen={openProduct} debug={debug} />
  ), [openProduct, debug]);
  return (
    <View style={{flex: 1}}>
      <TextInput value={note} onChangeText={setNote} accessibilityLabel="Unrelated note" />
      <FlatList data={products} keyExtractor={keyExtractor}
        initialNumToRender={initialItems} renderItem={renderItem} />
    </View>
  );
}

// Preserve unchanged product references; create a new object for changed data.
export function withPrice(products: readonly Product[], id: string, priceLabel: string): Product[] {
  return products.map(product =>
    product.id === id && product.priceLabel !== priceLabel
      ? {...product, priceLabel}
      : product,
  );
}

Verification procedure for your app

  1. Compare the same device, dataset, row content and interaction. Change one setting at a time.
  2. Check the first visible screen for underfilled areas with larger text and multiple screen sizes.
  3. Scroll quickly in both directions. Check gaps, delayed presses and memory as well as visual smoothness.
  4. Enable temporary prop diagnostics. Edit an unrelated note and inspect which keys change in the affected rows.
  5. Apply a targeted prop-identity correction. Repeat the same interaction and profile if you need a duration comparison.
  6. Change a real product price with an immutable update. Confirm the visible row updates and the press action still opens the correct ID.
  7. Check selection, language changes, RTL and accessibility behavior. Optimizations must preserve these updates.
  8. Remove debug logging and assess responsiveness in a representative release build. Record RN/React versions, device, build mode and dataset with any performance result.

These are integration steps. I did not run them in this production task. The reviewed source app declares React Native 0.79.6, which is provenance rather than a compatibility test of this example.

Optional: the FlashList assessment

The project’s historical assessment records earlier white-gap problems with a heterogeneous, nested feed and a return to FlatList. Its current decision banner says FlashList was not adopted. The superseded recommendation elsewhere in that document is not current project guidance, and the document is not a general benchmark of the two libraries. This lesson does not require switching list libraries.

What comes next

Day 4 completes Reliable Pagination: Load More, Retry Safely, and Refresh: requesting pages, avoiding duplicates, handling failure, and resolving refresh versus late load-more responses. Day 5 closes Lists with safe navigation and index/measurement failures. Their links will be added when published.

Comments

No account needed — your first comment creates an anonymous name and a secret key.

  1. Loading comments…