Skip to main content

Module 5: Lists and Performance

FlashList for Maximum Performance

Shopify's blazing-fast list component that's up to 10x faster than FlatList

🎯 Learning Objectives

  • Understand why FlashList outperforms FlatList
  • Install and configure FlashList in your project
  • Master the key props: estimatedItemSize and overrideItemLayout
  • Migrate existing FlatLists to FlashList
  • Use FlashList's performance warnings effectively
  • Implement masonry layouts for Pinterest-style grids
  • Know when to use FlashList vs FlatList

Why FlashList?

FlashList is Shopify's drop-in replacement for FlatList, built to solve the fundamental performance limitations of React Native's built-in list components. It uses a technique called cell recycling that makes scrolling dramatically smoother.

The Problem with FlatList

FlatList virtualizes content by mounting and unmounting components as they scroll in and out of view. This approach has limitations:

  • Mount/unmount overhead: Creating new React components is expensive
  • Memory pressure: Garbage collection spikes cause frame drops
  • Slow fast-scrolling: Can't keep up with rapid scroll gestures
  • Blank content: White space appears during fast scrolling
flowchart LR
    subgraph FlatList["FlatList Approach"]
        F1["Item scrolls out"] --> F2["Unmount component"]
        F2 --> F3["Garbage collect"]
        F4["Item scrolls in"] --> F5["Create new component"]
        F5 --> F6["Mount & render"]
    end
    
    subgraph FlashList["FlashList Approach"]
        FL1["Item scrolls out"] --> FL2["Detach from position"]
        FL2 --> FL3["Recycle cell"]
        FL4["Item scrolls in"] --> FL3
        FL3 --> FL5["Update content"]
        FL5 --> FL6["Attach to new position"]
    end
    
    style F2 fill:#ffcdd2
    style F3 fill:#ffcdd2
    style F5 fill:#ffcdd2
    style FL3 fill:#c8e6c9
    style FL5 fill:#c8e6c9
                

FlashList's Solution: Cell Recycling

FlashList reuses (recycles) cells instead of creating new ones. When an item scrolls out of view, its cell is updated with new data and moved to a new position—no mounting or unmounting required.

Cell Recycling in Action Before Scroll Cell A → Item 1 Cell B → Item 2 Cell C → Item 3 Cell D → Item 4 Cell E → Item 5 Scroll ↓ After Scroll Cell B → Item 2 Cell C → Item 3 Cell D → Item 4 Cell E → Item 5 Cell A → Item 6 Cell A recycled! Key Insight Same 5 cells render all items. No mounting, no unmounting!

Performance Comparison

⚡ Real-World Benchmarks

According to Shopify's benchmarks with production apps:

  • 5-10x faster average scroll performance
  • ~50% less memory usage during scrolling
  • Near-zero blank areas during fast scrolling
  • Consistent 60 FPS even with complex items

FlashList is from Shopify

FlashList was developed by Shopify's mobile team, who needed better list performance for their production apps with thousands of products. It's now open source and widely adopted in the React Native community.

# GitHub: https://github.com/Shopify/flash-list
# Used in production by Shopify, Discord, Coinbase, and many others

Installation

FlashList works seamlessly with Expo and bare React Native projects.

With Expo

# Install with npx expo install (handles version compatibility)
npx expo install @shopify/flash-list

With npm/yarn (Bare React Native)

# npm
npm install @shopify/flash-list

# yarn
yarn add @shopify/flash-list

# For bare React Native, also run:
cd ios && pod install

Verify Installation

import { FlashList } from '@shopify/flash-list';

// If this imports without errors, you're ready to go!
export default function App() {
  return (
    <FlashList
      data={[{ id: '1', title: 'Hello' }]}
      renderItem={({ item }) => <Text>{item.title}</Text>}
      estimatedItemSize={50}
    />
  );
}

⚠️ Requirements

  • React Native 0.63+ (older versions not supported)
  • Expo SDK 43+ (if using Expo)
  • recyclerlistview is a peer dependency (installed automatically)

Basic Usage

FlashList's API is nearly identical to FlatList, making migration straightforward. The key difference is the required estimatedItemSize prop.

Minimal Example

import { FlashList } from '@shopify/flash-list';
import { View, Text, StyleSheet } from 'react-native';

interface Item {
  id: string;
  title: string;
}

const DATA: Item[] = Array.from({ length: 1000 }, (_, i) => ({
  id: String(i),
  title: `Item ${i + 1}`,
}));

export default function BasicFlashList() {
  return (
    <FlashList
      data={DATA}
      renderItem={({ item }) => (
        <View style={styles.item}>
          <Text>{item.title}</Text>
        </View>
      )}
      estimatedItemSize={50}  // Required! Approximate height of items
    />
  );
}

const styles = StyleSheet.create({
  item: {
    padding: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
});

FlatList vs FlashList Comparison

// FlatList version
import { FlatList } from 'react-native';

<FlatList
  data={items}
  renderItem={renderItem}
  keyExtractor={(item) => item.id}
  // getItemLayout is optional but recommended
  getItemLayout={(data, index) => ({
    length: 50,
    offset: 50 * index,
    index,
  })}
/>

// FlashList version
import { FlashList } from '@shopify/flash-list';

<FlashList
  data={items}
  renderItem={renderItem}
  keyExtractor={(item) => item.id}
  estimatedItemSize={50}  // Required, replaces getItemLayout for most cases
/>

💡 Key Differences from FlatList

FlatList FlashList
getItemLayout (optional) estimatedItemSize (required)
Creates/destroys components Recycles components
No performance warnings Built-in performance warnings
No masonry support Masonry layout built-in

Common Props

FlashList supports most FlatList props plus some unique ones:

<FlashList
  // Standard FlatList props
  data={items}
  renderItem={renderItem}
  keyExtractor={keyExtractor}
  ListHeaderComponent={Header}
  ListFooterComponent={Footer}
  ListEmptyComponent={Empty}
  ItemSeparatorComponent={Separator}
  onEndReached={loadMore}
  onEndReachedThreshold={0.5}
  refreshing={isRefreshing}
  onRefresh={handleRefresh}
  horizontal={false}
  numColumns={1}
  inverted={false}
  
  // FlashList-specific props
  estimatedItemSize={50}              // Required
  overrideItemLayout={overrideLayout} // For variable heights
  drawDistance={250}                  // Pixels to render ahead
/>

The estimatedItemSize Prop

This is FlashList's most important prop. It tells FlashList approximately how tall (or wide, for horizontal lists) your items are. This enables efficient recycling and scroll position calculations.

Why It's Required

// FlashList needs to know approximate item sizes to:
// 1. Calculate how many cells to create
// 2. Estimate scroll positions
// 3. Pre-position items before they're measured

// Too small: Creates too many cells, wastes memory
// Too large: Creates too few cells, shows blank areas
// Just right: Optimal performance

// The value should be your average item height in pixels
<FlashList
  data={items}
  renderItem={renderItem}
  estimatedItemSize={72}  // Average item height
/>

How to Calculate estimatedItemSize

// Method 1: Measure your items
// Use React DevTools or console.log the onLayout event
const measureItem = (event) => {
  console.log('Item height:', event.nativeEvent.layout.height);
};

// Method 2: Calculate from styles
const styles = StyleSheet.create({
  item: {
    paddingVertical: 16,  // 16 * 2 = 32
    borderBottomWidth: 1,  // 1
    // Text line height: ~20
    // Avatar: ~40
  },
});
// Estimated: 32 + 1 + 20 + 40 = ~93, round to 90-100

// Method 3: FlashList tells you!
// In development, FlashList logs a warning if your estimate is off:
// "%.1fx%.1f" actual vs "%.1fx%.1f" estimated

For Variable Height Items

// If items have different heights, use the AVERAGE height
// FlashList will measure actual heights as items render

// Example: Chat messages (variable length)
// Short messages: ~50px
// Medium messages: ~80px
// Long messages: ~120px
// Average: ~75px

<FlashList
  data={messages}
  renderItem={renderMessage}
  estimatedItemSize={75}  // Average, not minimum or maximum
/>

// FlashList will still handle the variation correctly!

✅ estimatedItemSize Best Practices

  • Measure real items: Don't guess—measure actual rendered heights
  • Use the average: For variable heights, use the average, not min or max
  • Include separators: If using ItemSeparatorComponent, include that height
  • Check warnings: FlashList will warn you if your estimate is too far off
  • For grids: Use item height (including gaps) for numColumns > 1

Horizontal Lists

// For horizontal lists, estimatedItemSize is the WIDTH
<FlashList
  data={items}
  renderItem={renderItem}
  horizontal
  estimatedItemSize={120}  // Width of each item
/>

overrideItemLayout for Variable Heights

When you know item sizes ahead of time (like in a chat with message type metadata), overrideItemLayout gives you even better performance than estimatedItemSize alone.

Basic Usage

interface ChatMessage {
  id: string;
  type: 'text' | 'image' | 'system';
  content: string;
}

// Different message types have different heights
const MESSAGE_HEIGHTS = {
  text: 80,
  image: 200,
  system: 40,
};

<FlashList<ChatMessage>
  data={messages}
  renderItem={renderMessage}
  estimatedItemSize={80}  // Still needed as fallback
  overrideItemLayout={(layout, item) => {
    // Set the exact size for this item
    layout.size = MESSAGE_HEIGHTS[item.type];
  }}
/>

The Layout Object

// overrideItemLayout receives a mutable layout object
overrideItemLayout={(layout, item, index, maxColumns, extraData) => {
  // layout.size: Height (or width for horizontal lists)
  layout.size = calculateHeight(item);
  
  // layout.span: How many columns this item spans (for grids)
  // Default is 1, max is numColumns
  layout.span = item.isFullWidth ? 2 : 1;
}}

// Full signature
type OverrideItemLayout = (
  layout: {
    size?: number;
    span?: number;
  },
  item: T,
  index: number,
  maxColumns: number,
  extraData?: any
) => void;

Complete Example with Variable Heights

import { FlashList } from '@shopify/flash-list';
import { View, Text, Image, StyleSheet } from 'react-native';

interface FeedItem {
  id: string;
  type: 'post' | 'photo' | 'ad' | 'story';
  content: string;
  imageUrl?: string;
}

// Pre-calculated heights for each type
const HEIGHTS = {
  post: 120,    // Text post
  photo: 350,   // Photo post with image
  ad: 180,      // Advertisement
  story: 280,   // Story preview
};

function SocialFeed({ items }: { items: FeedItem[] }) {
  const renderItem = ({ item }: { item: FeedItem }) => {
    switch (item.type) {
      case 'photo':
        return (
          <View style={[styles.card, { height: HEIGHTS.photo }]}>
            <Image source={{ uri: item.imageUrl }} style={styles.image} />
            <Text>{item.content}</Text>
          </View>
        );
      case 'ad':
        return (
          <View style={[styles.card, styles.ad, { height: HEIGHTS.ad }]}>
            <Text style={styles.adLabel}>Sponsored</Text>
            <Text>{item.content}</Text>
          </View>
        );
      default:
        return (
          <View style={[styles.card, { height: HEIGHTS[item.type] }]}>
            <Text>{item.content}</Text>
          </View>
        );
    }
  };

  return (
    <FlashList<FeedItem>
      data={items}
      renderItem={renderItem}
      keyExtractor={(item) => item.id}
      estimatedItemSize={180}  // Average of all types
      overrideItemLayout={(layout, item) => {
        layout.size = HEIGHTS[item.type];
      }}
    />
  );
}

const styles = StyleSheet.create({
  card: {
    backgroundColor: '#fff',
    margin: 8,
    padding: 12,
    borderRadius: 8,
  },
  image: {
    width: '100%',
    height: 250,
    borderRadius: 8,
  },
  ad: {
    backgroundColor: '#fffde7',
    borderWidth: 1,
    borderColor: '#ffc107',
  },
  adLabel: {
    fontSize: 12,
    color: '#f57c00',
    marginBottom: 8,
  },
});

⚠️ When to Use overrideItemLayout

Use overrideItemLayout when:

  • You know item sizes ahead of time (from metadata)
  • Items have distinct size categories (text vs image)
  • You want to avoid layout shifts

Don't use it when:

  • Item sizes depend on measured content (text wrapping)
  • Sizes are truly unpredictable
  • The calculation would be expensive

Understanding View Recycling

Cell recycling is the core innovation that makes FlashList fast. Understanding how it works helps you write better code and avoid common pitfalls.

How Recycling Works

sequenceDiagram
    participant User
    participant FlashList
    participant CellPool
    participant UI

    User->>FlashList: Scroll down
    FlashList->>CellPool: Item 1 scrolled out, return cell
    CellPool->>CellPool: Store cell for reuse
    FlashList->>CellPool: Need cell for Item 6
    CellPool->>FlashList: Return recycled cell (was Item 1)
    FlashList->>FlashList: Update cell with Item 6 data
    FlashList->>UI: Position cell at Item 6 location
    UI->>User: See Item 6 (same cell!)
                

Recycling Pool

FlashList maintains a pool of cells that it reuses. The pool size is determined by estimatedItemSize and the viewport height:

// Simplified pool size calculation:
// poolSize ≈ (viewportHeight / estimatedItemSize) + buffer

// For a 800px viewport with 50px items:
// poolSize ≈ (800 / 50) + 5 = ~21 cells

// These 21 cells render ALL your items, even if you have 10,000!

Implications for Your Code

Because cells are recycled, you must be careful about state and effects:

// ❌ BAD: Component state persists across items!
function BadListItem({ item }) {
  const [isExpanded, setIsExpanded] = useState(false);
  
  // Problem: When this cell is recycled for a new item,
  // isExpanded stays true even though the new item wasn't expanded!
  
  return (
    <Pressable onPress={() => setIsExpanded(!isExpanded)}>
      <Text>{item.title}</Text>
      {isExpanded && <Text>{item.details}</Text>}
    </Pressable>
  );
}

// ✅ GOOD: Lift state up or derive from item data
function GoodListItem({ item, expandedIds, onToggle }) {
  const isExpanded = expandedIds.has(item.id);
  
  return (
    <Pressable onPress={() => onToggle(item.id)}>
      <Text>{item.title}</Text>
      {isExpanded && <Text>{item.details}</Text>}
    </Pressable>
  );
}

// Parent manages the state
function List() {
  const [expandedIds, setExpandedIds] = useState(new Set());
  
  const toggleExpand = useCallback((id) => {
    setExpandedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  return (
    <FlashList
      data={items}
      extraData={expandedIds}  // Important!
      renderItem={({ item }) => (
        <GoodListItem
          item={item}
          expandedIds={expandedIds}
          onToggle={toggleExpand}
        />
      )}
      estimatedItemSize={50}
    />
  );
}

Cleaning Up on Recycle

// ❌ BAD: Animation state carries over
function AnimatedItem({ item }) {
  const animation = useRef(new Animated.Value(0)).current;
  
  useEffect(() => {
    Animated.timing(animation, { toValue: 1, duration: 300 }).start();
    // When recycled, animation stays at 1 from previous item!
  }, []); // Empty deps = runs only on mount, not on recycle
  
  return <Animated.View style={{ opacity: animation }}>...</Animated.View>;
}

// ✅ GOOD: Reset when item changes
function FixedAnimatedItem({ item }) {
  const animation = useRef(new Animated.Value(0)).current;
  
  useEffect(() => {
    // Reset to 0 when item changes (including recycling)
    animation.setValue(0);
    Animated.timing(animation, { toValue: 1, duration: 300 }).start();
  }, [item.id]); // Re-run when item changes
  
  return <Animated.View style={{ opacity: animation }}>...</Animated.View>;
}

🚨 Recycling Gotchas

  • Local state persists: useState values carry over to new items
  • Refs persist: useRef values aren't reset
  • Effects may not re-run: Empty dependency arrays only run on mount
  • Animations continue: Animated values keep their state

Solution: Use item.id in dependency arrays, or lift state to parent components.

Using extraData

Just like FlatList, extraData tells FlashList to re-render when external state changes:

// When selection changes, FlashList needs to know
const [selectedId, setSelectedId] = useState(null);

<FlashList
  data={items}
  extraData={selectedId}  // Triggers re-render when selection changes
  renderItem={({ item }) => (
    <Item 
      item={item} 
      isSelected={item.id === selectedId}
      onSelect={setSelectedId}
    />
  )}
  estimatedItemSize={50}
/>

Performance Warnings

FlashList includes built-in performance analysis that warns you about common issues. These warnings only appear in development mode.

Types of Warnings

// Warning 1: estimatedItemSize is too different from actual size
// "%.1fx%.1f" measured vs "%.1fx%.1f" estimated
// Solution: Adjust estimatedItemSize to match reality

// Warning 2: Too many blanks visible
// "%.1f%% blank areas visible during scroll"
// Solution: Increase drawDistance or reduce item complexity

// Warning 3: renderItem is slow
// "renderItem took %.1fms (target: <16ms)"
// Solution: Optimize renderItem, use memo()

// Warning 4: Layout changes detected
// "Item layout changed, this hurts performance"
// Solution: Use fixed sizes or overrideItemLayout

Reading the Performance Overlay

FlashList can show a visual overlay with performance metrics:

// Enable the performance overlay in development
<FlashList
  data={items}
  renderItem={renderItem}
  estimatedItemSize={50}
  // Shows performance stats overlay
  // Only works in development mode
  {...(__DEV__ ? { debug: true } : {})}
/>

Common Warnings and Fixes

📊 Warning Reference

Warning Cause Fix
"Estimated size differs" estimatedItemSize is wrong Measure and update
"Blank areas visible" Items render too slowly Increase drawDistance, simplify items
"renderItem slow" Complex render logic Use memo(), reduce component tree
"Layout changed" Item size changes after render Use overrideItemLayout or fixed sizes
"Type mismatch" Different item types not handled Implement getItemType

getItemType for Mixed Content

When your list has different types of items, use getItemType to help FlashList recycle efficiently:

interface FeedItem {
  id: string;
  type: 'post' | 'ad' | 'story';
  // ...
}

<FlashList<FeedItem>
  data={feedItems}
  renderItem={renderItem}
  estimatedItemSize={100}
  // Tell FlashList about different item types
  getItemType={(item) => item.type}
/>

// FlashList will now:
// - Only recycle 'post' cells for other 'post' items
// - Only recycle 'ad' cells for other 'ad' items
// - Avoid layout issues from recycling different types

✅ Why getItemType Matters

Without getItemType, FlashList might recycle a tall "image post" cell for a short "text post", causing layout jumps. With getItemType, cells are only recycled within their type, ensuring smoother scrolling.

Migrating from FlatList

Migrating from FlatList to FlashList is usually straightforward. Here's a step-by-step guide.

Step 1: Change the Import

// Before
import { FlatList } from 'react-native';

// After
import { FlashList } from '@shopify/flash-list';

Step 2: Add estimatedItemSize

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

// After
<FlashList
  data={items}
  renderItem={renderItem}
  estimatedItemSize={50}  // Add this!
/>

Step 3: Replace getItemLayout (Optional)

// Before: FlatList with getItemLayout
<FlatList
  data={items}
  renderItem={renderItem}
  getItemLayout={(data, index) => ({
    length: ITEM_HEIGHT,
    offset: ITEM_HEIGHT * index,
    index,
  })}
/>

// After: FlashList with overrideItemLayout (if needed)
<FlashList
  data={items}
  renderItem={renderItem}
  estimatedItemSize={ITEM_HEIGHT}
  // Only if items have DIFFERENT sizes:
  overrideItemLayout={(layout, item) => {
    layout.size = ITEM_HEIGHT;
  }}
/>

// OR simply: (for fixed-height items)
<FlashList
  data={items}
  renderItem={renderItem}
  estimatedItemSize={ITEM_HEIGHT}
  // estimatedItemSize is enough for fixed heights!
/>

Step 4: Handle Recycling Issues

// Check for local state in list items
// If you have useState in renderItem, refactor:

// Before
function ListItem({ item }) {
  const [liked, setLiked] = useState(item.isLiked);
  // ❌ State won't update when cell is recycled
}

// After
function ListItem({ item, likedIds, onToggleLike }) {
  const isLiked = likedIds.has(item.id);
  // ✅ Derived from parent state
}

Step 5: Add getItemType (If Needed)

// If you have different item types, add getItemType
<FlashList
  data={mixedItems}
  renderItem={renderItem}
  estimatedItemSize={80}
  getItemType={(item) => {
    // Return a string identifying the type
    return item.type; // 'post' | 'ad' | 'header'
  }}
/>

Migration Checklist

📋 FlashList Migration Checklist

  • ☐ Install @shopify/flash-list
  • ☐ Change import from FlatList to FlashList
  • ☐ Add estimatedItemSize prop
  • ☐ Remove getItemLayout (use overrideItemLayout if variable heights)
  • ☐ Check for local state in list items (refactor if found)
  • ☐ Add getItemType if list has different item types
  • ☐ Ensure effects/animations reset on item change
  • ☐ Test scrolling performance
  • ☐ Check development warnings

Props That Work the Same

// These props work identically in FlashList:
const compatibleProps = {
  data: items,
  renderItem: renderItem,
  keyExtractor: keyExtractor,
  ListHeaderComponent: Header,
  ListFooterComponent: Footer,
  ListEmptyComponent: Empty,
  ItemSeparatorComponent: Separator,
  numColumns: 2,
  horizontal: false,
  inverted: false,
  onEndReached: loadMore,
  onEndReachedThreshold: 0.5,
  refreshing: isRefreshing,
  onRefresh: handleRefresh,
  onScroll: handleScroll,
  scrollEventThrottle: 16,
  showsVerticalScrollIndicator: true,
  contentContainerStyle: styles.container,
  extraData: selectedId,
};

Props That Don't Exist in FlashList

// These FlatList props are NOT supported in FlashList:
// - getItemLayout (use overrideItemLayout instead)
// - windowSize (FlashList manages this automatically)
// - maxToRenderPerBatch (FlashList manages this)
// - updateCellsBatchingPeriod (FlashList manages this)
// - initialNumToRender (use drawDistance instead)
// - removeClippedSubviews (always on in FlashList)

Masonry Layouts

FlashList includes built-in masonry layout support—something FlatList can't do. Masonry layouts are perfect for Pinterest-style grids with variable-height items.

Enabling Masonry

import { MasonryFlashList } from '@shopify/flash-list';

// Use MasonryFlashList instead of FlashList
<MasonryFlashList
  data={images}
  numColumns={2}
  renderItem={renderImage}
  estimatedItemSize={200}
/>
Regular Grid vs Masonry Layout FlashList (numColumns=2) Gap Gap MasonryFlashList ✓ No wasted space!

Complete Masonry Example

import { MasonryFlashList } from '@shopify/flash-list';
import { View, Image, Text, Dimensions, StyleSheet } from 'react-native';

interface PinImage {
  id: string;
  uri: string;
  width: number;
  height: number;
  title: string;
}

const SCREEN_WIDTH = Dimensions.get('window').width;
const NUM_COLUMNS = 2;
const GAP = 8;
const COLUMN_WIDTH = (SCREEN_WIDTH - GAP * (NUM_COLUMNS + 1)) / NUM_COLUMNS;

// Generate random heights for demo
const generatePins = (count: number): PinImage[] =>
  Array.from({ length: count }, (_, i) => {
    const aspectRatio = 0.5 + Math.random() * 1; // Random aspect ratio
    return {
      id: String(i),
      uri: `https://picsum.photos/seed/${i}/400/${Math.floor(400 * aspectRatio)}`,
      width: 400,
      height: Math.floor(400 * aspectRatio),
      title: `Pin ${i + 1}`,
    };
  });

const PinCard = ({ item }: { item: PinImage }) => {
  // Calculate height based on aspect ratio
  const aspectRatio = item.height / item.width;
  const imageHeight = COLUMN_WIDTH * aspectRatio;

  return (
    <View style={styles.pinCard}>
      <Image
        source={{ uri: item.uri }}
        style={[styles.pinImage, { height: imageHeight }]}
        resizeMode="cover"
      />
      <Text style={styles.pinTitle} numberOfLines={2}>
        {item.title}
      </Text>
    </View>
  );
};

export default function PinterestGrid() {
  const pins = generatePins(50);

  return (
    <MasonryFlashList
      data={pins}
      numColumns={NUM_COLUMNS}
      renderItem={({ item }) => <PinCard item={item} />}
      estimatedItemSize={200}
      contentContainerStyle={styles.container}
    />
  );
}

const styles = StyleSheet.create({
  container: {
    padding: GAP,
  },
  pinCard: {
    margin: GAP / 2,
    backgroundColor: '#fff',
    borderRadius: 12,
    overflow: 'hidden',
    elevation: 2,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
  },
  pinImage: {
    width: '100%',
  },
  pinTitle: {
    padding: 8,
    fontSize: 14,
    fontWeight: '500',
  },
});

MasonryFlashList Props

<MasonryFlashList
  // All FlashList props work, plus:
  
  numColumns={2}                    // Required for masonry
  
  // Optional: Customize item placement
  getColumnFlex={(items, index, maxColumns) => {
    // Return flex value for each column
    // Higher value = wider column
    return 1; // Equal width columns
  }}
  
  // Optional: Override individual item layout
  overrideItemLayout={(layout, item, index, maxColumns, extraData) => {
    // You can still override sizes if needed
    layout.size = calculateHeight(item);
  }}
  
  // Optimize for images
  optimizeItemArrangement={true}    // Default: true
  // Arranges items to minimize height differences between columns
/>

⚠️ Masonry Limitations

  • Horizontal not supported: Masonry only works vertically
  • No spans: Items can't span multiple columns
  • Different recycling: Cells recycle within columns, not globally
  • Height calculation: You should know item heights beforehand for best results

When to Use FlashList

FlashList isn't always the right choice. Here's a decision framework to help you choose between FlatList, SectionList, and FlashList.

Decision Framework

flowchart TD
    A["Need a scrollable list?"] -->|"Yes"| B{"How many items?"}
    A -->|"No"| ScrollView["Use ScrollView"]
    
    B -->|"< 50 items"| C{"Performance issues?"}
    B -->|"50-500 items"| D{"Need max performance?"}
    B -->|"500+ items"| FlashList1["Use FlashList"]
    
    C -->|"No"| FlatList1["FlatList is fine"]
    C -->|"Yes"| D
    
    D -->|"No"| E{"Need sections?"}
    D -->|"Yes"| FlashList2["Use FlashList"]
    
    E -->|"No"| FlatList2["Use FlatList"]
    E -->|"Yes"| F{"Simple sections?"}
    
    F -->|"Yes"| SectionList["Use SectionList"]
    F -->|"No, need performance"| FlashList3["Use FlashList
with getItemType"] style FlashList1 fill:#c8e6c9 style FlashList2 fill:#c8e6c9 style FlashList3 fill:#c8e6c9 style FlatList1 fill:#bbdefb style FlatList2 fill:#bbdefb style SectionList fill:#fff3cd

Use FlashList When...

✅ FlashList is the Best Choice

  • Large datasets: 500+ items or infinitely scrolling feeds
  • Complex items: Each item has images, multiple text views, nested components
  • Fast scrolling: Users scroll rapidly through content
  • Slow devices: Need to support older/cheaper phones
  • Masonry layouts: Pinterest-style variable-height grids
  • Mixed content: Different item types in the same list
  • Memory pressure: App is memory-constrained

Stick with FlatList When...

💡 FlatList is Sufficient

  • Small lists: Under 100 simple items
  • Local state in items: Each item manages its own state
  • Animations on items: Complex per-item animations that depend on mount/unmount
  • Legacy code: Migration effort outweighs benefits
  • No performance issues: If it ain't broke, don't fix it
  • Specific FlatList features: Need windowSize tuning, etc.

Use SectionList When...

📑 SectionList is Best

  • Grouped data: Natural sections with headers (contacts A-Z)
  • Sticky section headers: Native sticky header behavior needed
  • Section footers: Need to render content after each section
  • Performance isn't critical: List is reasonably sized

Note: FlashList can handle sections with getItemType, but SectionList's API is cleaner for truly sectioned data.

Performance Comparison

📊 Approximate Performance by List Type

Metric FlatList SectionList FlashList
Initial render ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
Scroll (simple items) ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
Scroll (complex items) ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐
Memory usage ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
Fast scroll (blank areas) ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐
Ease of use ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐

FlashList + Sections

FlashList doesn't have a built-in SectionList equivalent, but you can implement sections:

interface ListItem {
  type: 'header' | 'item';
  id: string;
  title?: string;
  data?: ItemData;
}

// Flatten sections into a single array with type markers
const flattenSections = (sections: Section[]): ListItem[] => {
  return sections.flatMap(section => [
    { type: 'header', id: `header-${section.title}`, title: section.title },
    ...section.data.map(item => ({ 
      type: 'item', 
      id: item.id, 
      data: item 
    })),
  ]);
};

function SectionedFlashList({ sections }) {
  const flatData = useMemo(() => flattenSections(sections), [sections]);

  return (
    <FlashList
      data={flatData}
      renderItem={({ item }) => {
        if (item.type === 'header') {
          return <SectionHeader title={item.title} />;
        }
        return <ListItem data={item.data} />;
      }}
      getItemType={(item) => item.type}
      estimatedItemSize={60}
      stickyHeaderIndices={
        flatData
          .map((item, index) => item.type === 'header' ? index : null)
          .filter(index => index !== null) as number[]
      }
    />
  );
}

Hybrid Approach

// Use different components based on list size
function SmartList({ items, ...props }) {
  // For small lists, FlatList is fine
  if (items.length < 100) {
    return <FlatList data={items} {...props} />;
  }
  
  // For large lists, use FlashList
  return (
    <FlashList 
      data={items} 
      estimatedItemSize={50}
      {...props} 
    />
  );
}

Hands-On Exercises

Practice using FlashList with these real-world scenarios.

Exercise 1: Migrate a FlatList to FlashList

Migrate this FlatList to FlashList and fix any issues.

// Current FlatList implementation - migrate this!
function ContactList() {
  const [contacts, setContacts] = useState(generateContacts(500));
  const [selectedIds, setSelectedIds] = useState(new Set());

  return (
    <FlatList
      data={contacts}
      keyExtractor={(item) => item.id}
      getItemLayout={(data, index) => ({
        length: 72,
        offset: 72 * index,
        index,
      })}
      renderItem={({ item }) => (
        <ContactItem
          contact={item}
          isSelected={selectedIds.has(item.id)}
          onToggleSelect={(id) => {
            setSelectedIds(prev => {
              const next = new Set(prev);
              if (next.has(id)) next.delete(id);
              else next.add(id);
              return next;
            });
          }}
        />
      )}
    />
  );
}

function ContactItem({ contact, isSelected, onToggleSelect }) {
  const [isExpanded, setIsExpanded] = useState(false);
  
  return (
    <Pressable onPress={() => onToggleSelect(contact.id)}>
      <View style={[styles.item, isSelected && styles.selected]}>
        <Text>{contact.name}</Text>
        <Pressable onPress={() => setIsExpanded(!isExpanded)}>
          <Text>{isExpanded ? '▲' : '▼'}</Text>
        </Pressable>
      </View>
      {isExpanded && <Text>{contact.details}</Text>}
    </Pressable>
  );
}
💡 Hint

Watch out for: the local isExpanded state in ContactItem will cause bugs with recycling. Lift it up to the parent component like selectedIds.

✅ Solution
import { FlashList } from '@shopify/flash-list';
import React, { useState, useCallback, memo } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';

interface Contact {
  id: string;
  name: string;
  details: string;
}

// Memoized item component - no local state!
const ContactItem = memo(function ContactItem({ 
  contact, 
  isSelected, 
  isExpanded,
  onToggleSelect,
  onToggleExpand,
}: { 
  contact: Contact;
  isSelected: boolean;
  isExpanded: boolean;
  onToggleSelect: (id: string) => void;
  onToggleExpand: (id: string) => void;
}) {
  return (
    <Pressable onPress={() => onToggleSelect(contact.id)}>
      <View style={[styles.item, isSelected && styles.selected]}>
        <Text style={styles.name}>{contact.name}</Text>
        <Pressable 
          onPress={() => onToggleExpand(contact.id)}
          hitSlop={8}
        >
          <Text style={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</Text>
        </Pressable>
      </View>
      {isExpanded && (
        <View style={styles.details}>
          <Text>{contact.details}</Text>
        </View>
      )}
    </Pressable>
  );
});

const generateContacts = (count: number): Contact[] =>
  Array.from({ length: count }, (_, i) => ({
    id: String(i),
    name: `Contact ${i + 1}`,
    details: `Email: contact${i + 1}@example.com\nPhone: 555-${String(i).padStart(4, '0')}`,
  }));

export default function ContactList() {
  const [contacts] = useState(() => generateContacts(500));
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

  const toggleSelect = useCallback((id: string) => {
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  // Lifted expand state to parent!
  const toggleExpand = useCallback((id: string) => {
    setExpandedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  const renderItem = useCallback(({ item }: { item: Contact }) => (
    <ContactItem
      contact={item}
      isSelected={selectedIds.has(item.id)}
      isExpanded={expandedIds.has(item.id)}
      onToggleSelect={toggleSelect}
      onToggleExpand={toggleExpand}
    />
  ), [selectedIds, expandedIds, toggleSelect, toggleExpand]);

  return (
    <FlashList
      data={contacts}
      keyExtractor={(item) => item.id}
      estimatedItemSize={72}
      extraData={{ selectedIds, expandedIds }}
      renderItem={renderItem}
    />
  );
}

const styles = StyleSheet.create({
  item: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    padding: 16,
    height: 72,
    backgroundColor: '#fff',
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  selected: {
    backgroundColor: '#e3f2fd',
  },
  name: {
    fontSize: 16,
    flex: 1,
  },
  expandIcon: {
    fontSize: 12,
    color: '#666',
    padding: 8,
  },
  details: {
    padding: 16,
    paddingTop: 0,
    backgroundColor: '#fafafa',
  },
});

Key changes:

  1. Changed FlatList to FlashList
  2. Replaced getItemLayout with estimatedItemSize
  3. Lifted isExpanded state from ContactItem to parent
  4. Added expandedIds Set to track expanded items
  5. Updated extraData to include both state Sets
  6. Memoized ContactItem with memo()
  7. Memoized callbacks with useCallback

Exercise 2: Product Catalog with Mixed Types

Build a product catalog with regular products and featured products (larger cards).

Requirements:

  • Regular products: 100px height
  • Featured products: 200px height with larger image
  • Use getItemType for efficient recycling
  • Use overrideItemLayout to specify heights
  • Mixed data with ~20% featured products
💡 Hint

Create a Product interface with a featured boolean. Use getItemType to return "featured" or "regular". Use overrideItemLayout to set the correct height.

✅ Solution
import { FlashList } from '@shopify/flash-list';
import React, { memo, useCallback } from 'react';
import { View, Text, Image, StyleSheet, Pressable } from 'react-native';

interface Product {
  id: string;
  name: string;
  price: number;
  image: string;
  featured: boolean;
}

const REGULAR_HEIGHT = 100;
const FEATURED_HEIGHT = 200;

// Generate mixed product data
const generateProducts = (count: number): Product[] =>
  Array.from({ length: count }, (_, i) => ({
    id: String(i),
    name: `Product ${i + 1}`,
    price: 9.99 + Math.random() * 90,
    image: `https://picsum.photos/seed/${i}/200`,
    featured: Math.random() < 0.2, // 20% featured
  }));

const RegularProduct = memo(({ product }: { product: Product }) => (
  <View style={styles.regularCard}>
    <Image source={{ uri: product.image }} style={styles.regularImage} />
    <View style={styles.productInfo}>
      <Text style={styles.productName} numberOfLines={2}>{product.name}</Text>
      <Text style={styles.productPrice}>${product.price.toFixed(2)}</Text>
    </View>
  </View>
));

const FeaturedProduct = memo(({ product }: { product: Product }) => (
  <View style={styles.featuredCard}>
    <View style={styles.featuredBadge}>
      <Text style={styles.badgeText}>⭐ Featured</Text>
    </View>
    <Image source={{ uri: product.image }} style={styles.featuredImage} />
    <View style={styles.featuredInfo}>
      <Text style={styles.featuredName}>{product.name}</Text>
      <Text style={styles.featuredPrice}>${product.price.toFixed(2)}</Text>
    </View>
  </View>
));

export default function ProductCatalog() {
  const products = React.useMemo(() => generateProducts(100), []);

  const renderItem = useCallback(({ item }: { item: Product }) => {
    if (item.featured) {
      return <FeaturedProduct product={item} />;
    }
    return <RegularProduct product={item} />;
  }, []);

  // Calculate average for estimatedItemSize
  const featuredCount = products.filter(p => p.featured).length;
  const avgHeight = (
    (featuredCount * FEATURED_HEIGHT) + 
    ((products.length - featuredCount) * REGULAR_HEIGHT)
  ) / products.length;

  return (
    <FlashList<Product>
      data={products}
      renderItem={renderItem}
      keyExtractor={(item) => item.id}
      estimatedItemSize={avgHeight}
      getItemType={(item) => (item.featured ? 'featured' : 'regular')}
      overrideItemLayout={(layout, item) => {
        layout.size = item.featured ? FEATURED_HEIGHT : REGULAR_HEIGHT;
      }}
      contentContainerStyle={styles.container}
    />
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 12,
  },
  regularCard: {
    height: REGULAR_HEIGHT - 12, // Account for margin
    flexDirection: 'row',
    backgroundColor: '#fff',
    borderRadius: 8,
    marginBottom: 12,
    overflow: 'hidden',
    elevation: 2,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
  },
  regularImage: {
    width: 88,
    height: '100%',
  },
  productInfo: {
    flex: 1,
    padding: 12,
    justifyContent: 'center',
  },
  productName: {
    fontSize: 14,
    fontWeight: '500',
  },
  productPrice: {
    fontSize: 16,
    fontWeight: 'bold',
    color: '#6200ee',
    marginTop: 4,
  },
  featuredCard: {
    height: FEATURED_HEIGHT - 12,
    backgroundColor: '#fff',
    borderRadius: 12,
    marginBottom: 12,
    overflow: 'hidden',
    elevation: 4,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.15,
    shadowRadius: 4,
    borderWidth: 2,
    borderColor: '#ffc107',
  },
  featuredBadge: {
    position: 'absolute',
    top: 8,
    right: 8,
    backgroundColor: '#ffc107',
    paddingHorizontal: 8,
    paddingVertical: 4,
    borderRadius: 4,
    zIndex: 1,
  },
  badgeText: {
    fontSize: 12,
    fontWeight: 'bold',
    color: '#333',
  },
  featuredImage: {
    width: '100%',
    height: 120,
  },
  featuredInfo: {
    padding: 12,
  },
  featuredName: {
    fontSize: 18,
    fontWeight: 'bold',
  },
  featuredPrice: {
    fontSize: 20,
    fontWeight: 'bold',
    color: '#6200ee',
    marginTop: 4,
  },
});

Exercise 3: Pinterest-Style Masonry Gallery

Build an image gallery with variable-height images using MasonryFlashList.

Requirements:

  • 2-column masonry layout
  • Images with varying aspect ratios
  • Image title overlay at the bottom
  • Tap to "like" with heart indicator
  • Pull-to-refresh to load new images
  • Infinite scroll to load more
💡 Hint

Use MasonryFlashList with numColumns={2}. Calculate image height from aspect ratio. Track liked images in parent state using a Set.

✅ Solution
import { MasonryFlashList } from '@shopify/flash-list';
import React, { useState, useCallback, memo } from 'react';
import {
  View,
  Text,
  Image,
  Pressable,
  Dimensions,
  StyleSheet,
  RefreshControl,
  ActivityIndicator,
} from 'react-native';

interface Pin {
  id: string;
  imageUrl: string;
  title: string;
  aspectRatio: number; // height / width
}

const SCREEN_WIDTH = Dimensions.get('window').width;
const NUM_COLUMNS = 2;
const GAP = 8;
const COLUMN_WIDTH = (SCREEN_WIDTH - GAP * 3) / NUM_COLUMNS;

// Generate random pins
const generatePins = (startId: number, count: number): Pin[] =>
  Array.from({ length: count }, (_, i) => {
    const id = startId + i;
    const aspectRatio = 0.6 + Math.random() * 0.8; // 0.6 to 1.4
    return {
      id: String(id),
      imageUrl: `https://picsum.photos/seed/${id}/400/${Math.floor(400 * aspectRatio)}`,
      title: `Beautiful Photo ${id + 1}`,
      aspectRatio,
    };
  });

const PinCard = memo(function PinCard({
  pin,
  isLiked,
  onToggleLike,
}: {
  pin: Pin;
  isLiked: boolean;
  onToggleLike: (id: string) => void;
}) {
  const imageHeight = COLUMN_WIDTH * pin.aspectRatio;

  return (
    <Pressable 
      style={styles.pinCard}
      onPress={() => onToggleLike(pin.id)}
    >
      <Image
        source={{ uri: pin.imageUrl }}
        style={[styles.pinImage, { height: imageHeight }]}
        resizeMode="cover"
      />
      <View style={styles.overlay}>
        <Text style={styles.pinTitle} numberOfLines={2}>
          {pin.title}
        </Text>
        <Text style={styles.heartIcon}>
          {isLiked ? '❤️' : '🤍'}
        </Text>
      </View>
    </Pressable>
  );
});

export default function MasonryGallery() {
  const [pins, setPins] = useState<Pin[]>(() => generatePins(0, 20));
  const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
  const [refreshing, setRefreshing] = useState(false);
  const [loadingMore, setLoadingMore] = useState(false);

  const toggleLike = useCallback((id: string) => {
    setLikedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  }, []);

  const handleRefresh = useCallback(async () => {
    setRefreshing(true);
    // Simulate API call
    await new Promise(r => setTimeout(r, 1000));
    setPins(generatePins(Math.floor(Math.random() * 1000), 20));
    setRefreshing(false);
  }, []);

  const handleLoadMore = useCallback(async () => {
    if (loadingMore) return;
    
    setLoadingMore(true);
    // Simulate API call
    await new Promise(r => setTimeout(r, 800));
    setPins(prev => [...prev, ...generatePins(prev.length, 10)]);
    setLoadingMore(false);
  }, [loadingMore]);

  const renderItem = useCallback(
    ({ item }: { item: Pin }) => (
      <PinCard
        pin={item}
        isLiked={likedIds.has(item.id)}
        onToggleLike={toggleLike}
      />
    ),
    [likedIds, toggleLike]
  );

  const renderFooter = useCallback(() => {
    if (!loadingMore) return null;
    return (
      <View style={styles.footer}>
        <ActivityIndicator color="#6200ee" />
      </View>
    );
  }, [loadingMore]);

  return (
    <MasonryFlashList<Pin>
      data={pins}
      numColumns={NUM_COLUMNS}
      renderItem={renderItem}
      keyExtractor={(item) => item.id}
      estimatedItemSize={200}
      extraData={likedIds}
      contentContainerStyle={styles.container}
      refreshControl={
        <RefreshControl
          refreshing={refreshing}
          onRefresh={handleRefresh}
          tintColor="#6200ee"
          colors={['#6200ee']}
        />
      }
      onEndReached={handleLoadMore}
      onEndReachedThreshold={0.5}
      ListFooterComponent={renderFooter}
    />
  );
}

const styles = StyleSheet.create({
  container: {
    padding: GAP,
  },
  pinCard: {
    margin: GAP / 2,
    borderRadius: 12,
    overflow: 'hidden',
    backgroundColor: '#f0f0f0',
  },
  pinImage: {
    width: '100%',
  },
  overlay: {
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
    flexDirection: 'row',
    alignItems: 'flex-end',
    justifyContent: 'space-between',
    padding: 8,
    paddingTop: 24,
    background: 'linear-gradient(transparent, rgba(0,0,0,0.6))',
    backgroundColor: 'rgba(0,0,0,0.4)',
  },
  pinTitle: {
    flex: 1,
    color: '#fff',
    fontSize: 12,
    fontWeight: '600',
    marginRight: 8,
  },
  heartIcon: {
    fontSize: 18,
  },
  footer: {
    paddingVertical: 20,
    alignItems: 'center',
  },
});

Exercise 4: Performance Comparison

Create identical lists with FlatList and FlashList, then compare performance.

Tasks:

  1. Create a list with 1000 complex items (image + multiple text views)
  2. Build the same list with both FlatList and FlashList
  3. Enable the performance monitor
  4. Scroll rapidly through both lists
  5. Note the FPS differences and blank areas

Record your findings:

  • FlatList JS FPS during scroll: ___
  • FlashList JS FPS during scroll: ___
  • FlatList blank areas visible: Yes / No
  • FlashList blank areas visible: Yes / No
💡 Expected Results

On most devices, you should see:

  • FlatList: 30-50 FPS during fast scroll, visible blank areas
  • FlashList: 55-60 FPS during fast scroll, minimal/no blank areas

The difference is most noticeable on:

  • Older/lower-end devices
  • Complex items with images
  • Very fast scrolling

Summary

You've learned FlashList—the high-performance alternative to FlatList that can transform your app's scrolling experience.

🎯 Key Takeaways

  • Cell recycling: FlashList reuses cells instead of mount/unmount, dramatically reducing overhead
  • estimatedItemSize: Required prop—use average item height for best results
  • overrideItemLayout: For known variable heights, specify exact sizes
  • getItemType: Essential for mixed content lists to recycle efficiently
  • No local state: Avoid useState in list items—lift state up instead
  • MasonryFlashList: Built-in Pinterest-style layouts
  • Performance warnings: FlashList tells you when something's wrong
  • Easy migration: API is nearly identical to FlatList

FlashList Quick Reference

flowchart TB
    subgraph Required["Required Props"]
        R1["data"]
        R2["renderItem"]
        R3["estimatedItemSize"]
    end
    
    subgraph Recommended["Recommended Props"]
        O1["keyExtractor"]
        O2["getItemType
(for mixed types)"] O3["overrideItemLayout
(for known heights)"] end subgraph Gotchas["Avoid"] G1["❌ Local state in items"] G2["❌ Animations without reset"] G3["❌ Wrong estimatedItemSize"] end style R1 fill:#c8e6c9 style R2 fill:#c8e6c9 style R3 fill:#c8e6c9 style O1 fill:#bbdefb style O2 fill:#bbdefb style O3 fill:#bbdefb style G1 fill:#ffcdd2 style G2 fill:#ffcdd2 style G3 fill:#ffcdd2

Props Comparison

FlatList Prop FlashList Equivalent
getItemLayout estimatedItemSize + overrideItemLayout
windowSize Automatic (use drawDistance)
maxToRenderPerBatch Automatic
initialNumToRender drawDistance
(none) getItemType (new feature)

🚀 What's Next?

Congratulations! You've completed Module 5: Lists and Performance! You now have a complete toolkit for building high-performance lists in React Native—from basic FlatList to optimized FlashList and everything in between.

Next up is Module 6: Navigation, where you'll learn to build multi-screen apps with React Navigation, including stack navigation, tabs, drawers, and deep linking.

Module 5 Recap

📚 What You Learned in Module 5

  1. Lesson 5.1: Why ScrollView Isn't Enough — The problem with rendering all items
  2. Lesson 5.2: FlatList Fundamentals — Core props, renderItem, keyExtractor
  3. Lesson 5.3: FlatList Performance — getItemLayout, memoization, optimization
  4. Lesson 5.4: FlatList Features — Refresh, infinite scroll, scroll methods
  5. Lesson 5.5: SectionList — Grouped data with section headers
  6. Lesson 5.6: FlashList — Maximum performance with cell recycling