DEV Community

Cover image for React Native Nested FlatList: Child FlatList viewability callbacks are triggered before the child is actually visible
Bishoy Bishai
Bishoy Bishai

Posted on • Originally published at bishoy-bishai.github.io

React Native Nested FlatList: Child FlatList viewability callbacks are triggered before the child is actually visible

The Ghost in the Machine: Why React Native Nested FlatList Viewability Can Lie

Let's face it. Building complex UIs in React Native often leads us down the path of nested FlatLists. It's a powerful pattern for feeds, carousels, and intricate dashboards. We love FlatList for its performance optimizations, especially onViewableItemsChanged, which seems like a godsend for analytics, lazy loading, and dynamic content. But what if I told you that in a nested scenario, this very callback, designed to tell you what's visible, can sometimes fire for items that are, in a very real sense, still hidden?

I've been there. Debugging analytics reports that showed a bizarre number of "impressions" for items that logically couldn't have been seen by the user. Or worse, a feature that was supposed to lazily load content only when it was truly on screen, instead triggered a flurry of API calls as soon as the parent section scrolled into view, not the individual items. It feels like a bug, a phantom trigger, a ghost in the machine. And trust me, it's not just you. This is a common, albeit subtle, gotcha in React Native development.

The Problem: When "Viewable" Isn't "Visible"

Here's the core of the issue: FlatList's onViewableItemsChanged callback is an incredibly useful mechanism. It's based on the viewabilityConfig you provide, which defines thresholds for what constitutes "viewable" (e.g., "at least 50% of the item is visible"). This works beautifully for a standalone FlatList.

However, when you embed a horizontal FlatList (let's call it the "child list") inside an item of a vertical FlatList (the "parent list"), the child list's viewability logic can get a bit ahead of itself. The child FlatList internally determines viewability relative to its own scroll view bounds. So, if the parent FlatList renders an item that contains the child FlatList, the child FlatList might immediately consider its first few items "viewable" relative to its own container, even if that entire parent item is still off-screen, clipped by the vertical scroll view.

This isn't necessarily a bug in FlatList itself, but rather a nuance in how React Native's layout and rendering engine works, combined with the inherent clipping behavior of scroll views. The FlatList renders cells in advance to ensure a smooth scrolling experience. For a child FlatList, this means its initial items might be mounted and rendered (and thus internally considered "viewable" by the child list's own logic) long before the user scrolls the parent list enough for the child list to actually appear on screen.

Why This Matters (Beyond Just Annoyance)

  • Analytics Inaccuracy: Reporting false impressions throws off your user behavior metrics, leading to misinformed product decisions.
  • Performance Hit: Triggering API calls or heavy computations for items not yet seen wastes resources, drains battery, and can make your app feel sluggish.
  • Unexpected UI Behavior: Animations, data fetches, or state updates tied to viewability might fire at the wrong time, leading to janky or confusing user experiences.
  • Data Consistency: If you're paginating content or pre-fetching based on viewability, premature triggers can fetch too much data, too soon, or even fetch duplicates.

Practical Code Walkthrough: Seeing the Ghost in Action

Let's illustrate with a simplified example. Imagine a vertical list of categories, each containing a horizontal list of products.

// ChildProductList.tsx
import React, { useRef, useCallback } from 'react';
import { FlatList, View, Text, StyleSheet, Dimensions, ViewToken } from 'react-native';

const { width } = Dimensions.get('window');

interface Product {
  id: string;
  name: string;
}

interface ChildProductListProps {
  categoryName: string;
  products: Product[];
}

const ProductItem: React.FC<{ product: Product }> = ({ product }) => (
  <View style={styles.productItem}>
    <Text style={styles.productText}>{product.name}</Text>
  </View>
);

const ChildProductList: React.FC<ChildProductListProps> = ({ categoryName, products }) => {
  const viewabilityConfig = useRef({
    itemVisiblePercentThreshold: 50,
  }).current;

  const onViewableItemsChanged = useCallback(({ viewableItems }: { viewableItems: ViewToken[] }) => {
    viewableItems.forEach(item => {
      if (item.isViewable) {
        // THIS FIRES PREMATURELY!
        console.log(`[ChildList - ${categoryName}] Product ${item.item.name} IS NOW VIEWABLE`);
        // In a real app: trigger analytics, fetch data, etc.
      } else {
        console.log(`[ChildList - ${categoryName}] Product ${item.item.name} IS NO LONGER VIEWABLE`);
      }
    });
  }, [categoryName]);

  return (
    <View style={styles.childContainer}>
      <Text style={styles.categoryTitle}>{categoryName}</Text>
      <FlatList
        data={products}
        renderItem={({ item }) => <ProductItem product={item} />}
        keyExtractor={(item) => item.id}
        horizontal
        showsHorizontalScrollIndicator={false}
        onViewableItemsChanged={onViewableItemsChanged}
        viewabilityConfig={viewabilityConfig}
        // These can help, but don't fully solve the nested issue alone
        initialNumToRender={3}
        maxToRenderPerBatch={3}
        windowSize={5}
        removeClippedSubviews={true} // Be careful with this, can cause issues with complex layouts
      />
    </View>
  );
};

// ParentApp.tsx (Simplified)
import React from 'react';
import { FlatList, View, StyleSheet } from 'react-native';
// ... import ChildProductList

interface Category {
  id: string;
  name: string;
  products: Product[];
}

const categories: Category[] = [
  // ... generate some dummy data for categories and products
  { id: '1', name: 'Electronics', products: [{id: 'e1', name: 'Laptop'}, {id: 'e2', name: 'Phone'}, {id: 'e3', name: 'Tablet'}, {id: 'e4', name: 'Watch'}] },
  { id: '2', name: 'Books', products: [{id: 'b1', name: 'Fiction'}, {id: 'b2', name: 'Non-Fiction'}, {id: 'b3', name: 'Sci-Fi'}, {id: 'b4', name: 'Fantasy'}] },
  { id: '3', name: 'Apparel', products: [{id: 'a1', name: 'Shirt'}, {id: 'a2', name: 'Pants'}, {id: 'a3', name: 'Dress'}, {id: 'a4', name: 'Jacket'}] },
  { id: '4', name: 'Home Goods', products: [{id: 'h1', name: 'Lamp'}, {id: 'h2', name: 'Chair'}, {id: 'h3', name: 'Table'}, {id: 'h4', name: 'Rug'}] },
  // ... more categories to ensure scrolling
];

const App = () => {
  const onViewableItemsChangedParent = useCallback(({ viewableItems }: { viewableItems: ViewToken[] }) => {
    viewableItems.forEach(item => {
      if (item.isViewable) {
        console.log(`[ParentList] Category ${item.item.name} IS NOW VIEWABLE`);
      } else {
        console.log(`[ParentList] Category ${item.item.name} IS NO LONGER VIEWABLE`);
      }
    });
  }, []);

  return (
    <View style={styles.parentContainer}>
      <FlatList
        data={categories}
        renderItem={({ item }) => (
          <ChildProductList categoryName={item.name} products={item.products} />
        )}
        keyExtractor={(item) => item.id}
        onViewableItemsChanged={onViewableItemsChangedParent}
        viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  parentContainer: { flex: 1, marginTop: 50 },
  childContainer: { marginVertical: 10, backgroundColor: '#f0f0f0', padding: 10 },
  categoryTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 5 },
  productItem: {
    width: width * 0.4, // Make items wide enough to scroll
    height: 100,
    backgroundColor: '#add8e6',
    marginRight: 10,
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 8,
  },
  productText: { fontSize: 16, color: '#333' },
});
Enter fullscreen mode Exit fullscreen mode

If you run this, you'll likely observe that the [ChildList - CategoryName] Product X IS NOW VIEWABLE logs appear for items in categories before the parent FlatList has scrolled that category fully into view. The child FlatList thinks its items are visible because its own containing view has appeared, even if that containing view is still outside the parent's viewport.

The Solution: Double-Checking Visibility

Since FlatList viewability isn't aware of its parent's clipping, we need to add an extra layer of validation. Here are a couple of strategies:

1. Augment Child Viewability with Parent Context

The most robust solution involves explicitly checking if the parent FlatList item (the one containing your ChildProductList) is actually visible before acting on the child's onViewableItemsChanged callback.

This can be done by passing a prop from the parent list item to the child list, indicating its own viewability.

// In ParentApp.tsx's FlatList renderItem:
<FlatList
  // ...
  renderItem={({ item, index }) => (
    <ChildProductList
      categoryName={item.name}
      products={item.products}
      // Pass a prop indicating if this parent row is currently visible
      isParentRowViewable={categoriesViewability[item.id] || false}
    />
  )}
  keyExtractor={(item) => item.id}
  onViewableItemsChanged={({ viewableItems }) => {
    const newViewability = {};
    viewableItems.forEach(item => {
      newViewability[item.item.id] = item.isViewable;
    });
    setCategoriesViewability(newViewability); // Store viewability by ID
  }}
  viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>

// In ChildProductList.tsx:
interface ChildProductListProps {
  categoryName: string;
  products: Product[];
  isParentRowViewable: boolean; // New prop
}

const ChildProductList: React.FC<ChildProductListProps> = ({ categoryName, products, isParentRowViewable }) => {
  // ... other code ...

  const onViewableItemsChanged = useCallback(({ viewableItems }: { viewableItems: ViewToken[] }) => {
    if (!isParentRowViewable) { // Crucial check!
      console.log(`[ChildList - ${categoryName}] Parent row not viewable, skipping child items.`);
      return;
    }

    viewableItems.forEach(item => {
      if (item.isViewable) {
        console.log(`[ChildList - ${categoryName}] Product ${item.item.name} IS NOW ACTUALLY VIEWABLE (Parent visible)`);
        // Now it's safe to trigger analytics, fetch data, etc.
      } else {
        console.log(`[ChildList - ${categoryName}] Product ${item.item.name} IS NO LONGER VIEWABLE`);
      }
    });
  }, [categoryName, isParentRowViewable]); // Add isParentRowViewable to deps

  // ... rest of the component
};
Enter fullscreen mode Exit fullscreen mode

This approach requires managing the viewability state of your parent list items, which can be done efficiently using a useState hook in the parent component and passing it down.

2. Using react-native-viewability-helper (or a similar custom hook)

For very complex scenarios or if you need more fine-grained control, you might look into libraries like react-native-viewability-helper or roll your own custom useViewability hook. These hooks often use onLayout and measure to get absolute positions and then calculate intersection with the viewport. While more involved, they offer precise control over what "visible" truly means.

3. Careful with removeClippedSubviews

removeClippedSubviews={true} on a FlatList is a powerful optimization that attempts to detach views that are entirely outside the scroll view's viewport. While it can improve performance, it can also lead to issues with complex nested layouts or when used with components that rely on being always mounted (e.g., video players). For the nested FlatList viewability problem, it might help reduce the number of prematurely mounted children, but it's not a silver bullet and can introduce other rendering quirks. Use with caution and thorough testing.

Key Takeaways from the Trenches

  • Understanding "Viewable" vs. "Visible": FlatList's onViewableItemsChanged defines "viewable" relative to its own scroll container. In a nested context, this container might be "viewable" to its child list even if the parent list is clipping it.
  • The Parent Dictates True Visibility: The most reliable way to know if a child list item is truly visible is to first confirm its parent FlatList item is visible.
  • Layer Your Checks: Combine the child FlatList's onViewableItemsChanged with a check against the parent's viewability state.
  • Test Thoroughly: Always test your viewability logic extensively on real devices, with various data sets and scroll speeds. Emulator behavior can sometimes mask real-world performance issues.
  • Don't Over-Optimize Prematurely: Start with a robust, correct solution, then optimize FlatList props like initialNumToRender, maxToRenderPerBatch, and windowSize if performance becomes an issue. Don't let these props obscure the core viewability problem.

Dealing with nested FlatList viewability can feel like chasing shadows, but by understanding the nuances of how React Native renders and recycles components, you can tame the ghost and ensure your viewability callbacks tell the honest truth. Happy coding!


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai


Top comments (0)