Skip to main content

Module 5: Lists and Performance

FlatList Performance Optimization

Achieving buttery-smooth 60 FPS scrolling with large datasets

🎯 Learning Objectives

  • Understand how FlatList's virtualization engine works internally
  • Master the key performance props: getItemLayout, windowSize, and more
  • Implement proper memoization strategies for list items
  • Diagnose and fix common performance bottlenecks
  • Use React Native's performance tools to measure improvements
  • Know when to reach for advanced solutions like FlashList

The Performance Mental Model

Before diving into optimization techniques, let's understand what FlatList is doing under the hood. This mental model will help you make informed decisions about which optimizations to apply.

The Virtualization Loop

FlatList continuously runs a virtualization loop as the user scrolls:

flowchart TB
    subgraph Loop["Virtualization Loop (runs on scroll)"]
        A["1. User scrolls"] --> B["2. Calculate visible range"]
        B --> C["3. Determine items to render"]
        C --> D{"4. Items changed?"}
        D -->|Yes| E["5. Mount new items"]
        D -->|No| F["6. Skip render"]
        E --> G["7. Unmount off-screen items"]
        G --> H["8. Update spacers"]
        F --> H
        H --> I["9. Commit to screen"]
    end
    
    style A fill:#e3f2fd
    style E fill:#fff3cd
    style G fill:#ffebee
    style I fill:#e8f5e9
                

Each step in this loop has a cost. The goal of optimization is to make each step as fast as possible, and to skip steps when they're not needed.

The Two Threads

React Native runs on two main threads, and FlatList performance problems can occur on either:

React Native's Two Main Threads JavaScript Thread Responsibilities: β€’ React reconciliation β€’ renderItem execution β€’ State updates β€’ Event handlers β€’ Business logic When blocked: β€’ Touch delays β€’ Scroll stuttering UI (Main) Thread Responsibilities: β€’ Native view rendering β€’ Layout calculations β€’ Touch handling β€’ Scroll physics β€’ Animations (native) When blocked: β€’ Frozen UI β€’ Dropped frames Bridge

πŸ“– The 16ms Budget

For 60 FPS scrolling, each frame must complete in under 16.67 milliseconds. If your renderItem function takes 20ms, you'll drop frames. If layout calculations take 30ms, the UI freezes. Every optimization is about staying under this budget.

Where Performance Problems Hide

FlatList performance issues typically fall into these categories:

  1. Expensive renderItem: Your render function does too much work
  2. Unnecessary re-renders: Items re-render when they shouldn't
  3. Layout thrashing: FlatList can't predict item sizes
  4. Too many items in memory: Rendering window is too large
  5. Heavy images: Large images without proper caching
  6. Inline functions: New function references on every render

We'll address each of these throughout this lesson.

Measuring Performance

Before optimizing, you need to measure. React Native provides several tools for performance analysis.

The Performance Monitor

The built-in Performance Monitor shows real-time FPS for both threads:

// Enable in development:
// 1. Shake device (or Cmd+D in iOS Simulator, Cmd+M in Android)
// 2. Select "Show Perf Monitor"

// What to look for:
// - JS FPS: Should stay near 60
// - UI FPS: Should stay near 60
// - RAM: Should stay stable (not continuously growing)

πŸ’‘ Reading the Performance Monitor

  • Both at 60: Your list is performing well
  • JS drops, UI stays 60: JavaScript is the bottleneck (renderItem, state updates)
  • UI drops, JS stays 60: Native rendering is the bottleneck (complex layouts, images)
  • Both drop: Severe problemβ€”likely both threads are overwhelmed

React DevTools Profiler

The React DevTools Profiler shows exactly which components render and why:

// Install React DevTools
// npm install -g react-devtools

// Run in terminal
// react-devtools

// In your app, the Profiler tab shows:
// - What components rendered
// - How long each render took
// - Why components rendered (props changed, state changed, etc.)

// Pro tip: Look for list items that re-render when they shouldn't

Console Timing

For quick measurements, use console timing:

// Measure renderItem performance
const renderItem = useCallback(({ item }) => {
  console.time(`render-${item.id}`);
  
  const result = (
    <ItemComponent item={item} />
  );
  
  console.timeEnd(`render-${item.id}`);
  return result;
}, []);

// Measure initial render
useEffect(() => {
  console.time('list-mount');
  return () => console.timeEnd('list-mount');
}, []);

Systrace (Advanced)

For deep performance analysis, use Systrace:

# Android only
# Start trace
adb shell "atrace --async_start -b 8192 -c view input sched freq"

# Interact with your app (scroll the list)

# Stop trace
adb shell "atrace --async_stop -o /data/local/tmp/trace.txt"

# Pull and analyze
adb pull /data/local/tmp/trace.txt
# Open in chrome://tracing

getItemLayout: The Biggest Win

If you implement only one optimization, make it getItemLayout. This prop tells FlatList the exact size and position of every item without measuring them.

The Problem: Layout Measurement

By default, FlatList doesn't know how tall your items are until they render. This causes:

  • Scroll position jumps when items above the viewport resize
  • Incorrect scroll indicators because total height is estimated
  • scrollToIndex failures because positions are unknown
  • Extra layout passes as items are measured
flowchart LR
    subgraph Without["Without getItemLayout"]
        W1["Render item"] --> W2["Measure height"]
        W2 --> W3["Update positions"]
        W3 --> W4["Re-layout list"]
        W4 --> W5["Possible scroll jump"]
    end
    
    subgraph With["With getItemLayout"]
        G1["Calculate position"] --> G2["Render at position"]
    end
    
    Without --> |"Slower, janky"| Result1["😟"]
    With --> |"Instant, smooth"| Result2["😊"]
    
    style W5 fill:#ffcdd2
    style G2 fill:#c8e6c9
                

Implementing getItemLayout

For items with consistent height, getItemLayout is straightforward:

const ITEM_HEIGHT = 80;

<FlatList
  data={items}
  renderItem={renderItem}
  getItemLayout={(data, index) => ({
    length: ITEM_HEIGHT,    // Height of the item
    offset: ITEM_HEIGHT * index,  // Distance from top
    index,                   // The index
  })}
/>

With Separators

If you have separators, include them in the calculation:

const ITEM_HEIGHT = 80;
const SEPARATOR_HEIGHT = 1;

<FlatList
  data={items}
  renderItem={renderItem}
  ItemSeparatorComponent={() => (
    <View style={{ height: SEPARATOR_HEIGHT, backgroundColor: '#eee' }} />
  )}
  getItemLayout={(data, index) => ({
    length: ITEM_HEIGHT,
    offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index,
    index,
  })}
/>

With Headers

Headers add complexityβ€”you need to account for their height:

const ITEM_HEIGHT = 80;
const HEADER_HEIGHT = 120;

<FlatList
  data={items}
  renderItem={renderItem}
  ListHeaderComponent={<Header />}
  getItemLayout={(data, index) => ({
    length: ITEM_HEIGHT,
    offset: HEADER_HEIGHT + (ITEM_HEIGHT * index),
    index,
  })}
/>

Full Example with All Elements

const ITEM_HEIGHT = 72;
const SEPARATOR_HEIGHT = 1;
const HEADER_HEIGHT = 100;
const FOOTER_HEIGHT = 50;

function OptimizedList({ data }) {
  const getItemLayout = useCallback(
    (data: Item[] | null | undefined, index: number) => {
      // Calculate offset: header + (item + separator) * index
      const offset = HEADER_HEIGHT + (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index;
      
      return {
        length: ITEM_HEIGHT,
        offset,
        index,
      };
    },
    []
  );

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      getItemLayout={getItemLayout}
      ListHeaderComponent={<View style={{ height: HEADER_HEIGHT }} />}
      ListFooterComponent={<View style={{ height: FOOTER_HEIGHT }} />}
      ItemSeparatorComponent={() => (
        <View style={{ height: SEPARATOR_HEIGHT }} />
      )}
    />
  );
}

⚠️ When You Can't Use getItemLayout

If your items have variable heights (like a chat with different message lengths), you can't use getItemLayout. In these cases:

  • Consider fixed-height item containers with scrollable content inside
  • Use estimatedItemSize in FlashList (covered later)
  • Accept the measurement cost and optimize other areas

scrollToIndex with getItemLayout

One major benefit: scrollToIndex works perfectly when you have getItemLayout:

const flatListRef = useRef<FlatList>(null);

const scrollToItem = (index: number) => {
  // Without getItemLayout, this might fail with:
  // "scrollToIndex should be used in conjunction with getItemLayout"
  
  flatListRef.current?.scrollToIndex({
    index,
    animated: true,
    viewPosition: 0.5,  // Center the item on screen
  });
};

// Handle scrollToIndex failures (for lists without getItemLayout)
<FlatList
  ref={flatListRef}
  onScrollToIndexFailed={(info) => {
    // Wait for items to render, then try again
    setTimeout(() => {
      flatListRef.current?.scrollToIndex({
        index: info.index,
        animated: true,
      });
    }, 100);
  }}
/>

windowSize and Render Batching

FlatList renders more items than are visible to ensure smooth scrolling. These props control how much is rendered and when.

Understanding windowSize

The windowSize prop determines how many "screens" worth of content to render:

windowSize Visualization Full Data (1000 items) windowSize = 21 (default) 10 screens above Viewport 10 screens below Not rendered windowSize = 5 (optimized) 2 above Viewport 2 below Not rendered ~200 items in memory (windowSize=21) ~50 items in memory (windowSize=5)
// windowSize = number of viewport heights to render
// Default is 21 (10 above + 1 visible + 10 below)

// Lower value = less memory, but may show blank areas during fast scroll
<FlatList
  data={items}
  renderItem={renderItem}
  windowSize={5}  // 2 above + 1 visible + 2 below
/>

// Recommendation by use case:
// - Complex items, slow renders: windowSize={21} (default)
// - Simple items, fast renders: windowSize={5-11}
// - Very simple items: windowSize={3-5}

Render Batching Props

These props control how FlatList batches rendering work:

<FlatList
  data={items}
  renderItem={renderItem}
  
  // How many items to render in each batch
  // Lower = faster initial render, slower scroll catch-up
  maxToRenderPerBatch={10}  // Default: 10
  
  // How many items to render initially
  // Higher = slower initial render, but less blank content
  initialNumToRender={10}   // Default: 10
  
  // Minimum time between batch renders (ms)
  // Higher = more responsive to touch, slower list population
  updateCellsBatchingPeriod={50}  // Default: 50
/>

βœ… Recommended Starting Values

Scenario Settings
Simple items
(text only)
windowSize={5}
maxToRenderPerBatch={20}
Medium items
(text + image)
windowSize={11}
maxToRenderPerBatch={10}
Complex items
(heavy computation)
windowSize={21}
maxToRenderPerBatch={5}

Memoization Strategies

Memoization prevents unnecessary re-computation. In FlatList, proper memoization can make the difference between 30 FPS and 60 FPS scrolling.

Memoizing Item Components

Wrap your item components with React.memo to prevent re-renders when props haven't changed:

// ❌ Without memo: Re-renders on every parent render
const ListItem = ({ item, onPress }) => {
  console.log(`Rendering item ${item.id}`);  // Logs constantly!
  
  return (
    <Pressable onPress={() => onPress(item.id)}>
      <Text>{item.name}</Text>
    </Pressable>
  );
};

// βœ… With memo: Only re-renders when props change
const ListItem = memo(function ListItem({ item, onPress }) {
  console.log(`Rendering item ${item.id}`);  // Logs only when needed
  
  return (
    <Pressable onPress={() => onPress(item.id)}>
      <Text>{item.name}</Text>
    </Pressable>
  );
});

Custom Comparison Functions

By default, memo does shallow comparison. For complex props, provide a custom comparison:

interface ItemProps {
  item: {
    id: string;
    name: string;
    metadata: {
      views: number;
      likes: number;
    };
  };
  onPress: (id: string) => void;
}

// Custom comparison: only re-render if id or name changed
// Ignore metadata changes and function reference changes
const ListItem = memo(
  function ListItem({ item, onPress }: ItemProps) {
    return (
      <Pressable onPress={() => onPress(item.id)}>
        <Text>{item.name}</Text>
      </Pressable>
    );
  },
  (prevProps, nextProps) => {
    // Return true if props are equal (skip re-render)
    // Return false if props differ (trigger re-render)
    return (
      prevProps.item.id === nextProps.item.id &&
      prevProps.item.name === nextProps.item.name
    );
  }
);

Memoizing renderItem

Always memoize your renderItem function to maintain stable references:

// ❌ New function on every render
function BadList({ data }) {
  return (
    <FlatList
      data={data}
      renderItem={({ item }) => <ListItem item={item} />}  // New ref every render!
    />
  );
}

// βœ… Stable function reference
function GoodList({ data }) {
  const renderItem = useCallback(
    ({ item }) => <ListItem item={item} />,
    []  // Empty deps = never changes
  );

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
    />
  );
}

// βœ… With dependencies
function ListWithHandlers({ data, onItemPress }) {
  const renderItem = useCallback(
    ({ item }) => (
      <ListItem item={item} onPress={onItemPress} />
    ),
    [onItemPress]  // Re-create if handler changes
  );

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
    />
  );
}

Memoizing Other Props

function OptimizedList({ data, filterActive }) {
  // Memoize keyExtractor
  const keyExtractor = useCallback(
    (item: Item) => item.id,
    []
  );

  // Memoize getItemLayout
  const getItemLayout = useCallback(
    (data: Item[] | null | undefined, index: number) => ({
      length: ITEM_HEIGHT,
      offset: ITEM_HEIGHT * index,
      index,
    }),
    []
  );

  // Memoize components
  const ListHeader = useMemo(
    () => <Header filterActive={filterActive} />,
    [filterActive]
  );

  const ItemSeparator = useCallback(
    () => <View style={styles.separator} />,
    []
  );

  const ListEmpty = useMemo(
    () => <EmptyState />,
    []
  );

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      getItemLayout={getItemLayout}
      ListHeaderComponent={ListHeader}
      ItemSeparatorComponent={ItemSeparator}
      ListEmptyComponent={ListEmpty}
    />
  );
}
flowchart TD
    subgraph Without["Without Memoization"]
        W1["Parent renders"] --> W2["New renderItem fn"]
        W2 --> W3["New keyExtractor fn"]
        W3 --> W4["FlatList sees 'new' props"]
        W4 --> W5["Re-renders all visible items"]
    end
    
    subgraph With["With Memoization"]
        M1["Parent renders"] --> M2["Same renderItem ref"]
        M2 --> M3["Same keyExtractor ref"]
        M3 --> M4["FlatList sees same props"]
        M4 --> M5["Skips unnecessary work"]
    end
    
    style W5 fill:#ffcdd2
    style M5 fill:#c8e6c9
                

Avoiding Unnecessary Re-renders

Even with memoization, certain patterns can cause items to re-render when they shouldn't. Let's identify and fix them.

The Inline Function Trap

// ❌ Inline function creates new reference every render
<FlatList
  data={items}
  renderItem={({ item }) => (
    <Pressable onPress={() => handlePress(item.id)}>
      <Text>{item.name}</Text>
    </Pressable>
  )}
/>

// ❌ Even with memoized renderItem, inline handler breaks memo
const renderItem = useCallback(({ item }) => (
  <MemoizedItem 
    item={item} 
    onPress={() => handlePress(item.id)}  // New function!
  />
), [handlePress]);

// βœ… Move handler into the item component
const ListItem = memo(function ListItem({ item, onPress }) {
  const handleItemPress = useCallback(() => {
    onPress(item.id);
  }, [item.id, onPress]);

  return (
    <Pressable onPress={handleItemPress}>
      <Text>{item.name}</Text>
    </Pressable>
  );
});

// Parent just passes the handler
const handlePress = useCallback((id: string) => {
  console.log('Pressed:', id);
}, []);

const renderItem = useCallback(
  ({ item }) => <ListItem item={item} onPress={handlePress} />,
  [handlePress]
);

The Style Object Trap

// ❌ Inline style object creates new reference
<FlatList
  data={items}
  contentContainerStyle={{ padding: 16 }}  // New object every render!
  renderItem={({ item }) => (
    <View style={{ padding: 12 }}>  // New object every render!
      <Text>{item.name}</Text>
    </View>
  )}
/>

// βœ… Use StyleSheet or memoize
const styles = StyleSheet.create({
  container: { padding: 16 },
  item: { padding: 12 },
});

<FlatList
  data={items}
  contentContainerStyle={styles.container}
  renderItem={({ item }) => (
    <View style={styles.item}>
      <Text>{item.name}</Text>
    </View>
  )}
/>

// For dynamic styles, memoize the combination
const ListItem = memo(function ListItem({ item, isSelected }) {
  const itemStyle = useMemo(
    () => [styles.item, isSelected && styles.selected],
    [isSelected]
  );

  return (
    <View style={itemStyle}>
      <Text>{item.name}</Text>
    </View>
  );
});

The extraData Gotcha

// ❌ Object literal creates new reference every render
<FlatList
  data={items}
  extraData={{ selectedId, isEditing }}  // New object = full re-render!
  renderItem={renderItem}
/>

// βœ… Use primitive value when possible
<FlatList
  data={items}
  extraData={selectedId}
  renderItem={renderItem}
/>

// βœ… Or memoize the object
const extraData = useMemo(
  () => ({ selectedId, isEditing }),
  [selectedId, isEditing]
);

<FlatList
  data={items}
  extraData={extraData}
  renderItem={renderItem}
/>

Detecting Re-renders

Add logging to find components that re-render too often:

// Development-only render counter
const ListItem = memo(function ListItem({ item }) {
  const renderCount = useRef(0);
  renderCount.current += 1;

  if (__DEV__) {
    console.log(`Item ${item.id} render #${renderCount.current}`);
  }

  return (
    <View>
      <Text>{item.name}</Text>
      {__DEV__ && (
        <Text style={{ color: 'red', fontSize: 10 }}>
          Renders: {renderCount.current}
        </Text>
      )}
    </View>
  );
});

// If you see high render counts during scrolling,
// there's a memoization problem!

🚨 Common Re-render Causes

Cause Fix
Inline functions useCallback
Inline style objects StyleSheet.create
New extraData object useMemo or primitives
Un-memoized items React.memo
Context changes Split contexts or use selectors

Image Optimization in Lists

Images are often the biggest performance bottleneck in lists. Unoptimized images can cause scroll stuttering, memory pressure, and slow initial renders.

The Image Problem

// ❌ Common mistakes with images in lists
<FlatList
  data={posts}
  renderItem={({ item }) => (
    <View>
      {/* No size specified - causes layout thrashing */}
      <Image source={{ uri: item.imageUrl }} />
      
      {/* Full-size image - wastes memory */}
      <Image 
        source={{ uri: item.highResUrl }}
        style={{ width: 100, height: 100 }}
      />
      
      {/* No placeholder - shows blank while loading */}
      <Image source={{ uri: item.imageUrl }} />
    </View>
  )}
/>

Always Specify Dimensions

// βœ… Fixed dimensions prevent layout recalculation
const IMAGE_SIZE = 80;

const ListItem = memo(function ListItem({ item }) {
  return (
    <View style={styles.item}>
      <Image
        source={{ uri: item.thumbnailUrl }}
        style={{
          width: IMAGE_SIZE,
          height: IMAGE_SIZE,
          borderRadius: IMAGE_SIZE / 2,
        }}
      />
      <Text>{item.name}</Text>
    </View>
  );
});

// βœ… For aspect-ratio images, use a fixed container
const PostImage = memo(function PostImage({ uri }) {
  return (
    <View style={styles.imageContainer}>
      <Image
        source={{ uri }}
        style={styles.image}
        resizeMode="cover"
      />
    </View>
  );
});

const styles = StyleSheet.create({
  imageContainer: {
    width: '100%',
    aspectRatio: 16 / 9,  // Container has fixed aspect ratio
    backgroundColor: '#f0f0f0',  // Placeholder color
  },
  image: {
    width: '100%',
    height: '100%',
  },
});

Use Thumbnails

// βœ… Request appropriately sized images
const getImageUrl = (baseUrl: string, size: 'thumb' | 'medium' | 'full') => {
  const sizes = {
    thumb: '100x100',
    medium: '400x400',
    full: '1200x1200',
  };
  return `${baseUrl}?size=${sizes[size]}`;
};

const ListItem = memo(function ListItem({ item }) {
  return (
    <View style={styles.item}>
      <Image
        // Request thumbnail for list view
        source={{ uri: getImageUrl(item.imageUrl, 'thumb') }}
        style={styles.thumbnail}
      />
    </View>
  );
});

// Full image only on detail screen
const DetailScreen = ({ item }) => (
  <Image
    source={{ uri: getImageUrl(item.imageUrl, 'full') }}
    style={styles.fullImage}
  />
);

Image Caching with expo-image

For production apps, use expo-image instead of the built-in Image component:

# Install expo-image
npx expo install expo-image
import { Image } from 'expo-image';

// expo-image provides:
// - Automatic caching
// - Blur hash placeholders
// - Better memory management
// - Smoother loading transitions

const ListItem = memo(function ListItem({ item }) {
  return (
    <View style={styles.item}>
      <Image
        source={{ uri: item.thumbnailUrl }}
        style={styles.thumbnail}
        // Blur hash for instant placeholder
        placeholder={item.blurHash}
        // Smooth transition when image loads
        transition={200}
        // Memory-efficient caching
        cachePolicy="memory-disk"
        // Content fit mode
        contentFit="cover"
      />
      <Text>{item.name}</Text>
    </View>
  );
});

βœ… Image Best Practices for Lists

  • Always specify dimensions: Prevents layout thrashing
  • Use thumbnails: Request size-appropriate images
  • Use expo-image: Better caching and performance
  • Add placeholders: Blur hash or solid color
  • Lazy load off-screen: FlatList handles this automatically
  • Consider recycling: expo-image handles this internally

removeClippedSubviews

The removeClippedSubviews prop is a native-level optimization that detaches off-screen views from the view hierarchy.

// Detach views that are outside the viewport
<FlatList
  data={items}
  renderItem={renderItem}
  removeClippedSubviews={true}
/>

How It Works

flowchart LR
    subgraph Default["removeClippedSubviews={false}"]
        D1["View 1"] --- D2["View 2"]
        D2 --- D3["View 3 (visible)"]
        D3 --- D4["View 4 (visible)"]
        D4 --- D5["View 5"]
        D5 --- D6["View 6"]
    end
    
    subgraph Optimized["removeClippedSubviews={true}"]
        O3["View 3 (visible)"]
        O4["View 4 (visible)"]
        X1["Views 1,2,5,6 detached"]
    end
    
    style D1 fill:#ffcdd2
    style D2 fill:#ffcdd2
    style D3 fill:#c8e6c9
    style D4 fill:#c8e6c9
    style D5 fill:#ffcdd2
    style D6 fill:#ffcdd2
    style O3 fill:#c8e6c9
    style O4 fill:#c8e6c9
    style X1 fill:#f5f5f5,stroke-dasharray: 5
                

⚠️ removeClippedSubviews Caveats

  • Android mostly: More impactful on Android than iOS
  • Can cause bugs: Some views may not reappear correctly
  • Test thoroughly: Especially with complex item layouts
  • FlatList default: Already true on Android for FlatList

Recommendation: Leave it at the default unless you have specific issues. Modern React Native and FlatList handle this well internally.

Advanced Performance Props

Beyond the common optimizations, FlatList offers several advanced props for fine-tuning performance in specific scenarios.

initialScrollIndex

Start the list at a specific position without rendering items before it:

// Jump straight to item 50 without rendering 0-49
<FlatList
  data={items}
  renderItem={renderItem}
  initialScrollIndex={50}
  getItemLayout={getItemLayout}  // Required for initialScrollIndex!
/>

// Use case: Opening a chat at the last message
const MessageList = ({ messages, lastReadIndex }) => (
  <FlatList
    data={messages}
    renderItem={renderMessage}
    initialScrollIndex={lastReadIndex}
    getItemLayout={getItemLayout}
    inverted  // Chat style
  />
);

maintainVisibleContentPosition

Prevent scroll jumps when items are added at the top (useful for chat/feed):

// When new messages arrive at top, maintain scroll position
<FlatList
  data={messages}
  renderItem={renderMessage}
  maintainVisibleContentPosition={{
    minIndexForVisible: 0,
    autoscrollToTopThreshold: 10,  // Auto-scroll if within 10px of top
  }}
/>

// Common use case: Pull-to-refresh that adds items
function Feed() {
  const [posts, setPosts] = useState(initialPosts);
  
  const onRefresh = async () => {
    const newPosts = await fetchNewPosts();
    setPosts([...newPosts, ...posts]);
    // Without maintainVisibleContentPosition, the list would jump!
  };

  return (
    <FlatList
      data={posts}
      renderItem={renderPost}
      onRefresh={onRefresh}
      refreshing={isRefreshing}
      maintainVisibleContentPosition={{
        minIndexForVisible: 0,
      }}
    />
  );
}

legacyImplementation (deprecated)

You might see this in older codeβ€”it's deprecated and should be removed:

// ❌ Don't use - deprecated
<FlatList
  legacyImplementation={true}  // Remove this
/>

// The modern FlatList implementation is superior

disableVirtualization

Disables virtualization entirelyβ€”only use for debugging:

// ⚠️ Only for debugging! Renders ALL items like ScrollView
<FlatList
  data={items}
  renderItem={renderItem}
  disableVirtualization={true}  // Don't ship with this!
/>

viewabilityConfig

Fine-tune when items are considered "viewable" for tracking or lazy loading:

// Configure viewability thresholds
const viewabilityConfig = {
  // Item is "viewable" when 50% visible for 500ms
  viewAreaCoveragePercentThreshold: 50,
  minimumViewTime: 500,
  
  // OR use item coverage (how much of item is visible)
  // itemVisiblePercentThreshold: 75,
};

const onViewableItemsChanged = useCallback(({ viewableItems, changed }) => {
  // Track impressions
  viewableItems.forEach(({ item }) => {
    analytics.trackImpression(item.id);
  });
  
  // Lazy load data for visible items
  changed
    .filter(({ isViewable }) => isViewable)
    .forEach(({ item }) => {
      prefetchData(item.id);
    });
}, []);

// Keep config reference stable!
const viewabilityConfigCallbackPairs = useRef([
  { viewabilityConfig, onViewableItemsChanged },
]);

<FlatList
  data={items}
  renderItem={renderItem}
  viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current}
/>

All Performance Props Reference

πŸ“š FlatList Performance Props

Prop Default Purpose
getItemLayout undefined Skip measurement for fixed-height items
windowSize 21 Number of viewports to render
maxToRenderPerBatch 10 Items rendered per batch
initialNumToRender 10 Initial batch size
updateCellsBatchingPeriod 50 Delay between batches (ms)
removeClippedSubviews varies Detach off-screen views
initialScrollIndex undefined Start at specific index
maintainVisibleContentPosition undefined Prevent scroll jumps on prepend

Performance Optimization Checklist

Use this checklist when optimizing FlatList performance. Work through it in orderβ€”early items have the biggest impact.

πŸš€ FlatList Performance Checklist

High Impact (Do First)

  • ☐ getItemLayout β€” Implement if items have fixed height
  • ☐ keyExtractor β€” Use unique, stable keys (not index)
  • ☐ React.memo β€” Wrap item component
  • ☐ useCallback β€” Memoize renderItem and handlers
  • ☐ Image dimensions β€” Always specify width/height

Medium Impact

  • ☐ windowSize β€” Reduce from 21 if items are simple
  • ☐ Image caching β€” Use expo-image or similar
  • ☐ Inline styles β€” Move to StyleSheet
  • ☐ extraData β€” Memoize if using object/array
  • ☐ Thumbnail images β€” Request size-appropriate images

Fine-Tuning

  • ☐ maxToRenderPerBatch β€” Adjust based on item complexity
  • ☐ initialNumToRender β€” Match visible items on screen
  • ☐ Render tracking β€” Add dev-only render counters
  • ☐ Profile with DevTools β€” Find remaining bottlenecks

When All Else Fails

  • ☐ FlashList β€” Consider switching (covered in Lesson 5.7)
  • ☐ Simplify items β€” Reduce component tree depth
  • ☐ Defer heavy work β€” Load details after scroll stops

Quick Diagnosis Guide

flowchart TD
    A["Scroll is janky"] --> B{"Which FPS drops?"}
    
    B -->|"JS FPS drops"| C["JavaScript bottleneck"]
    B -->|"UI FPS drops"| D["Native bottleneck"]
    B -->|"Both drop"| E["Severe issue"]
    
    C --> C1["Check renderItem complexity"]
    C --> C2["Check for unnecessary re-renders"]
    C --> C3["Profile with React DevTools"]
    
    D --> D1["Check image sizes"]
    D --> D2["Check layout complexity"]
    D --> D3["Check removeClippedSubviews"]
    
    E --> E1["Implement getItemLayout"]
    E --> E2["Reduce windowSize"]
    E --> E3["Consider FlashList"]
    
    style C fill:#fff3cd
    style D fill:#e3f2fd
    style E fill:#ffcdd2
                

Before and After Example

// ❌ BEFORE: Unoptimized FlatList
function UnoptimizedList({ data, onItemPress }) {
  return (
    <FlatList
      data={data}
      renderItem={({ item }) => (
        <View style={{ padding: 16, borderBottomWidth: 1 }}>
          <Image source={{ uri: item.image }} />
          <Text>{item.title}</Text>
          <Pressable onPress={() => onItemPress(item.id)}>
            <Text>View Details</Text>
          </Pressable>
        </View>
      )}
    />
  );
}

// βœ… AFTER: Optimized FlatList
const ITEM_HEIGHT = 100;

const ListItem = memo(function ListItem({ item, onPress }) {
  const handlePress = useCallback(() => {
    onPress(item.id);
  }, [item.id, onPress]);

  return (
    <View style={styles.item}>
      <Image 
        source={{ uri: item.thumbnailUrl }}
        style={styles.image}
      />
      <Text style={styles.title}>{item.title}</Text>
      <Pressable onPress={handlePress} style={styles.button}>
        <Text>View Details</Text>
      </Pressable>
    </View>
  );
});

function OptimizedList({ data, onItemPress }) {
  const renderItem = useCallback(
    ({ item }) => <ListItem item={item} onPress={onItemPress} />,
    [onItemPress]
  );

  const keyExtractor = useCallback(
    (item) => item.id,
    []
  );

  const getItemLayout = useCallback(
    (data, index) => ({
      length: ITEM_HEIGHT,
      offset: ITEM_HEIGHT * index,
      index,
    }),
    []
  );

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      getItemLayout={getItemLayout}
      windowSize={11}
      maxToRenderPerBatch={10}
      initialNumToRender={10}
    />
  );
}

const styles = StyleSheet.create({
  item: {
    height: ITEM_HEIGHT,
    padding: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  image: {
    width: 60,
    height: 60,
  },
  title: {
    fontSize: 16,
    fontWeight: '600',
  },
  button: {
    padding: 8,
  },
});

Hands-On Exercises

Let's put these optimization techniques into practice.

Exercise 1: Implement getItemLayout

Add getItemLayout to a list with fixed-height items and separators.

Given this list structure:

  • Item height: 72px
  • Separator height: 1px
  • Header height: 60px
  • No footer

Implement getItemLayout that correctly calculates positions.

πŸ’‘ Hint

Offset = Header + (ItemHeight + SeparatorHeight) Γ— index. Remember that separators appear between items, so the last item doesn't have a separator after it.

βœ… Solution
const ITEM_HEIGHT = 72;
const SEPARATOR_HEIGHT = 1;
const HEADER_HEIGHT = 60;

const getItemLayout = useCallback(
  (data: Item[] | null | undefined, index: number) => {
    // Each item is followed by a separator (except possibly the last)
    // But for offset calculation, we count separators before each item
    const offset = HEADER_HEIGHT + (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index;
    
    return {
      length: ITEM_HEIGHT,
      offset,
      index,
    };
  },
  []
);

// Full component
function OptimizedList({ items }) {
  const renderItem = useCallback(
    ({ item }) => (
      <View style={{ height: ITEM_HEIGHT, justifyContent: 'center', paddingHorizontal: 16 }}>
        <Text>{item.name}</Text>
      </View>
    ),
    []
  );

  const Separator = useCallback(
    () => <View style={{ height: SEPARATOR_HEIGHT, backgroundColor: '#e0e0e0' }} />,
    []
  );

  const Header = useMemo(
    () => (
      <View style={{ height: HEADER_HEIGHT, justifyContent: 'center', paddingHorizontal: 16 }}>
        <Text style={{ fontSize: 24, fontWeight: 'bold' }}>My List</Text>
      </View>
    ),
    []
  );

  return (
    <FlatList
      data={items}
      renderItem={renderItem}
      keyExtractor={(item) => item.id}
      getItemLayout={getItemLayout}
      ItemSeparatorComponent={Separator}
      ListHeaderComponent={Header}
    />
  );
}

Exercise 2: Fix Re-render Issues

This list has performance problems. Find and fix them.

// Buggy code - find the issues!
function BuggyList({ items, onSelect, selectedId }) {
  return (
    <FlatList
      data={items}
      extraData={{ selectedId }}
      renderItem={({ item }) => (
        <Pressable
          style={{
            padding: 16,
            backgroundColor: item.id === selectedId ? '#e3f2fd' : '#fff',
          }}
          onPress={() => onSelect(item.id)}
        >
          <Text>{item.name}</Text>
        </Pressable>
      )}
      keyExtractor={(item, index) => index.toString()}
    />
  );
}
πŸ’‘ Hint

Look for: inline functions, inline styles, unstable extraData, index as key, and missing memoization.

βœ… Solution
// Fixed version with all issues resolved

// 1. Memoized item component
const ListItem = memo(function ListItem({ 
  item, 
  isSelected, 
  onSelect 
}: {
  item: Item;
  isSelected: boolean;
  onSelect: (id: string) => void;
}) {
  // 2. Memoized press handler
  const handlePress = useCallback(() => {
    onSelect(item.id);
  }, [item.id, onSelect]);

  // 3. Memoized style
  const itemStyle = useMemo(
    () => [styles.item, isSelected && styles.itemSelected],
    [isSelected]
  );

  return (
    <Pressable style={itemStyle} onPress={handlePress}>
      <Text>{item.name}</Text>
    </Pressable>
  );
});

function FixedList({ items, onSelect, selectedId }) {
  // 4. Memoized renderItem
  const renderItem = useCallback(
    ({ item }) => (
      <ListItem
        item={item}
        isSelected={item.id === selectedId}
        onSelect={onSelect}
      />
    ),
    [selectedId, onSelect]
  );

  // 5. Memoized keyExtractor with proper unique key
  const keyExtractor = useCallback(
    (item: Item) => item.id,  // Use item.id, not index!
    []
  );

  return (
    <FlatList
      data={items}
      // 6. Primitive extraData instead of object
      extraData={selectedId}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
    />
  );
}

// 7. Styles in StyleSheet, not inline
const styles = StyleSheet.create({
  item: {
    padding: 16,
    backgroundColor: '#fff',
  },
  itemSelected: {
    backgroundColor: '#e3f2fd',
  },
});

Issues fixed:

  1. Added React.memo to item component
  2. Memoized press handler with useCallback
  3. Memoized dynamic style with useMemo
  4. Memoized renderItem with useCallback
  5. Used item.id instead of index for key
  6. Used primitive selectedId for extraData
  7. Moved styles to StyleSheet.create

Exercise 3: Build an Optimized Image Gallery

Create a performant image gallery list with all optimizations applied.

Requirements:

  • Display a grid of images (2 columns)
  • Each image cell is a fixed 180px Γ— 180px square
  • Use expo-image (or regular Image with proper sizing)
  • Implement getItemLayout for the grid
  • Add all appropriate memoization
  • Track viewable items for analytics
πŸ’‘ Hint

Use numColumns={2} for the grid. With 2 columns, each "row" in getItemLayout represents 2 items. Calculate row index as Math.floor(index / 2).

βœ… Solution
import React, { useCallback, useMemo, useRef } from 'react';
import {
  FlatList,
  View,
  Text,
  StyleSheet,
  Dimensions,
  ViewabilityConfig,
  ViewToken,
} from 'react-native';
import { Image } from 'expo-image';

interface GalleryImage {
  id: string;
  uri: string;
  blurHash?: string;
}

// Constants
const SCREEN_WIDTH = Dimensions.get('window').width;
const NUM_COLUMNS = 2;
const GAP = 4;
const IMAGE_SIZE = (SCREEN_WIDTH - GAP * (NUM_COLUMNS + 1)) / NUM_COLUMNS;
const ROW_HEIGHT = IMAGE_SIZE + GAP;

// Generate mock data
const generateImages = (count: number): GalleryImage[] =>
  Array.from({ length: count }, (_, i) => ({
    id: `image-${i}`,
    uri: `https://picsum.photos/seed/${i}/400/400`,
    blurHash: 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.',
  }));

// Memoized image cell
const ImageCell = memo(function ImageCell({ image }: { image: GalleryImage }) {
  return (
    <View style={styles.imageContainer}>
      <Image
        source={{ uri: image.uri }}
        style={styles.image}
        placeholder={image.blurHash}
        contentFit="cover"
        transition={200}
        cachePolicy="memory-disk"
      />
    </View>
  );
});

export default function OptimizedGallery() {
  const images = useMemo(() => generateImages(100), []);
  
  // Track viewed images
  const viewedImages = useRef<Set<string>>(new Set());

  // Memoized renderItem
  const renderItem = useCallback(
    ({ item }: { item: GalleryImage }) => <ImageCell image={item} />,
    []
  );

  // Memoized keyExtractor
  const keyExtractor = useCallback(
    (item: GalleryImage) => item.id,
    []
  );

  // getItemLayout for grid (2 columns)
  const getItemLayout = useCallback(
    (data: GalleryImage[] | null | undefined, index: number) => {
      // Each row contains NUM_COLUMNS items
      const rowIndex = Math.floor(index / NUM_COLUMNS);
      
      return {
        length: ROW_HEIGHT,
        offset: ROW_HEIGHT * rowIndex,
        index,
      };
    },
    []
  );

  // Viewability configuration
  const viewabilityConfig: ViewabilityConfig = useMemo(() => ({
    viewAreaCoveragePercentThreshold: 50,
    minimumViewTime: 500,
  }), []);

  const onViewableItemsChanged = useCallback(
    ({ viewableItems }: { viewableItems: ViewToken[] }) => {
      viewableItems.forEach(({ item, isViewable }) => {
        if (isViewable && !viewedImages.current.has(item.id)) {
          viewedImages.current.add(item.id);
          // Analytics tracking would go here
          console.log(`Image viewed: ${item.id}`);
        }
      });
    },
    []
  );

  // Keep reference stable
  const viewabilityConfigCallbackPairs = useRef([
    { viewabilityConfig, onViewableItemsChanged },
  ]);

  // Column separator
  const ItemSeparator = useCallback(
    () => <View style={{ height: GAP }} />,
    []
  );

  return (
    <View style={styles.container}>
      <FlatList<GalleryImage>
        data={images}
        renderItem={renderItem}
        keyExtractor={keyExtractor}
        getItemLayout={getItemLayout}
        numColumns={NUM_COLUMNS}
        columnWrapperStyle={styles.row}
        ItemSeparatorComponent={ItemSeparator}
        viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current}
        // Performance props
        windowSize={7}
        maxToRenderPerBatch={8}
        initialNumToRender={8}
        removeClippedSubviews={true}
        // Styling
        contentContainerStyle={styles.listContent}
        showsVerticalScrollIndicator={false}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#000',
  },
  listContent: {
    padding: GAP,
  },
  row: {
    justifyContent: 'space-between',
  },
  imageContainer: {
    width: IMAGE_SIZE,
    height: IMAGE_SIZE,
    borderRadius: 4,
    overflow: 'hidden',
    backgroundColor: '#1a1a1a',
  },
  image: {
    width: '100%',
    height: '100%',
  },
});

Optimizations applied:

  • getItemLayout with grid calculation
  • React.memo on ImageCell
  • useCallback on all function props
  • expo-image with blur hash placeholder and caching
  • Fixed image dimensions
  • Reduced windowSize for images
  • Viewability tracking for analytics
  • removeClippedSubviews enabled

Exercise 4: Performance Audit

Practice diagnosing performance issues using the Performance Monitor.

Tasks:

  1. Create a list with 500+ items that includes images
  2. Open the Performance Monitor (shake β†’ "Perf Monitor")
  3. Scroll the list rapidly and note the FPS
  4. Apply optimizations one at a time, measuring after each
  5. Document which optimization had the biggest impact

Record your findings:

  • Baseline FPS: ___
  • After getItemLayout: ___
  • After memo: ___
  • After image optimization: ___
  • Final FPS: ___
πŸ’‘ Expected Results

Typical improvements you might see:

  • Baseline: 25-40 FPS with visible jank
  • + getItemLayout: +5-10 FPS improvement
  • + memo: +5-15 FPS improvement
  • + image optimization: +5-10 FPS improvement
  • Final: 55-60 FPS smooth scrolling

The exact numbers depend on your device, item complexity, and starting implementation. The key insight is that optimizations are cumulative!

Summary

You've learned the techniques that separate laggy lists from buttery-smooth ones. Let's recap the key concepts.

🎯 Key Takeaways

  • 16ms budget: Each frame must complete in under 16.67ms for 60 FPS
  • getItemLayout: The single most impactful optimization for fixed-height items
  • Memoization: React.memo + useCallback prevent unnecessary re-renders
  • windowSize: Reduce from 21 to render fewer off-screen items
  • Images: Always specify dimensions, use thumbnails, consider expo-image
  • Measure first: Use Performance Monitor and React DevTools before optimizing
  • Cumulative gains: Small optimizations add up to smooth performance

The Optimization Priority

flowchart TB
    subgraph Priority["Optimization Priority (High to Low)"]
        direction TB
        P1["1. getItemLayout"]
        P2["2. keyExtractor (unique keys)"]
        P3["3. React.memo on items"]
        P4["4. useCallback on renderItem"]
        P5["5. Image optimization"]
        P6["6. windowSize tuning"]
        P7["7. Fine-tuning batch props"]
        
        P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7
    end
    
    style P1 fill:#c8e6c9,stroke:#4CAF50,stroke-width:2px
    style P2 fill:#c8e6c9
    style P3 fill:#dcedc8
    style P4 fill:#dcedc8
    style P5 fill:#f0f4c3
    style P6 fill:#fff9c4
    style P7 fill:#fff9c4
                

πŸš€ What's Next?

Now that you can optimize basic lists, the next lesson covers FlatList Featuresβ€”pull-to-refresh, infinite scroll, scroll-to-index, and other capabilities that make lists interactive and user-friendly. After that, we'll explore SectionList for grouped data, and finally FlashList as an alternative when you need maximum performance.

πŸ’‘ Remember

Don't optimize prematurely! Start with a working list, then optimize only if you see performance issues. Many apps work fine with basic FlatList usage. Use the Performance Monitor to identify real problems before applying these techniques.