Module 5: Lists and Performance
FlatList Performance Optimization
Achieving buttery-smooth 60 FPS scrolling with large datasets
π― Learning Objectives
- Understand how FlatList's virtualization engine works internally
- Master the key performance props: getItemLayout, windowSize, and more
- Implement proper memoization strategies for list items
- Diagnose and fix common performance bottlenecks
- Use React Native's performance tools to measure improvements
- Know when to reach for advanced solutions like FlashList
π Table of Contents
- The Performance Mental Model
- Measuring Performance
- getItemLayout: The Biggest Win
- windowSize and Render Batching
- Memoization Strategies
- Avoiding Unnecessary Re-renders
- Image Optimization in Lists
- removeClippedSubviews
- Advanced Performance Props
- Performance Optimization Checklist
- Hands-On Exercises
- Summary
The Performance Mental Model
Before diving into optimization techniques, let's understand what FlatList is doing under the hood. This mental model will help you make informed decisions about which optimizations to apply.
The Virtualization Loop
FlatList continuously runs a virtualization loop as the user scrolls:
flowchart TB
subgraph Loop["Virtualization Loop (runs on scroll)"]
A["1. User scrolls"] --> B["2. Calculate visible range"]
B --> C["3. Determine items to render"]
C --> D{"4. Items changed?"}
D -->|Yes| E["5. Mount new items"]
D -->|No| F["6. Skip render"]
E --> G["7. Unmount off-screen items"]
G --> H["8. Update spacers"]
F --> H
H --> I["9. Commit to screen"]
end
style A fill:#e3f2fd
style E fill:#fff3cd
style G fill:#ffebee
style I fill:#e8f5e9
Each step in this loop has a cost. The goal of optimization is to make each step as fast as possible, and to skip steps when they're not needed.
The Two Threads
React Native runs on two main threads, and FlatList performance problems can occur on either:
π The 16ms Budget
For 60 FPS scrolling, each frame must complete in under 16.67 milliseconds. If your renderItem function takes 20ms, you'll drop frames. If layout calculations take 30ms, the UI freezes. Every optimization is about staying under this budget.
Where Performance Problems Hide
FlatList performance issues typically fall into these categories:
- Expensive renderItem: Your render function does too much work
- Unnecessary re-renders: Items re-render when they shouldn't
- Layout thrashing: FlatList can't predict item sizes
- Too many items in memory: Rendering window is too large
- Heavy images: Large images without proper caching
- Inline functions: New function references on every render
We'll address each of these throughout this lesson.
Measuring Performance
Before optimizing, you need to measure. React Native provides several tools for performance analysis.
The Performance Monitor
The built-in Performance Monitor shows real-time FPS for both threads:
// Enable in development:
// 1. Shake device (or Cmd+D in iOS Simulator, Cmd+M in Android)
// 2. Select "Show Perf Monitor"
// What to look for:
// - JS FPS: Should stay near 60
// - UI FPS: Should stay near 60
// - RAM: Should stay stable (not continuously growing)
π‘ Reading the Performance Monitor
- Both at 60: Your list is performing well
- JS drops, UI stays 60: JavaScript is the bottleneck (renderItem, state updates)
- UI drops, JS stays 60: Native rendering is the bottleneck (complex layouts, images)
- Both drop: Severe problemβlikely both threads are overwhelmed
React DevTools Profiler
The React DevTools Profiler shows exactly which components render and why:
// Install React DevTools
// npm install -g react-devtools
// Run in terminal
// react-devtools
// In your app, the Profiler tab shows:
// - What components rendered
// - How long each render took
// - Why components rendered (props changed, state changed, etc.)
// Pro tip: Look for list items that re-render when they shouldn't
Console Timing
For quick measurements, use console timing:
// Measure renderItem performance
const renderItem = useCallback(({ item }) => {
console.time(`render-${item.id}`);
const result = (
<ItemComponent item={item} />
);
console.timeEnd(`render-${item.id}`);
return result;
}, []);
// Measure initial render
useEffect(() => {
console.time('list-mount');
return () => console.timeEnd('list-mount');
}, []);
Systrace (Advanced)
For deep performance analysis, use Systrace:
# Android only
# Start trace
adb shell "atrace --async_start -b 8192 -c view input sched freq"
# Interact with your app (scroll the list)
# Stop trace
adb shell "atrace --async_stop -o /data/local/tmp/trace.txt"
# Pull and analyze
adb pull /data/local/tmp/trace.txt
# Open in chrome://tracing
getItemLayout: The Biggest Win
If you implement only one optimization, make it getItemLayout. This prop tells FlatList the exact size and position of every item without measuring them.
The Problem: Layout Measurement
By default, FlatList doesn't know how tall your items are until they render. This causes:
- Scroll position jumps when items above the viewport resize
- Incorrect scroll indicators because total height is estimated
- scrollToIndex failures because positions are unknown
- Extra layout passes as items are measured
flowchart LR
subgraph Without["Without getItemLayout"]
W1["Render item"] --> W2["Measure height"]
W2 --> W3["Update positions"]
W3 --> W4["Re-layout list"]
W4 --> W5["Possible scroll jump"]
end
subgraph With["With getItemLayout"]
G1["Calculate position"] --> G2["Render at position"]
end
Without --> |"Slower, janky"| Result1["π"]
With --> |"Instant, smooth"| Result2["π"]
style W5 fill:#ffcdd2
style G2 fill:#c8e6c9
Implementing getItemLayout
For items with consistent height, getItemLayout is straightforward:
const ITEM_HEIGHT = 80;
<FlatList
data={items}
renderItem={renderItem}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT, // Height of the item
offset: ITEM_HEIGHT * index, // Distance from top
index, // The index
})}
/>
With Separators
If you have separators, include them in the calculation:
const ITEM_HEIGHT = 80;
const SEPARATOR_HEIGHT = 1;
<FlatList
data={items}
renderItem={renderItem}
ItemSeparatorComponent={() => (
<View style={{ height: SEPARATOR_HEIGHT, backgroundColor: '#eee' }} />
)}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index,
index,
})}
/>
With Headers
Headers add complexityβyou need to account for their height:
const ITEM_HEIGHT = 80;
const HEADER_HEIGHT = 120;
<FlatList
data={items}
renderItem={renderItem}
ListHeaderComponent={<Header />}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: HEADER_HEIGHT + (ITEM_HEIGHT * index),
index,
})}
/>
Full Example with All Elements
const ITEM_HEIGHT = 72;
const SEPARATOR_HEIGHT = 1;
const HEADER_HEIGHT = 100;
const FOOTER_HEIGHT = 50;
function OptimizedList({ data }) {
const getItemLayout = useCallback(
(data: Item[] | null | undefined, index: number) => {
// Calculate offset: header + (item + separator) * index
const offset = HEADER_HEIGHT + (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index;
return {
length: ITEM_HEIGHT,
offset,
index,
};
},
[]
);
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
getItemLayout={getItemLayout}
ListHeaderComponent={<View style={{ height: HEADER_HEIGHT }} />}
ListFooterComponent={<View style={{ height: FOOTER_HEIGHT }} />}
ItemSeparatorComponent={() => (
<View style={{ height: SEPARATOR_HEIGHT }} />
)}
/>
);
}
β οΈ When You Can't Use getItemLayout
If your items have variable heights (like a chat with different message lengths), you can't use getItemLayout. In these cases:
- Consider fixed-height item containers with scrollable content inside
- Use
estimatedItemSizein FlashList (covered later) - Accept the measurement cost and optimize other areas
scrollToIndex with getItemLayout
One major benefit: scrollToIndex works perfectly when you have getItemLayout:
const flatListRef = useRef<FlatList>(null);
const scrollToItem = (index: number) => {
// Without getItemLayout, this might fail with:
// "scrollToIndex should be used in conjunction with getItemLayout"
flatListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0.5, // Center the item on screen
});
};
// Handle scrollToIndex failures (for lists without getItemLayout)
<FlatList
ref={flatListRef}
onScrollToIndexFailed={(info) => {
// Wait for items to render, then try again
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: info.index,
animated: true,
});
}, 100);
}}
/>
windowSize and Render Batching
FlatList renders more items than are visible to ensure smooth scrolling. These props control how much is rendered and when.
Understanding windowSize
The windowSize prop determines how many "screens" worth of content to render:
// windowSize = number of viewport heights to render
// Default is 21 (10 above + 1 visible + 10 below)
// Lower value = less memory, but may show blank areas during fast scroll
<FlatList
data={items}
renderItem={renderItem}
windowSize={5} // 2 above + 1 visible + 2 below
/>
// Recommendation by use case:
// - Complex items, slow renders: windowSize={21} (default)
// - Simple items, fast renders: windowSize={5-11}
// - Very simple items: windowSize={3-5}
Render Batching Props
These props control how FlatList batches rendering work:
<FlatList
data={items}
renderItem={renderItem}
// How many items to render in each batch
// Lower = faster initial render, slower scroll catch-up
maxToRenderPerBatch={10} // Default: 10
// How many items to render initially
// Higher = slower initial render, but less blank content
initialNumToRender={10} // Default: 10
// Minimum time between batch renders (ms)
// Higher = more responsive to touch, slower list population
updateCellsBatchingPeriod={50} // Default: 50
/>
β Recommended Starting Values
| Scenario | Settings |
|---|---|
| Simple items (text only) |
windowSize={5}maxToRenderPerBatch={20}
|
| Medium items (text + image) |
windowSize={11}maxToRenderPerBatch={10}
|
| Complex items (heavy computation) |
windowSize={21}maxToRenderPerBatch={5}
|
Memoization Strategies
Memoization prevents unnecessary re-computation. In FlatList, proper memoization can make the difference between 30 FPS and 60 FPS scrolling.
Memoizing Item Components
Wrap your item components with React.memo to prevent re-renders when props haven't changed:
// β Without memo: Re-renders on every parent render
const ListItem = ({ item, onPress }) => {
console.log(`Rendering item ${item.id}`); // Logs constantly!
return (
<Pressable onPress={() => onPress(item.id)}>
<Text>{item.name}</Text>
</Pressable>
);
};
// β
With memo: Only re-renders when props change
const ListItem = memo(function ListItem({ item, onPress }) {
console.log(`Rendering item ${item.id}`); // Logs only when needed
return (
<Pressable onPress={() => onPress(item.id)}>
<Text>{item.name}</Text>
</Pressable>
);
});
Custom Comparison Functions
By default, memo does shallow comparison. For complex props, provide a custom comparison:
interface ItemProps {
item: {
id: string;
name: string;
metadata: {
views: number;
likes: number;
};
};
onPress: (id: string) => void;
}
// Custom comparison: only re-render if id or name changed
// Ignore metadata changes and function reference changes
const ListItem = memo(
function ListItem({ item, onPress }: ItemProps) {
return (
<Pressable onPress={() => onPress(item.id)}>
<Text>{item.name}</Text>
</Pressable>
);
},
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
// Return false if props differ (trigger re-render)
return (
prevProps.item.id === nextProps.item.id &&
prevProps.item.name === nextProps.item.name
);
}
);
Memoizing renderItem
Always memoize your renderItem function to maintain stable references:
// β New function on every render
function BadList({ data }) {
return (
<FlatList
data={data}
renderItem={({ item }) => <ListItem item={item} />} // New ref every render!
/>
);
}
// β
Stable function reference
function GoodList({ data }) {
const renderItem = useCallback(
({ item }) => <ListItem item={item} />,
[] // Empty deps = never changes
);
return (
<FlatList
data={data}
renderItem={renderItem}
/>
);
}
// β
With dependencies
function ListWithHandlers({ data, onItemPress }) {
const renderItem = useCallback(
({ item }) => (
<ListItem item={item} onPress={onItemPress} />
),
[onItemPress] // Re-create if handler changes
);
return (
<FlatList
data={data}
renderItem={renderItem}
/>
);
}
Memoizing Other Props
function OptimizedList({ data, filterActive }) {
// Memoize keyExtractor
const keyExtractor = useCallback(
(item: Item) => item.id,
[]
);
// Memoize getItemLayout
const getItemLayout = useCallback(
(data: Item[] | null | undefined, index: number) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[]
);
// Memoize components
const ListHeader = useMemo(
() => <Header filterActive={filterActive} />,
[filterActive]
);
const ItemSeparator = useCallback(
() => <View style={styles.separator} />,
[]
);
const ListEmpty = useMemo(
() => <EmptyState />,
[]
);
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
getItemLayout={getItemLayout}
ListHeaderComponent={ListHeader}
ItemSeparatorComponent={ItemSeparator}
ListEmptyComponent={ListEmpty}
/>
);
}
flowchart TD
subgraph Without["Without Memoization"]
W1["Parent renders"] --> W2["New renderItem fn"]
W2 --> W3["New keyExtractor fn"]
W3 --> W4["FlatList sees 'new' props"]
W4 --> W5["Re-renders all visible items"]
end
subgraph With["With Memoization"]
M1["Parent renders"] --> M2["Same renderItem ref"]
M2 --> M3["Same keyExtractor ref"]
M3 --> M4["FlatList sees same props"]
M4 --> M5["Skips unnecessary work"]
end
style W5 fill:#ffcdd2
style M5 fill:#c8e6c9
Avoiding Unnecessary Re-renders
Even with memoization, certain patterns can cause items to re-render when they shouldn't. Let's identify and fix them.
The Inline Function Trap
// β Inline function creates new reference every render
<FlatList
data={items}
renderItem={({ item }) => (
<Pressable onPress={() => handlePress(item.id)}>
<Text>{item.name}</Text>
</Pressable>
)}
/>
// β Even with memoized renderItem, inline handler breaks memo
const renderItem = useCallback(({ item }) => (
<MemoizedItem
item={item}
onPress={() => handlePress(item.id)} // New function!
/>
), [handlePress]);
// β
Move handler into the item component
const ListItem = memo(function ListItem({ item, onPress }) {
const handleItemPress = useCallback(() => {
onPress(item.id);
}, [item.id, onPress]);
return (
<Pressable onPress={handleItemPress}>
<Text>{item.name}</Text>
</Pressable>
);
});
// Parent just passes the handler
const handlePress = useCallback((id: string) => {
console.log('Pressed:', id);
}, []);
const renderItem = useCallback(
({ item }) => <ListItem item={item} onPress={handlePress} />,
[handlePress]
);
The Style Object Trap
// β Inline style object creates new reference
<FlatList
data={items}
contentContainerStyle={{ padding: 16 }} // New object every render!
renderItem={({ item }) => (
<View style={{ padding: 12 }}> // New object every render!
<Text>{item.name}</Text>
</View>
)}
/>
// β
Use StyleSheet or memoize
const styles = StyleSheet.create({
container: { padding: 16 },
item: { padding: 12 },
});
<FlatList
data={items}
contentContainerStyle={styles.container}
renderItem={({ item }) => (
<View style={styles.item}>
<Text>{item.name}</Text>
</View>
)}
/>
// For dynamic styles, memoize the combination
const ListItem = memo(function ListItem({ item, isSelected }) {
const itemStyle = useMemo(
() => [styles.item, isSelected && styles.selected],
[isSelected]
);
return (
<View style={itemStyle}>
<Text>{item.name}</Text>
</View>
);
});
The extraData Gotcha
// β Object literal creates new reference every render
<FlatList
data={items}
extraData={{ selectedId, isEditing }} // New object = full re-render!
renderItem={renderItem}
/>
// β
Use primitive value when possible
<FlatList
data={items}
extraData={selectedId}
renderItem={renderItem}
/>
// β
Or memoize the object
const extraData = useMemo(
() => ({ selectedId, isEditing }),
[selectedId, isEditing]
);
<FlatList
data={items}
extraData={extraData}
renderItem={renderItem}
/>
Detecting Re-renders
Add logging to find components that re-render too often:
// Development-only render counter
const ListItem = memo(function ListItem({ item }) {
const renderCount = useRef(0);
renderCount.current += 1;
if (__DEV__) {
console.log(`Item ${item.id} render #${renderCount.current}`);
}
return (
<View>
<Text>{item.name}</Text>
{__DEV__ && (
<Text style={{ color: 'red', fontSize: 10 }}>
Renders: {renderCount.current}
</Text>
)}
</View>
);
});
// If you see high render counts during scrolling,
// there's a memoization problem!
π¨ Common Re-render Causes
| Cause | Fix |
|---|---|
| Inline functions | useCallback |
| Inline style objects | StyleSheet.create |
| New extraData object | useMemo or primitives |
| Un-memoized items | React.memo |
| Context changes | Split contexts or use selectors |
Image Optimization in Lists
Images are often the biggest performance bottleneck in lists. Unoptimized images can cause scroll stuttering, memory pressure, and slow initial renders.
The Image Problem
// β Common mistakes with images in lists
<FlatList
data={posts}
renderItem={({ item }) => (
<View>
{/* No size specified - causes layout thrashing */}
<Image source={{ uri: item.imageUrl }} />
{/* Full-size image - wastes memory */}
<Image
source={{ uri: item.highResUrl }}
style={{ width: 100, height: 100 }}
/>
{/* No placeholder - shows blank while loading */}
<Image source={{ uri: item.imageUrl }} />
</View>
)}
/>
Always Specify Dimensions
// β
Fixed dimensions prevent layout recalculation
const IMAGE_SIZE = 80;
const ListItem = memo(function ListItem({ item }) {
return (
<View style={styles.item}>
<Image
source={{ uri: item.thumbnailUrl }}
style={{
width: IMAGE_SIZE,
height: IMAGE_SIZE,
borderRadius: IMAGE_SIZE / 2,
}}
/>
<Text>{item.name}</Text>
</View>
);
});
// β
For aspect-ratio images, use a fixed container
const PostImage = memo(function PostImage({ uri }) {
return (
<View style={styles.imageContainer}>
<Image
source={{ uri }}
style={styles.image}
resizeMode="cover"
/>
</View>
);
});
const styles = StyleSheet.create({
imageContainer: {
width: '100%',
aspectRatio: 16 / 9, // Container has fixed aspect ratio
backgroundColor: '#f0f0f0', // Placeholder color
},
image: {
width: '100%',
height: '100%',
},
});
Use Thumbnails
// β
Request appropriately sized images
const getImageUrl = (baseUrl: string, size: 'thumb' | 'medium' | 'full') => {
const sizes = {
thumb: '100x100',
medium: '400x400',
full: '1200x1200',
};
return `${baseUrl}?size=${sizes[size]}`;
};
const ListItem = memo(function ListItem({ item }) {
return (
<View style={styles.item}>
<Image
// Request thumbnail for list view
source={{ uri: getImageUrl(item.imageUrl, 'thumb') }}
style={styles.thumbnail}
/>
</View>
);
});
// Full image only on detail screen
const DetailScreen = ({ item }) => (
<Image
source={{ uri: getImageUrl(item.imageUrl, 'full') }}
style={styles.fullImage}
/>
);
Image Caching with expo-image
For production apps, use expo-image instead of the built-in Image component:
# Install expo-image
npx expo install expo-image
import { Image } from 'expo-image';
// expo-image provides:
// - Automatic caching
// - Blur hash placeholders
// - Better memory management
// - Smoother loading transitions
const ListItem = memo(function ListItem({ item }) {
return (
<View style={styles.item}>
<Image
source={{ uri: item.thumbnailUrl }}
style={styles.thumbnail}
// Blur hash for instant placeholder
placeholder={item.blurHash}
// Smooth transition when image loads
transition={200}
// Memory-efficient caching
cachePolicy="memory-disk"
// Content fit mode
contentFit="cover"
/>
<Text>{item.name}</Text>
</View>
);
});
β Image Best Practices for Lists
- Always specify dimensions: Prevents layout thrashing
- Use thumbnails: Request size-appropriate images
- Use expo-image: Better caching and performance
- Add placeholders: Blur hash or solid color
- Lazy load off-screen: FlatList handles this automatically
- Consider recycling: expo-image handles this internally
removeClippedSubviews
The removeClippedSubviews prop is a native-level optimization that detaches off-screen views from the view hierarchy.
// Detach views that are outside the viewport
<FlatList
data={items}
renderItem={renderItem}
removeClippedSubviews={true}
/>
How It Works
flowchart LR
subgraph Default["removeClippedSubviews={false}"]
D1["View 1"] --- D2["View 2"]
D2 --- D3["View 3 (visible)"]
D3 --- D4["View 4 (visible)"]
D4 --- D5["View 5"]
D5 --- D6["View 6"]
end
subgraph Optimized["removeClippedSubviews={true}"]
O3["View 3 (visible)"]
O4["View 4 (visible)"]
X1["Views 1,2,5,6 detached"]
end
style D1 fill:#ffcdd2
style D2 fill:#ffcdd2
style D3 fill:#c8e6c9
style D4 fill:#c8e6c9
style D5 fill:#ffcdd2
style D6 fill:#ffcdd2
style O3 fill:#c8e6c9
style O4 fill:#c8e6c9
style X1 fill:#f5f5f5,stroke-dasharray: 5
β οΈ removeClippedSubviews Caveats
- Android mostly: More impactful on Android than iOS
- Can cause bugs: Some views may not reappear correctly
- Test thoroughly: Especially with complex item layouts
- FlatList default: Already true on Android for FlatList
Recommendation: Leave it at the default unless you have specific issues. Modern React Native and FlatList handle this well internally.
Advanced Performance Props
Beyond the common optimizations, FlatList offers several advanced props for fine-tuning performance in specific scenarios.
initialScrollIndex
Start the list at a specific position without rendering items before it:
// Jump straight to item 50 without rendering 0-49
<FlatList
data={items}
renderItem={renderItem}
initialScrollIndex={50}
getItemLayout={getItemLayout} // Required for initialScrollIndex!
/>
// Use case: Opening a chat at the last message
const MessageList = ({ messages, lastReadIndex }) => (
<FlatList
data={messages}
renderItem={renderMessage}
initialScrollIndex={lastReadIndex}
getItemLayout={getItemLayout}
inverted // Chat style
/>
);
maintainVisibleContentPosition
Prevent scroll jumps when items are added at the top (useful for chat/feed):
// When new messages arrive at top, maintain scroll position
<FlatList
data={messages}
renderItem={renderMessage}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 10, // Auto-scroll if within 10px of top
}}
/>
// Common use case: Pull-to-refresh that adds items
function Feed() {
const [posts, setPosts] = useState(initialPosts);
const onRefresh = async () => {
const newPosts = await fetchNewPosts();
setPosts([...newPosts, ...posts]);
// Without maintainVisibleContentPosition, the list would jump!
};
return (
<FlatList
data={posts}
renderItem={renderPost}
onRefresh={onRefresh}
refreshing={isRefreshing}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
}}
/>
);
}
legacyImplementation (deprecated)
You might see this in older codeβit's deprecated and should be removed:
// β Don't use - deprecated
<FlatList
legacyImplementation={true} // Remove this
/>
// The modern FlatList implementation is superior
disableVirtualization
Disables virtualization entirelyβonly use for debugging:
// β οΈ Only for debugging! Renders ALL items like ScrollView
<FlatList
data={items}
renderItem={renderItem}
disableVirtualization={true} // Don't ship with this!
/>
viewabilityConfig
Fine-tune when items are considered "viewable" for tracking or lazy loading:
// Configure viewability thresholds
const viewabilityConfig = {
// Item is "viewable" when 50% visible for 500ms
viewAreaCoveragePercentThreshold: 50,
minimumViewTime: 500,
// OR use item coverage (how much of item is visible)
// itemVisiblePercentThreshold: 75,
};
const onViewableItemsChanged = useCallback(({ viewableItems, changed }) => {
// Track impressions
viewableItems.forEach(({ item }) => {
analytics.trackImpression(item.id);
});
// Lazy load data for visible items
changed
.filter(({ isViewable }) => isViewable)
.forEach(({ item }) => {
prefetchData(item.id);
});
}, []);
// Keep config reference stable!
const viewabilityConfigCallbackPairs = useRef([
{ viewabilityConfig, onViewableItemsChanged },
]);
<FlatList
data={items}
renderItem={renderItem}
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current}
/>
All Performance Props Reference
π FlatList Performance Props
| Prop | Default | Purpose |
|---|---|---|
getItemLayout |
undefined | Skip measurement for fixed-height items |
windowSize |
21 | Number of viewports to render |
maxToRenderPerBatch |
10 | Items rendered per batch |
initialNumToRender |
10 | Initial batch size |
updateCellsBatchingPeriod |
50 | Delay between batches (ms) |
removeClippedSubviews |
varies | Detach off-screen views |
initialScrollIndex |
undefined | Start at specific index |
maintainVisibleContentPosition |
undefined | Prevent scroll jumps on prepend |
Performance Optimization Checklist
Use this checklist when optimizing FlatList performance. Work through it in orderβearly items have the biggest impact.
π FlatList Performance Checklist
High Impact (Do First)
- β getItemLayout β Implement if items have fixed height
- β keyExtractor β Use unique, stable keys (not index)
- β React.memo β Wrap item component
- β useCallback β Memoize renderItem and handlers
- β Image dimensions β Always specify width/height
Medium Impact
- β windowSize β Reduce from 21 if items are simple
- β Image caching β Use expo-image or similar
- β Inline styles β Move to StyleSheet
- β extraData β Memoize if using object/array
- β Thumbnail images β Request size-appropriate images
Fine-Tuning
- β maxToRenderPerBatch β Adjust based on item complexity
- β initialNumToRender β Match visible items on screen
- β Render tracking β Add dev-only render counters
- β Profile with DevTools β Find remaining bottlenecks
When All Else Fails
- β FlashList β Consider switching (covered in Lesson 5.7)
- β Simplify items β Reduce component tree depth
- β Defer heavy work β Load details after scroll stops
Quick Diagnosis Guide
flowchart TD
A["Scroll is janky"] --> B{"Which FPS drops?"}
B -->|"JS FPS drops"| C["JavaScript bottleneck"]
B -->|"UI FPS drops"| D["Native bottleneck"]
B -->|"Both drop"| E["Severe issue"]
C --> C1["Check renderItem complexity"]
C --> C2["Check for unnecessary re-renders"]
C --> C3["Profile with React DevTools"]
D --> D1["Check image sizes"]
D --> D2["Check layout complexity"]
D --> D3["Check removeClippedSubviews"]
E --> E1["Implement getItemLayout"]
E --> E2["Reduce windowSize"]
E --> E3["Consider FlashList"]
style C fill:#fff3cd
style D fill:#e3f2fd
style E fill:#ffcdd2
Before and After Example
// β BEFORE: Unoptimized FlatList
function UnoptimizedList({ data, onItemPress }) {
return (
<FlatList
data={data}
renderItem={({ item }) => (
<View style={{ padding: 16, borderBottomWidth: 1 }}>
<Image source={{ uri: item.image }} />
<Text>{item.title}</Text>
<Pressable onPress={() => onItemPress(item.id)}>
<Text>View Details</Text>
</Pressable>
</View>
)}
/>
);
}
// β
AFTER: Optimized FlatList
const ITEM_HEIGHT = 100;
const ListItem = memo(function ListItem({ item, onPress }) {
const handlePress = useCallback(() => {
onPress(item.id);
}, [item.id, onPress]);
return (
<View style={styles.item}>
<Image
source={{ uri: item.thumbnailUrl }}
style={styles.image}
/>
<Text style={styles.title}>{item.title}</Text>
<Pressable onPress={handlePress} style={styles.button}>
<Text>View Details</Text>
</Pressable>
</View>
);
});
function OptimizedList({ data, onItemPress }) {
const renderItem = useCallback(
({ item }) => <ListItem item={item} onPress={onItemPress} />,
[onItemPress]
);
const keyExtractor = useCallback(
(item) => item.id,
[]
);
const getItemLayout = useCallback(
(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[]
);
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
getItemLayout={getItemLayout}
windowSize={11}
maxToRenderPerBatch={10}
initialNumToRender={10}
/>
);
}
const styles = StyleSheet.create({
item: {
height: ITEM_HEIGHT,
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
image: {
width: 60,
height: 60,
},
title: {
fontSize: 16,
fontWeight: '600',
},
button: {
padding: 8,
},
});
Hands-On Exercises
Let's put these optimization techniques into practice.
Exercise 1: Implement getItemLayout
Add getItemLayout to a list with fixed-height items and separators.
Given this list structure:
- Item height: 72px
- Separator height: 1px
- Header height: 60px
- No footer
Implement getItemLayout that correctly calculates positions.
π‘ Hint
Offset = Header + (ItemHeight + SeparatorHeight) Γ index. Remember that separators appear between items, so the last item doesn't have a separator after it.
β Solution
const ITEM_HEIGHT = 72;
const SEPARATOR_HEIGHT = 1;
const HEADER_HEIGHT = 60;
const getItemLayout = useCallback(
(data: Item[] | null | undefined, index: number) => {
// Each item is followed by a separator (except possibly the last)
// But for offset calculation, we count separators before each item
const offset = HEADER_HEIGHT + (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index;
return {
length: ITEM_HEIGHT,
offset,
index,
};
},
[]
);
// Full component
function OptimizedList({ items }) {
const renderItem = useCallback(
({ item }) => (
<View style={{ height: ITEM_HEIGHT, justifyContent: 'center', paddingHorizontal: 16 }}>
<Text>{item.name}</Text>
</View>
),
[]
);
const Separator = useCallback(
() => <View style={{ height: SEPARATOR_HEIGHT, backgroundColor: '#e0e0e0' }} />,
[]
);
const Header = useMemo(
() => (
<View style={{ height: HEADER_HEIGHT, justifyContent: 'center', paddingHorizontal: 16 }}>
<Text style={{ fontSize: 24, fontWeight: 'bold' }}>My List</Text>
</View>
),
[]
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
getItemLayout={getItemLayout}
ItemSeparatorComponent={Separator}
ListHeaderComponent={Header}
/>
);
}
Exercise 2: Fix Re-render Issues
This list has performance problems. Find and fix them.
// Buggy code - find the issues!
function BuggyList({ items, onSelect, selectedId }) {
return (
<FlatList
data={items}
extraData={{ selectedId }}
renderItem={({ item }) => (
<Pressable
style={{
padding: 16,
backgroundColor: item.id === selectedId ? '#e3f2fd' : '#fff',
}}
onPress={() => onSelect(item.id)}
>
<Text>{item.name}</Text>
</Pressable>
)}
keyExtractor={(item, index) => index.toString()}
/>
);
}
π‘ Hint
Look for: inline functions, inline styles, unstable extraData, index as key, and missing memoization.
β Solution
// Fixed version with all issues resolved
// 1. Memoized item component
const ListItem = memo(function ListItem({
item,
isSelected,
onSelect
}: {
item: Item;
isSelected: boolean;
onSelect: (id: string) => void;
}) {
// 2. Memoized press handler
const handlePress = useCallback(() => {
onSelect(item.id);
}, [item.id, onSelect]);
// 3. Memoized style
const itemStyle = useMemo(
() => [styles.item, isSelected && styles.itemSelected],
[isSelected]
);
return (
<Pressable style={itemStyle} onPress={handlePress}>
<Text>{item.name}</Text>
</Pressable>
);
});
function FixedList({ items, onSelect, selectedId }) {
// 4. Memoized renderItem
const renderItem = useCallback(
({ item }) => (
<ListItem
item={item}
isSelected={item.id === selectedId}
onSelect={onSelect}
/>
),
[selectedId, onSelect]
);
// 5. Memoized keyExtractor with proper unique key
const keyExtractor = useCallback(
(item: Item) => item.id, // Use item.id, not index!
[]
);
return (
<FlatList
data={items}
// 6. Primitive extraData instead of object
extraData={selectedId}
renderItem={renderItem}
keyExtractor={keyExtractor}
/>
);
}
// 7. Styles in StyleSheet, not inline
const styles = StyleSheet.create({
item: {
padding: 16,
backgroundColor: '#fff',
},
itemSelected: {
backgroundColor: '#e3f2fd',
},
});
Issues fixed:
- Added
React.memoto item component - Memoized press handler with
useCallback - Memoized dynamic style with
useMemo - Memoized
renderItemwithuseCallback - Used
item.idinstead of index for key - Used primitive
selectedIdfor extraData - Moved styles to
StyleSheet.create
Exercise 3: Build an Optimized Image Gallery
Create a performant image gallery list with all optimizations applied.
Requirements:
- Display a grid of images (2 columns)
- Each image cell is a fixed 180px Γ 180px square
- Use expo-image (or regular Image with proper sizing)
- Implement getItemLayout for the grid
- Add all appropriate memoization
- Track viewable items for analytics
π‘ Hint
Use numColumns={2} for the grid. With 2 columns, each "row" in getItemLayout represents 2 items. Calculate row index as Math.floor(index / 2).
β Solution
import React, { useCallback, useMemo, useRef } from 'react';
import {
FlatList,
View,
Text,
StyleSheet,
Dimensions,
ViewabilityConfig,
ViewToken,
} from 'react-native';
import { Image } from 'expo-image';
interface GalleryImage {
id: string;
uri: string;
blurHash?: string;
}
// Constants
const SCREEN_WIDTH = Dimensions.get('window').width;
const NUM_COLUMNS = 2;
const GAP = 4;
const IMAGE_SIZE = (SCREEN_WIDTH - GAP * (NUM_COLUMNS + 1)) / NUM_COLUMNS;
const ROW_HEIGHT = IMAGE_SIZE + GAP;
// Generate mock data
const generateImages = (count: number): GalleryImage[] =>
Array.from({ length: count }, (_, i) => ({
id: `image-${i}`,
uri: `https://picsum.photos/seed/${i}/400/400`,
blurHash: 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.',
}));
// Memoized image cell
const ImageCell = memo(function ImageCell({ image }: { image: GalleryImage }) {
return (
<View style={styles.imageContainer}>
<Image
source={{ uri: image.uri }}
style={styles.image}
placeholder={image.blurHash}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</View>
);
});
export default function OptimizedGallery() {
const images = useMemo(() => generateImages(100), []);
// Track viewed images
const viewedImages = useRef<Set<string>>(new Set());
// Memoized renderItem
const renderItem = useCallback(
({ item }: { item: GalleryImage }) => <ImageCell image={item} />,
[]
);
// Memoized keyExtractor
const keyExtractor = useCallback(
(item: GalleryImage) => item.id,
[]
);
// getItemLayout for grid (2 columns)
const getItemLayout = useCallback(
(data: GalleryImage[] | null | undefined, index: number) => {
// Each row contains NUM_COLUMNS items
const rowIndex = Math.floor(index / NUM_COLUMNS);
return {
length: ROW_HEIGHT,
offset: ROW_HEIGHT * rowIndex,
index,
};
},
[]
);
// Viewability configuration
const viewabilityConfig: ViewabilityConfig = useMemo(() => ({
viewAreaCoveragePercentThreshold: 50,
minimumViewTime: 500,
}), []);
const onViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
viewableItems.forEach(({ item, isViewable }) => {
if (isViewable && !viewedImages.current.has(item.id)) {
viewedImages.current.add(item.id);
// Analytics tracking would go here
console.log(`Image viewed: ${item.id}`);
}
});
},
[]
);
// Keep reference stable
const viewabilityConfigCallbackPairs = useRef([
{ viewabilityConfig, onViewableItemsChanged },
]);
// Column separator
const ItemSeparator = useCallback(
() => <View style={{ height: GAP }} />,
[]
);
return (
<View style={styles.container}>
<FlatList<GalleryImage>
data={images}
renderItem={renderItem}
keyExtractor={keyExtractor}
getItemLayout={getItemLayout}
numColumns={NUM_COLUMNS}
columnWrapperStyle={styles.row}
ItemSeparatorComponent={ItemSeparator}
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current}
// Performance props
windowSize={7}
maxToRenderPerBatch={8}
initialNumToRender={8}
removeClippedSubviews={true}
// Styling
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
listContent: {
padding: GAP,
},
row: {
justifyContent: 'space-between',
},
imageContainer: {
width: IMAGE_SIZE,
height: IMAGE_SIZE,
borderRadius: 4,
overflow: 'hidden',
backgroundColor: '#1a1a1a',
},
image: {
width: '100%',
height: '100%',
},
});
Optimizations applied:
getItemLayoutwith grid calculationReact.memoon ImageCelluseCallbackon all function propsexpo-imagewith blur hash placeholder and caching- Fixed image dimensions
- Reduced
windowSizefor images - Viewability tracking for analytics
removeClippedSubviewsenabled
Exercise 4: Performance Audit
Practice diagnosing performance issues using the Performance Monitor.
Tasks:
- Create a list with 500+ items that includes images
- Open the Performance Monitor (shake β "Perf Monitor")
- Scroll the list rapidly and note the FPS
- Apply optimizations one at a time, measuring after each
- Document which optimization had the biggest impact
Record your findings:
- Baseline FPS: ___
- After getItemLayout: ___
- After memo: ___
- After image optimization: ___
- Final FPS: ___
π‘ Expected Results
Typical improvements you might see:
- Baseline: 25-40 FPS with visible jank
- + getItemLayout: +5-10 FPS improvement
- + memo: +5-15 FPS improvement
- + image optimization: +5-10 FPS improvement
- Final: 55-60 FPS smooth scrolling
The exact numbers depend on your device, item complexity, and starting implementation. The key insight is that optimizations are cumulative!
Summary
You've learned the techniques that separate laggy lists from buttery-smooth ones. Let's recap the key concepts.
π― Key Takeaways
- 16ms budget: Each frame must complete in under 16.67ms for 60 FPS
- getItemLayout: The single most impactful optimization for fixed-height items
- Memoization:
React.memo+useCallbackprevent unnecessary re-renders - windowSize: Reduce from 21 to render fewer off-screen items
- Images: Always specify dimensions, use thumbnails, consider expo-image
- Measure first: Use Performance Monitor and React DevTools before optimizing
- Cumulative gains: Small optimizations add up to smooth performance
The Optimization Priority
flowchart TB
subgraph Priority["Optimization Priority (High to Low)"]
direction TB
P1["1. getItemLayout"]
P2["2. keyExtractor (unique keys)"]
P3["3. React.memo on items"]
P4["4. useCallback on renderItem"]
P5["5. Image optimization"]
P6["6. windowSize tuning"]
P7["7. Fine-tuning batch props"]
P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7
end
style P1 fill:#c8e6c9,stroke:#4CAF50,stroke-width:2px
style P2 fill:#c8e6c9
style P3 fill:#dcedc8
style P4 fill:#dcedc8
style P5 fill:#f0f4c3
style P6 fill:#fff9c4
style P7 fill:#fff9c4
π What's Next?
Now that you can optimize basic lists, the next lesson covers FlatList Featuresβpull-to-refresh, infinite scroll, scroll-to-index, and other capabilities that make lists interactive and user-friendly. After that, we'll explore SectionList for grouped data, and finally FlashList as an alternative when you need maximum performance.
π‘ Remember
Don't optimize prematurely! Start with a working list, then optimize only if you see performance issues. Many apps work fine with basic FlatList usage. Use the Performance Monitor to identify real problems before applying these techniques.