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.
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:
useStatevalues carry over to new items - Refs persist:
useRefvalues 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
estimatedItemSizeprop - ☐ Remove
getItemLayout(useoverrideItemLayoutif variable heights) - ☐ Check for local state in list items (refactor if found)
- ☐ Add
getItemTypeif 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}
/>
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:
- Changed FlatList to FlashList
- Replaced
getItemLayoutwithestimatedItemSize - Lifted
isExpandedstate from ContactItem to parent - Added
expandedIdsSet to track expanded items - Updated
extraDatato include both state Sets - Memoized ContactItem with
memo() - 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
getItemTypefor efficient recycling - Use
overrideItemLayoutto 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:
- Create a list with 1000 complex items (image + multiple text views)
- Build the same list with both FlatList and FlashList
- Enable the performance monitor
- Scroll rapidly through both lists
- 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
- Lesson 5.1: Why ScrollView Isn't Enough — The problem with rendering all items
- Lesson 5.2: FlatList Fundamentals — Core props, renderItem, keyExtractor
- Lesson 5.3: FlatList Performance — getItemLayout, memoization, optimization
- Lesson 5.4: FlatList Features — Refresh, infinite scroll, scroll methods
- Lesson 5.5: SectionList — Grouped data with section headers
- Lesson 5.6: FlashList — Maximum performance with cell recycling