Module 5: Lists and Performance
FlatList Features
Interactive capabilities that bring your lists to life
π― Learning Objectives
- Implement pull-to-refresh for data refreshing
- Build infinite scroll with onEndReached
- Use scroll methods to navigate programmatically
- Handle scroll events for custom behaviors
- Create multi-column grid layouts
- Build inverted lists for chat interfaces
- Implement sticky headers and scroll indicators
Pull-to-Refresh
Pull-to-refresh is a standard mobile pattern that lets users refresh content by pulling down on the list. FlatList has built-in support for this interaction.
Basic Implementation
import React, { useState, useCallback } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';
function RefreshableList() {
const [data, setData] = useState(initialData);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
try {
const newData = await fetchLatestData();
setData(newData);
} catch (error) {
console.error('Refresh failed:', error);
} finally {
setRefreshing(false);
}
}, []);
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
// Pull-to-refresh props
refreshing={refreshing}
onRefresh={onRefresh}
/>
);
}
π The Two Required Props
refreshing: Boolean indicating if refresh is in progress. Controls the spinner visibility.
onRefresh: Function called when user pulls to refresh. Set refreshing=true at start, false when done.
Custom Refresh Control
For more control over the refresh indicator, use the RefreshControl component:
import { FlatList, RefreshControl } from 'react-native';
function CustomRefreshList() {
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await fetchData();
setRefreshing(false);
}, []);
return (
<FlatList
data={data}
renderItem={renderItem}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
// Customization options
tintColor="#6200ee" // iOS spinner color
colors={['#6200ee', '#03dac6']} // Android spinner colors
progressBackgroundColor="#fff" // Android background
title="Pull to refresh..." // iOS title text
titleColor="#666" // iOS title color
progressViewOffset={50} // Android offset
/>
}
/>
);
}
Handling Refresh with React Query / TanStack Query
import { useQuery } from '@tanstack/react-query';
function QueryRefreshList() {
const { data, isLoading, isRefetching, refetch } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return (
<FlatList
data={data ?? []}
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshing={isRefetching}
onRefresh={refetch}
ListEmptyComponent={isLoading ? <LoadingSpinner /> : <EmptyState />}
/>
);
}
β οΈ Common Mistakes
- Forgetting to set refreshing=false: Spinner stays forever if you don't reset it
- Not handling errors: Always wrap in try/catch with finally for the state reset
- Mutating data: Replace the array, don't push to it
Infinite Scroll
Infinite scroll loads more data as the user approaches the end of the list. This is essential for feeds, search results, and any large dataset.
Basic Implementation
import React, { useState, useCallback } from 'react';
import { FlatList, ActivityIndicator, View } from 'react-native';
function InfiniteScrollList() {
const [data, setData] = useState<Item[]>(initialData);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const loadMore = useCallback(async () => {
// Prevent multiple simultaneous calls
if (loading || !hasMore) return;
setLoading(true);
try {
const nextPage = page + 1;
const newItems = await fetchPage(nextPage);
if (newItems.length === 0) {
setHasMore(false);
} else {
setData(prev => [...prev, ...newItems]);
setPage(nextPage);
}
} catch (error) {
console.error('Failed to load more:', error);
} finally {
setLoading(false);
}
}, [loading, hasMore, page]);
const renderFooter = () => {
if (!loading) return null;
return (
<View style={styles.footer}>
<ActivityIndicator size="small" />
</View>
);
};
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
/>
);
}
Understanding onEndReachedThreshold
The onEndReachedThreshold determines how close to the end the user must scroll before onEndReached fires:
// onEndReachedThreshold values:
// 0.1 = Trigger when 10% of visible height from bottom (late)
// 0.5 = Trigger when 50% of visible height from bottom (recommended)
// 1.0 = Trigger when 1 screen away from bottom
// 2.0 = Trigger when 2 screens away from bottom (very early)
<FlatList
onEndReached={loadMore}
onEndReachedThreshold={0.5} // Trigger at half-screen from bottom
/>
Preventing Double Loads
onEndReached can fire multiple times during a scroll. Prevent duplicate API calls:
function RobustInfiniteList() {
const [data, setData] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
// Use ref to track loading state (avoids stale closure issues)
const isLoadingRef = useRef(false);
const loadMore = useCallback(async () => {
// Guard against multiple calls
if (isLoadingRef.current || !hasMore) {
return;
}
isLoadingRef.current = true;
try {
const response = await api.fetchItems({ page: page + 1 });
setData(prev => [...prev, ...response.items]);
setPage(prev => prev + 1);
setHasMore(response.hasNextPage);
} catch (error) {
// Handle error - maybe show a "tap to retry" in footer
} finally {
isLoadingRef.current = false;
}
}, [page, hasMore]);
return (
<FlatList
data={data}
renderItem={renderItem}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
/>
);
}
Complete Infinite Scroll Example
import React, { useState, useCallback, useRef } from 'react';
import {
FlatList,
View,
Text,
ActivityIndicator,
Pressable,
StyleSheet
} from 'react-native';
interface Post {
id: string;
title: string;
body: string;
}
interface ApiResponse {
posts: Post[];
nextPage: number | null;
}
export default function InfiniteFeed() {
const [posts, setPosts] = useState<Post[]>([]);
const [nextPage, setNextPage] = useState<number | null>(1);
const [error, setError] = useState<string | null>(null);
const isLoading = useRef(false);
const fetchPosts = useCallback(async (page: number): Promise<ApiResponse> => {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
const newPosts = Array.from({ length: 10 }, (_, i) => ({
id: `post-${page}-${i}`,
title: `Post ${(page - 1) * 10 + i + 1}`,
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
}));
return {
posts: newPosts,
nextPage: page < 5 ? page + 1 : null, // Simulate 5 pages
};
}, []);
const loadMore = useCallback(async () => {
if (isLoading.current || nextPage === null) return;
isLoading.current = true;
setError(null);
try {
const response = await fetchPosts(nextPage);
setPosts(prev => [...prev, ...response.posts]);
setNextPage(response.nextPage);
} catch (err) {
setError('Failed to load posts');
} finally {
isLoading.current = false;
}
}, [nextPage, fetchPosts]);
// Load initial data
React.useEffect(() => {
loadMore();
}, []);
const renderItem = useCallback(({ item }: { item: Post }) => (
<View style={styles.postCard}>
<Text style={styles.postTitle}>{item.title}</Text>
<Text style={styles.postBody}>{item.body}</Text>
</View>
), []);
const renderFooter = () => {
if (error) {
return (
<Pressable style={styles.errorFooter} onPress={loadMore}>
<Text style={styles.errorText}>{error}</Text>
<Text style={styles.retryText}>Tap to retry</Text>
</Pressable>
);
}
if (nextPage === null) {
return (
<View style={styles.endFooter}>
<Text style={styles.endText}>You've reached the end!</Text>
</View>
);
}
return (
<View style={styles.loadingFooter}>
<ActivityIndicator size="small" color="#6200ee" />
</View>
);
};
return (
<FlatList
data={posts}
renderItem={renderItem}
keyExtractor={(item) => item.id}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
contentContainerStyle={styles.listContent}
/>
);
}
const styles = StyleSheet.create({
listContent: {
padding: 16,
},
postCard: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 16,
marginBottom: 12,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
postTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
postBody: {
fontSize: 14,
color: '#666',
lineHeight: 20,
},
loadingFooter: {
paddingVertical: 20,
alignItems: 'center',
},
errorFooter: {
paddingVertical: 20,
alignItems: 'center',
},
errorText: {
color: '#f44336',
marginBottom: 4,
},
retryText: {
color: '#6200ee',
fontWeight: '600',
},
endFooter: {
paddingVertical: 20,
alignItems: 'center',
},
endText: {
color: '#999',
},
});
flowchart TD
A["User scrolls"] --> B{"Near end?
threshold check"}
B -->|"No"| A
B -->|"Yes"| C{"Already loading?"}
C -->|"Yes"| A
C -->|"No"| D{"Has more data?"}
D -->|"No"| E["Show 'End of list'"]
D -->|"Yes"| F["Set loading=true"]
F --> G["Fetch next page"]
G --> H{"Success?"}
H -->|"Yes"| I["Append data"]
H -->|"No"| J["Show error"]
I --> K["Set loading=false"]
J --> K
K --> A
style F fill:#fff3cd
style I fill:#c8e6c9
style J fill:#ffcdd2
Scroll Methods
FlatList provides methods to programmatically scroll to specific positions. These require a ref to the FlatList component.
Setting Up the Ref
import { useRef } from 'react';
import { FlatList } from 'react-native';
function ScrollableList() {
const flatListRef = useRef<FlatList>(null);
return (
<FlatList
ref={flatListRef}
data={data}
renderItem={renderItem}
/>
);
}
scrollToIndex
Scroll to a specific item by its index:
// Basic usage
const scrollToItem = (index: number) => {
flatListRef.current?.scrollToIndex({
index,
animated: true,
});
};
// With positioning options
flatListRef.current?.scrollToIndex({
index: 10,
animated: true,
viewPosition: 0, // 0 = top, 0.5 = center, 1 = bottom
viewOffset: 0, // Additional offset in pixels
});
// β οΈ scrollToIndex requires getItemLayout for accuracy!
// Without it, you may get errors or incorrect positions
<FlatList
ref={flatListRef}
data={data}
renderItem={renderItem}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
// Handle failures when getItemLayout isn't provided
onScrollToIndexFailed={(info) => {
// Wait and retry
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: info.index,
animated: true,
});
}, 100);
}}
/>
scrollToOffset
Scroll to a specific pixel offset:
// Scroll to top
const scrollToTop = () => {
flatListRef.current?.scrollToOffset({
offset: 0,
animated: true,
});
};
// Scroll to specific position
const scrollToPosition = (offset: number) => {
flatListRef.current?.scrollToOffset({
offset,
animated: true,
});
};
// Common pattern: "Scroll to top" button
function ListWithScrollToTop() {
const flatListRef = useRef<FlatList>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
const handleScroll = (event) => {
const offsetY = event.nativeEvent.contentOffset.y;
setShowScrollTop(offsetY > 500);
};
return (
<View style={{ flex: 1 }}>
<FlatList
ref={flatListRef}
data={data}
renderItem={renderItem}
onScroll={handleScroll}
scrollEventThrottle={16}
/>
{showScrollTop && (
<Pressable
style={styles.scrollTopButton}
onPress={() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}}
>
<Text>β Top</Text>
</Pressable>
)}
</View>
);
}
scrollToEnd
Scroll to the end of the list:
// Scroll to bottom
const scrollToEnd = () => {
flatListRef.current?.scrollToEnd({
animated: true,
});
};
// Use case: New message in chat
const handleNewMessage = (message: Message) => {
setMessages(prev => [...prev, message]);
// Wait for render, then scroll
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 100);
};
scrollToItem
Scroll to a specific item object:
// Scroll to a specific item (not by index)
const scrollToItem = (item: Item) => {
flatListRef.current?.scrollToItem({
item,
animated: true,
viewPosition: 0.5, // Center the item
});
};
// Useful when you have the item but not the index
const highlightedItem = items.find(i => i.isHighlighted);
if (highlightedItem) {
scrollToItem(highlightedItem);
}
β Scroll Methods Summary
| Method | Use When | Requires |
|---|---|---|
scrollToIndex |
You know the index | getItemLayout recommended |
scrollToOffset |
You know the pixel position | Nothing extra |
scrollToEnd |
Jump to bottom | Nothing extra |
scrollToItem |
You have the item object | getItemLayout recommended |
Scroll Events
FlatList inherits from ScrollView, giving you access to scroll events for custom behaviors like hiding headers, parallax effects, or scroll-based animations.
onScroll
import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
function ScrollTrackingList() {
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
// Current scroll position
const scrollY = contentOffset.y;
const scrollX = contentOffset.x;
// Total scrollable content size
const contentHeight = contentSize.height;
const contentWidth = contentSize.width;
// Visible area size
const visibleHeight = layoutMeasurement.height;
const visibleWidth = layoutMeasurement.width;
// Calculate scroll percentage
const scrollPercentage = scrollY / (contentHeight - visibleHeight);
console.log(`Scrolled: ${(scrollPercentage * 100).toFixed(1)}%`);
};
return (
<FlatList
data={data}
renderItem={renderItem}
onScroll={handleScroll}
scrollEventThrottle={16} // How often to fire (16 = 60fps)
/>
);
}
β οΈ scrollEventThrottle
On iOS, onScroll fires at 60fps by default, which can hurt performance. Use scrollEventThrottle to control the frequency:
- 16: Every frame (60fps) - for smooth animations
- 100: ~10 times per second - for most tracking
- 500: ~2 times per second - for infrequent updates
Hiding Header on Scroll
import { useRef } from 'react';
import { Animated, FlatList } from 'react-native';
function HidingHeaderList() {
const scrollY = useRef(new Animated.Value(0)).current;
const lastScrollY = useRef(0);
const headerVisible = useRef(new Animated.Value(1)).current;
const handleScroll = Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{
useNativeDriver: true,
listener: (event) => {
const currentScrollY = event.nativeEvent.contentOffset.y;
// Scrolling down - hide header
if (currentScrollY > lastScrollY.current && currentScrollY > 50) {
Animated.timing(headerVisible, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start();
}
// Scrolling up - show header
else if (currentScrollY < lastScrollY.current) {
Animated.timing(headerVisible, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start();
}
lastScrollY.current = currentScrollY;
},
}
);
const headerTranslateY = headerVisible.interpolate({
inputRange: [0, 1],
outputRange: [-60, 0],
});
return (
<View style={{ flex: 1 }}>
<Animated.View
style={[
styles.header,
{ transform: [{ translateY: headerTranslateY }] },
]}
>
<Text style={styles.headerText}>My App</Text>
</Animated.View>
<FlatList
data={data}
renderItem={renderItem}
onScroll={handleScroll}
scrollEventThrottle={16}
contentContainerStyle={{ paddingTop: 60 }}
/>
</View>
);
}
Other Scroll Events
<FlatList
data={data}
renderItem={renderItem}
// Fires when scrolling starts
onScrollBeginDrag={(event) => {
console.log('Started scrolling');
}}
// Fires when user lifts finger
onScrollEndDrag={(event) => {
console.log('Stopped dragging');
}}
// Fires when momentum scroll ends
onMomentumScrollBegin={(event) => {
console.log('Momentum scroll started');
}}
onMomentumScrollEnd={(event) => {
console.log('Momentum scroll ended');
// Good place to load visible images, etc.
}}
/>
Using Animated for Smooth Effects
import { Animated } from 'react-native';
function ParallaxHeaderList() {
const scrollY = useRef(new Animated.Value(0)).current;
// Parallax effect: header moves slower than content
const headerTranslate = scrollY.interpolate({
inputRange: [0, 200],
outputRange: [0, -100],
extrapolate: 'clamp',
});
// Fade header as you scroll
const headerOpacity = scrollY.interpolate({
inputRange: [0, 150],
outputRange: [1, 0],
extrapolate: 'clamp',
});
return (
<View style={{ flex: 1 }}>
<Animated.View
style={[
styles.parallaxHeader,
{
transform: [{ translateY: headerTranslate }],
opacity: headerOpacity,
},
]}
>
<Image source={headerImage} style={styles.headerImage} />
</Animated.View>
<Animated.FlatList
data={data}
renderItem={renderItem}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
contentContainerStyle={{ paddingTop: 200 }}
/>
</View>
);
}
Multi-Column Grids
FlatList supports grid layouts with the numColumns prop. This is perfect for photo galleries, product grids, and tile-based interfaces.
Basic Grid
import { FlatList, Dimensions } from 'react-native';
const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_SIZE = SCREEN_WIDTH / NUM_COLUMNS;
function BasicGrid() {
return (
<FlatList
data={items}
numColumns={NUM_COLUMNS}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={{
width: ITEM_SIZE,
height: ITEM_SIZE,
backgroundColor: item.color,
}} />
)}
/>
);
}
Grid with Gaps
const NUM_COLUMNS = 2;
const GAP = 12;
const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_WIDTH = (SCREEN_WIDTH - GAP * (NUM_COLUMNS + 1)) / NUM_COLUMNS;
function GappedGrid() {
return (
<FlatList
data={products}
numColumns={NUM_COLUMNS}
keyExtractor={(item) => item.id}
columnWrapperStyle={{
justifyContent: 'space-between',
paddingHorizontal: GAP,
marginBottom: GAP,
}}
contentContainerStyle={{
paddingTop: GAP,
}}
renderItem={({ item }) => (
<View style={{
width: ITEM_WIDTH,
backgroundColor: '#fff',
borderRadius: 8,
padding: 12,
}}>
<Image
source={{ uri: item.image }}
style={{ width: '100%', aspectRatio: 1 }}
/>
<Text>{item.name}</Text>
<Text>${item.price}</Text>
</View>
)}
/>
);
}
Handling Incomplete Rows
When your data doesn't divide evenly into columns, the last row may have fewer items:
const NUM_COLUMNS = 3;
// Option 1: Pad with empty items
const padData = (data: Item[], columns: number) => {
const remainder = data.length % columns;
if (remainder === 0) return data;
const padding = columns - remainder;
return [...data, ...Array(padding).fill({ id: 'empty', isEmpty: true })];
};
function PaddedGrid({ items }) {
const paddedItems = padData(items, NUM_COLUMNS);
return (
<FlatList
data={paddedItems}
numColumns={NUM_COLUMNS}
renderItem={({ item }) => {
if (item.isEmpty) {
return <View style={{ flex: 1 }} />; // Empty spacer
}
return <GridItem item={item} />;
}}
/>
);
}
// Option 2: Use columnWrapperStyle for spacing
function SpacedGrid({ items }) {
return (
<FlatList
data={items}
numColumns={NUM_COLUMNS}
columnWrapperStyle={{
justifyContent: 'flex-start', // Left-align incomplete rows
gap: 8, // Consistent gap between items
}}
renderItem={({ item }) => (
<View style={{ width: ITEM_WIDTH }}>
<GridItem item={item} />
</View>
)}
/>
);
}
getItemLayout for Grids
const NUM_COLUMNS = 2;
const ITEM_HEIGHT = 180;
const GAP = 12;
const ROW_HEIGHT = ITEM_HEIGHT + GAP;
function OptimizedGrid({ items }) {
const getItemLayout = useCallback(
(data: Item[] | null | undefined, index: number) => {
// In a grid, items are arranged in rows
// Row index = floor(item index / num columns)
const rowIndex = Math.floor(index / NUM_COLUMNS);
return {
length: ITEM_HEIGHT,
offset: ROW_HEIGHT * rowIndex,
index,
};
},
[]
);
return (
<FlatList
data={items}
numColumns={NUM_COLUMNS}
renderItem={renderItem}
getItemLayout={getItemLayout}
/>
);
}
β οΈ Grid Limitations
- numColumns is static: Changing it requires remounting the FlatList
- No staggered grids: All items in a row must have the same height
- Horizontal grids: Can't combine
numColumnswithhorizontal
For staggered/masonry grids, consider libraries like @shopify/flash-list with masonry layout or react-native-masonry-list.
Inverted Lists
Inverted lists render from bottom to topβessential for chat interfaces where the newest messages appear at the bottom.
Basic Inverted List
// Chat messages - newest at bottom
function ChatMessages({ messages }) {
return (
<FlatList
data={messages}
renderItem={({ item }) => <MessageBubble message={item} />}
keyExtractor={(item) => item.id}
inverted // This single prop inverts the list
/>
);
}
// β οΈ Important: Your data should be newest-first!
const messages = [
{ id: '3', text: 'Latest message', time: '10:03' }, // Bottom
{ id: '2', text: 'Earlier message', time: '10:02' }, // Middle
{ id: '1', text: 'First message', time: '10:01' }, // Top
];
Complete Chat Implementation
import React, { useState, useCallback, useRef } from 'react';
import {
FlatList,
View,
Text,
TextInput,
Pressable,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
interface Message {
id: string;
text: string;
sender: 'me' | 'them';
timestamp: Date;
}
export default function ChatScreen() {
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [inputText, setInputText] = useState('');
const flatListRef = useRef<FlatList>(null);
const sendMessage = useCallback(() => {
if (!inputText.trim()) return;
const newMessage: Message = {
id: Date.now().toString(),
text: inputText.trim(),
sender: 'me',
timestamp: new Date(),
};
// Add to beginning (newest first for inverted list)
setMessages(prev => [newMessage, ...prev]);
setInputText('');
// Auto-scroll to bottom (which is the top in inverted)
// Actually not needed - inverted list handles this automatically
}, [inputText]);
const renderMessage = useCallback(({ item }: { item: Message }) => (
<View style={[
styles.messageBubble,
item.sender === 'me' ? styles.myMessage : styles.theirMessage,
]}>
<Text style={[
styles.messageText,
item.sender === 'me' ? styles.myMessageText : styles.theirMessageText,
]}>
{item.text}
</Text>
<Text style={styles.timestamp}>
{item.timestamp.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</Text>
</View>
), []);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
>
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
inverted
contentContainerStyle={styles.messageList}
/>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder="Type a message..."
multiline
maxLength={500}
/>
<Pressable
style={[styles.sendButton, !inputText.trim() && styles.sendButtonDisabled]}
onPress={sendMessage}
disabled={!inputText.trim()}
>
<Text style={styles.sendButtonText}>Send</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
messageList: {
padding: 16,
},
messageBubble: {
maxWidth: '80%',
padding: 12,
borderRadius: 16,
marginBottom: 8,
},
myMessage: {
alignSelf: 'flex-end',
backgroundColor: '#6200ee',
borderBottomRightRadius: 4,
},
theirMessage: {
alignSelf: 'flex-start',
backgroundColor: '#fff',
borderBottomLeftRadius: 4,
},
messageText: {
fontSize: 16,
lineHeight: 22,
},
myMessageText: {
color: '#fff',
},
theirMessageText: {
color: '#333',
},
timestamp: {
fontSize: 11,
color: 'rgba(255,255,255,0.7)',
marginTop: 4,
alignSelf: 'flex-end',
},
inputContainer: {
flexDirection: 'row',
padding: 12,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
alignItems: 'flex-end',
},
input: {
flex: 1,
backgroundColor: '#f5f5f5',
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
marginRight: 8,
maxHeight: 100,
fontSize: 16,
},
sendButton: {
backgroundColor: '#6200ee',
borderRadius: 20,
paddingHorizontal: 20,
paddingVertical: 10,
},
sendButtonDisabled: {
backgroundColor: '#ccc',
},
sendButtonText: {
color: '#fff',
fontWeight: '600',
},
});
flowchart LR
subgraph Normal["Normal List"]
direction TB
N1["Item 1 (oldest)"] --> N2["Item 2"]
N2 --> N3["Item 3 (newest)"]
end
subgraph Inverted["Inverted List"]
direction TB
I3["Item 3 (newest)"] --> I2["Item 2"]
I2 --> I1["Item 1 (oldest)"]
end
subgraph Data["Data Array"]
D["[newest, ..., oldest]"]
end
Data --> Normal
Data --> Inverted
style N3 fill:#c8e6c9
style I3 fill:#c8e6c9
Sticky Headers
Sticky headers remain visible at the top of the screen as you scroll. FlatList supports them through the stickyHeaderIndices prop.
Basic Sticky Header
// Make specific indices sticky
function StickyHeaderList() {
const data = [
{ type: 'header', title: 'Section A' },
{ type: 'item', name: 'Item 1' },
{ type: 'item', name: 'Item 2' },
{ type: 'header', title: 'Section B' },
{ type: 'item', name: 'Item 3' },
{ type: 'item', name: 'Item 4' },
];
// Find indices of headers
const stickyIndices = data
.map((item, index) => item.type === 'header' ? index : null)
.filter(index => index !== null) as number[];
return (
<FlatList
data={data}
stickyHeaderIndices={stickyIndices}
renderItem={({ item }) => {
if (item.type === 'header') {
return (
<View style={styles.stickyHeader}>
<Text style={styles.headerText}>{item.title}</Text>
</View>
);
}
return (
<View style={styles.item}>
<Text>{item.name}</Text>
</View>
);
}}
/>
);
}
const styles = StyleSheet.create({
stickyHeader: {
backgroundColor: '#6200ee',
padding: 12,
},
headerText: {
color: '#fff',
fontWeight: 'bold',
fontSize: 16,
},
item: {
padding: 16,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
});
π‘ Sticky Headers vs SectionList
If your data is naturally grouped with headers, consider using SectionList instead of FlatList with stickyHeaderIndices. SectionList has built-in support for section headers and makes the code cleaner. We'll cover SectionList in the next lesson.
Sticky ListHeaderComponent
// Make the list header sticky
function StickyListHeader() {
return (
<FlatList
data={items}
renderItem={renderItem}
ListHeaderComponent={
<View style={styles.searchHeader}>
<TextInput
style={styles.searchInput}
placeholder="Search..."
/>
</View>
}
// Index 0 is the ListHeaderComponent
stickyHeaderIndices={[0]}
// Hide header shadow on iOS
stickyHeaderHiddenOnScroll={false}
/>
);
}
Styling Sticky Headers
// Add shadows for depth when stuck
const styles = StyleSheet.create({
stickyHeader: {
backgroundColor: '#fff',
padding: 12,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
// iOS shadow
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
// Android elevation
elevation: 4,
// Ensure header is above items
zIndex: 1,
},
});
Scroll Indicators
Control the visibility and style of scroll indicators (scrollbars) with these props.
Visibility Control
<FlatList
data={items}
renderItem={renderItem}
// Hide indicators
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
// Or show them (default)
showsVerticalScrollIndicator={true}
/>
iOS-Specific Styling
<FlatList
data={items}
renderItem={renderItem}
// iOS scroll indicator style
indicatorStyle="black" // 'default' | 'black' | 'white'
// Inset the indicators from edges
scrollIndicatorInsets={{
top: 10,
right: 5,
bottom: 10,
left: 0,
}}
/>
When to Hide Scroll Indicators
π¨ Design Guidelines
| Show Indicators | Hide Indicators |
|---|---|
| Long content lists (feeds, search results) | Horizontal carousels |
| Settings/forms | Tab bars/category chips |
| Document viewers | Image galleries |
| Any list where scroll position matters | Short lists that fit on screen |
Keyboard Handling
When your list contains input fields or needs to work alongside an input, proper keyboard handling ensures a smooth user experience.
Keyboard Dismiss Modes
<FlatList
data={items}
renderItem={renderItem}
// Dismiss keyboard behavior
keyboardDismissMode="none" // Default - keyboard stays open
keyboardDismissMode="on-drag" // Dismiss when user drags
keyboardDismissMode="interactive" // iOS: drag dismisses progressively
// Should touches dismiss keyboard?
keyboardShouldPersistTaps="never" // Default - dismiss on any tap
keyboardShouldPersistTaps="always" // Never dismiss on tap
keyboardShouldPersistTaps="handled" // Dismiss unless tap is handled
/>
Common Patterns
// Search list - dismiss keyboard when scrolling results
function SearchList() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
return (
<View style={{ flex: 1 }}>
<TextInput
style={styles.searchInput}
value={query}
onChangeText={setQuery}
placeholder="Search..."
/>
<FlatList
data={results}
renderItem={renderItem}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
/>
</View>
);
}
// Form list - keep keyboard open when tapping buttons
function FormList() {
return (
<FlatList
data={formFields}
renderItem={({ item }) => (
<TextInput
style={styles.input}
placeholder={item.label}
/>
)}
keyboardShouldPersistTaps="always"
ListFooterComponent={
<Pressable style={styles.submitButton}>
<Text>Submit</Text>
</Pressable>
}
/>
);
}
Automatic Scroll to Input
import { useRef } from 'react';
import { FlatList, TextInput, Keyboard } from 'react-native';
function AutoScrollForm() {
const flatListRef = useRef<FlatList>(null);
const inputRefs = useRef<Record<string, TextInput>>({});
const handleFocus = (index: number) => {
// Scroll to bring the focused input into view
flatListRef.current?.scrollToIndex({
index,
viewPosition: 0.3, // Show input in upper third
animated: true,
});
};
return (
<FlatList
ref={flatListRef}
data={formFields}
keyExtractor={(item) => item.id}
getItemLayout={getItemLayout}
keyboardShouldPersistTaps="handled"
renderItem={({ item, index }) => (
<TextInput
ref={(ref) => { inputRefs.current[item.id] = ref!; }}
style={styles.input}
placeholder={item.label}
onFocus={() => handleFocus(index)}
returnKeyType={index === formFields.length - 1 ? 'done' : 'next'}
onSubmitEditing={() => {
if (index < formFields.length - 1) {
inputRefs.current[formFields[index + 1].id]?.focus();
} else {
Keyboard.dismiss();
}
}}
/>
)}
/>
);
}
β Keyboard Best Practices
- Search screens: Use
keyboardDismissMode="on-drag" - Forms: Use
keyboardShouldPersistTaps="handled" - Chat: Use
keyboardShouldPersistTaps="always"withinverted - Always test on real devices: Simulator keyboards behave differently
Hands-On Exercises
Practice implementing these FlatList features in real-world scenarios.
Exercise 1: Social Media Feed
Build a complete social media feed with pull-to-refresh and infinite scroll.
Requirements:
- Pull-to-refresh that fetches new posts
- Infinite scroll that loads older posts
- Loading indicator in the footer
- "No more posts" message when all posts are loaded
- Error handling with retry option
π‘ Hint
Use separate state for refreshing and loadingMore. Track hasMore to know when to stop loading. Use useRef for the loading lock to prevent race conditions.
β Solution
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
FlatList,
View,
Text,
Image,
ActivityIndicator,
Pressable,
RefreshControl,
StyleSheet,
} from 'react-native';
interface Post {
id: string;
author: string;
avatar: string;
content: string;
likes: number;
timestamp: string;
}
// Simulate API
const fetchPosts = async (page: number, refresh = false): Promise<{
posts: Post[];
hasMore: boolean;
}> => {
await new Promise(r => setTimeout(r, 1000));
// Simulate 5 pages of content
if (page > 5) return { posts: [], hasMore: false };
const posts = Array.from({ length: 10 }, (_, i) => ({
id: `${refresh ? 'new-' : ''}${page}-${i}-${Date.now()}`,
author: `User ${Math.floor(Math.random() * 100)}`,
avatar: `https://i.pravatar.cc/100?img=${(page * 10 + i) % 70}`,
content: `This is post #${(page - 1) * 10 + i + 1}. Lorem ipsum dolor sit amet.`,
likes: Math.floor(Math.random() * 500),
timestamp: new Date(Date.now() - Math.random() * 86400000).toISOString(),
}));
return { posts, hasMore: page < 5 };
};
const PostCard = React.memo(function PostCard({ post }: { post: Post }) {
return (
<View style={styles.postCard}>
<View style={styles.postHeader}>
<Image source={{ uri: post.avatar }} style={styles.avatar} />
<View>
<Text style={styles.authorName}>{post.author}</Text>
<Text style={styles.timestamp}>
{new Date(post.timestamp).toLocaleString()}
</Text>
</View>
</View>
<Text style={styles.content}>{post.content}</Text>
<Text style={styles.likes}>β€οΈ {post.likes} likes</Text>
</View>
);
});
export default function SocialFeed() {
const [posts, setPosts] = useState<Post[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const page = useRef(1);
const isLoadingMore = useRef(false);
// Initial load
useEffect(() => {
loadInitial();
}, []);
const loadInitial = async () => {
try {
const result = await fetchPosts(1);
setPosts(result.posts);
setHasMore(result.hasMore);
page.current = 1;
} catch (err) {
setError('Failed to load posts');
}
};
const onRefresh = useCallback(async () => {
setRefreshing(true);
setError(null);
try {
const result = await fetchPosts(1, true);
setPosts(result.posts);
setHasMore(result.hasMore);
page.current = 1;
} catch (err) {
setError('Failed to refresh');
} finally {
setRefreshing(false);
}
}, []);
const loadMore = useCallback(async () => {
if (isLoadingMore.current || !hasMore) return;
isLoadingMore.current = true;
try {
const nextPage = page.current + 1;
const result = await fetchPosts(nextPage);
setPosts(prev => [...prev, ...result.posts]);
setHasMore(result.hasMore);
page.current = nextPage;
} catch (err) {
setError('Failed to load more');
} finally {
isLoadingMore.current = false;
}
}, [hasMore]);
const renderItem = useCallback(
({ item }: { item: Post }) => <PostCard post={item} />,
[]
);
const renderFooter = () => {
if (error && posts.length > 0) {
return (
<Pressable style={styles.errorFooter} onPress={loadMore}>
<Text style={styles.errorText}>{error}</Text>
<Text style={styles.retryText}>Tap to retry</Text>
</Pressable>
);
}
if (!hasMore) {
return (
<View style={styles.endFooter}>
<Text style={styles.endText}>π You're all caught up!</Text>
</View>
);
}
return (
<View style={styles.loadingFooter}>
<ActivityIndicator color="#6200ee" />
</View>
);
};
return (
<FlatList
data={posts}
renderItem={renderItem}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#6200ee"
colors={['#6200ee']}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={renderFooter}
contentContainerStyle={styles.listContent}
/>
);
}
const styles = StyleSheet.create({
listContent: {
padding: 12,
},
postCard: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 3,
},
postHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
marginRight: 12,
},
authorName: {
fontWeight: '600',
fontSize: 15,
},
timestamp: {
color: '#666',
fontSize: 12,
marginTop: 2,
},
content: {
fontSize: 15,
lineHeight: 22,
marginBottom: 12,
},
likes: {
color: '#666',
fontSize: 13,
},
loadingFooter: {
paddingVertical: 24,
alignItems: 'center',
},
errorFooter: {
paddingVertical: 24,
alignItems: 'center',
},
errorText: {
color: '#f44336',
},
retryText: {
color: '#6200ee',
fontWeight: '600',
marginTop: 4,
},
endFooter: {
paddingVertical: 24,
alignItems: 'center',
},
endText: {
color: '#666',
fontSize: 15,
},
});
Exercise 2: Photo Grid Gallery
Build a 3-column photo grid with a "scroll to top" button.
Requirements:
- 3-column grid with proper spacing
- Square image cells
- Scroll to top button that appears after scrolling down
- Smooth animated scroll to top
- Hide scroll indicator
π‘ Hint
Use numColumns={3} and calculate item size from screen width. Track scroll position with onScroll to show/hide the button. Use scrollToOffset for the smooth scroll.
β Solution
import React, { useState, useCallback, useRef } from 'react';
import {
FlatList,
View,
Image,
Pressable,
Text,
Dimensions,
StyleSheet,
Animated,
} from 'react-native';
const NUM_COLUMNS = 3;
const GAP = 2;
const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_SIZE = (SCREEN_WIDTH - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS;
interface Photo {
id: string;
uri: string;
}
// Generate mock photos
const generatePhotos = (count: number): Photo[] =>
Array.from({ length: count }, (_, i) => ({
id: `photo-${i}`,
uri: `https://picsum.photos/seed/${i}/400/400`,
}));
const PhotoCell = React.memo(function PhotoCell({ photo }: { photo: Photo }) {
return (
<Pressable style={styles.photoCell}>
<Image source={{ uri: photo.uri }} style={styles.photo} />
</Pressable>
);
});
export default function PhotoGrid() {
const [photos] = useState(() => generatePhotos(100));
const [showScrollTop, setShowScrollTop] = useState(false);
const flatListRef = useRef<FlatList>(null);
const buttonOpacity = useRef(new Animated.Value(0)).current;
const handleScroll = useCallback((event) => {
const offsetY = event.nativeEvent.contentOffset.y;
const shouldShow = offsetY > 500;
if (shouldShow !== showScrollTop) {
setShowScrollTop(shouldShow);
Animated.timing(buttonOpacity, {
toValue: shouldShow ? 1 : 0,
duration: 200,
useNativeDriver: true,
}).start();
}
}, [showScrollTop, buttonOpacity]);
const scrollToTop = useCallback(() => {
flatListRef.current?.scrollToOffset({
offset: 0,
animated: true,
});
}, []);
const renderItem = useCallback(
({ item }: { item: Photo }) => <PhotoCell photo={item} />,
[]
);
const getItemLayout = useCallback(
(data: Photo[] | null | undefined, index: number) => {
const rowIndex = Math.floor(index / NUM_COLUMNS);
return {
length: ITEM_SIZE,
offset: (ITEM_SIZE + GAP) * rowIndex,
index,
};
},
[]
);
return (
<View style={styles.container}>
<FlatList
ref={flatListRef}
data={photos}
renderItem={renderItem}
keyExtractor={(item) => item.id}
numColumns={NUM_COLUMNS}
getItemLayout={getItemLayout}
onScroll={handleScroll}
scrollEventThrottle={100}
showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.row}
/>
<Animated.View style={[styles.scrollTopButton, { opacity: buttonOpacity }]}>
<Pressable onPress={scrollToTop} style={styles.buttonInner}>
<Text style={styles.buttonText}>β</Text>
</Pressable>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
row: {
gap: GAP,
marginBottom: GAP,
},
photoCell: {
width: ITEM_SIZE,
height: ITEM_SIZE,
},
photo: {
width: '100%',
height: '100%',
},
scrollTopButton: {
position: 'absolute',
bottom: 30,
right: 20,
},
buttonInner: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'rgba(255,255,255,0.9)',
justifyContent: 'center',
alignItems: 'center',
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
},
buttonText: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
},
});
Exercise 3: Simple Chat Interface
Build a basic chat interface with an inverted list and message input.
Requirements:
- Inverted FlatList for messages
- Messages from "me" aligned right with a different color
- Messages from "them" aligned left
- Text input at bottom with send button
- New messages appear at bottom automatically
- Keyboard handling (dismiss on drag)
π‘ Hint
Use inverted prop. Store messages newest-first in state. Wrap in KeyboardAvoidingView for proper keyboard handling. Use alignSelf for message alignment.
β Solution
import React, { useState, useCallback } from 'react';
import {
FlatList,
View,
Text,
TextInput,
Pressable,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
interface Message {
id: string;
text: string;
sender: 'me' | 'them';
timestamp: Date;
}
const INITIAL_MESSAGES: Message[] = [
{ id: '3', text: 'Looking forward to it!', sender: 'them', timestamp: new Date() },
{ id: '2', text: 'Sure, how about tomorrow at 2pm?', sender: 'me', timestamp: new Date() },
{ id: '1', text: 'Hey! Want to grab coffee sometime?', sender: 'them', timestamp: new Date() },
];
const MessageBubble = React.memo(function MessageBubble({
message
}: {
message: Message
}) {
const isMe = message.sender === 'me';
return (
<View style={[
styles.bubble,
isMe ? styles.myBubble : styles.theirBubble,
]}>
<Text style={[
styles.bubbleText,
isMe ? styles.myBubbleText : styles.theirBubbleText,
]}>
{message.text}
</Text>
<Text style={[
styles.time,
isMe ? styles.myTime : styles.theirTime,
]}>
{message.timestamp.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</Text>
</View>
);
});
export default function ChatInterface() {
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [inputText, setInputText] = useState('');
const sendMessage = useCallback(() => {
if (!inputText.trim()) return;
const newMessage: Message = {
id: Date.now().toString(),
text: inputText.trim(),
sender: 'me',
timestamp: new Date(),
};
// Add to beginning (newest first for inverted list)
setMessages(prev => [newMessage, ...prev]);
setInputText('');
// Simulate reply after 1 second
setTimeout(() => {
const reply: Message = {
id: (Date.now() + 1).toString(),
text: 'Got it! π',
sender: 'them',
timestamp: new Date(),
};
setMessages(prev => [reply, ...prev]);
}, 1000);
}, [inputText]);
const renderItem = useCallback(
({ item }: { item: Message }) => <MessageBubble message={item} />,
[]
);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<FlatList
data={messages}
renderItem={renderItem}
keyExtractor={(item) => item.id}
inverted
contentContainerStyle={styles.messageList}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
/>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder="Type a message..."
multiline
maxLength={500}
returnKeyType="send"
onSubmitEditing={sendMessage}
blurOnSubmit={false}
/>
<Pressable
style={[
styles.sendButton,
!inputText.trim() && styles.sendButtonDisabled,
]}
onPress={sendMessage}
disabled={!inputText.trim()}
>
<Text style={styles.sendText}>β€</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#e5ddd5',
},
messageList: {
padding: 16,
},
bubble: {
maxWidth: '75%',
padding: 10,
paddingHorizontal: 14,
borderRadius: 18,
marginBottom: 8,
},
myBubble: {
alignSelf: 'flex-end',
backgroundColor: '#dcf8c6',
borderBottomRightRadius: 4,
},
theirBubble: {
alignSelf: 'flex-start',
backgroundColor: '#fff',
borderBottomLeftRadius: 4,
},
bubbleText: {
fontSize: 16,
lineHeight: 22,
},
myBubbleText: {
color: '#000',
},
theirBubbleText: {
color: '#000',
},
time: {
fontSize: 11,
marginTop: 4,
},
myTime: {
color: '#7a8c6e',
alignSelf: 'flex-end',
},
theirTime: {
color: '#999',
alignSelf: 'flex-end',
},
inputContainer: {
flexDirection: 'row',
padding: 8,
backgroundColor: '#f0f0f0',
alignItems: 'flex-end',
},
input: {
flex: 1,
backgroundColor: '#fff',
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 10,
paddingRight: 16,
marginRight: 8,
maxHeight: 100,
fontSize: 16,
},
sendButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#25D366',
justifyContent: 'center',
alignItems: 'center',
},
sendButtonDisabled: {
backgroundColor: '#ccc',
},
sendText: {
fontSize: 20,
color: '#fff',
},
});
Exercise 4: Alphabet Scroll Index
Build a contacts list with an alphabet index for quick navigation.
Requirements:
- Contacts sorted alphabetically
- Right-side alphabet strip (A-Z)
- Tap a letter to scroll to that section
- Use
scrollToIndexfor navigation - Implement
getItemLayoutfor accurate scrolling
π‘ Hint
Create an index map that stores the first occurrence index of each letter. When tapping a letter, look up its index and use scrollToIndex. Make sure to implement getItemLayout for accurate positioning.
β Solution
import React, { useCallback, useMemo, useRef } from 'react';
import {
FlatList,
View,
Text,
Pressable,
StyleSheet,
} from 'react-native';
interface Contact {
id: string;
name: string;
}
// Generate alphabetically sorted contacts
const generateContacts = (): Contact[] => {
const names = [
'Alice', 'Amanda', 'Amy', 'Bob', 'Brian', 'Carol', 'Charlie',
'David', 'Diana', 'Edward', 'Emily', 'Frank', 'George', 'Hannah',
'Ivan', 'Julia', 'Kevin', 'Laura', 'Michael', 'Nancy', 'Oliver',
'Patricia', 'Quinn', 'Rachel', 'Steven', 'Tina', 'Uma', 'Victor',
'William', 'Xavier', 'Yolanda', 'Zachary',
];
return names.map((name, i) => ({
id: `contact-${i}`,
name: `${name} ${['Smith', 'Johnson', 'Williams', 'Brown'][i % 4]}`,
})).sort((a, b) => a.name.localeCompare(b.name));
};
const ITEM_HEIGHT = 56;
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
export default function AlphabetScrollList() {
const contacts = useMemo(() => generateContacts(), []);
const flatListRef = useRef<FlatList>(null);
// Build index map: letter -> first contact index
const letterIndexMap = useMemo(() => {
const map: Record<string, number> = {};
contacts.forEach((contact, index) => {
const letter = contact.name[0].toUpperCase();
if (!(letter in map)) {
map[letter] = index;
}
});
return map;
}, [contacts]);
const scrollToLetter = useCallback((letter: string) => {
const index = letterIndexMap[letter];
if (index !== undefined) {
flatListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0,
});
}
}, [letterIndexMap]);
const getItemLayout = useCallback(
(data: Contact[] | null | undefined, index: number) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[]
);
const renderItem = useCallback(({ item }: { item: Contact }) => (
<View style={styles.contactItem}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{item.name[0]}</Text>
</View>
<Text style={styles.contactName}>{item.name}</Text>
</View>
), []);
return (
<View style={styles.container}>
<FlatList
ref={flatListRef}
data={contacts}
renderItem={renderItem}
keyExtractor={(item) => item.id}
getItemLayout={getItemLayout}
showsVerticalScrollIndicator={false}
/>
{/* Alphabet Index */}
<View style={styles.alphabetContainer}>
{ALPHABET.map((letter) => {
const hasContacts = letter in letterIndexMap;
return (
<Pressable
key={letter}
onPress={() => scrollToLetter(letter)}
disabled={!hasContacts}
style={styles.letterButton}
>
<Text style={[
styles.letterText,
!hasContacts && styles.letterDisabled,
]}>
{letter}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
flexDirection: 'row',
},
contactItem: {
height: ITEM_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
avatar: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#6200ee',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
avatarText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
contactName: {
fontSize: 16,
},
alphabetContainer: {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: 24,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.9)',
},
letterButton: {
paddingVertical: 1,
paddingHorizontal: 4,
},
letterText: {
fontSize: 11,
fontWeight: '600',
color: '#6200ee',
},
letterDisabled: {
color: '#ccc',
},
});
Summary
You've now mastered the interactive features that make FlatList a powerful tool for building production-ready mobile apps.
π― Key Takeaways
- Pull-to-refresh: Use
refreshingandonRefreshprops, orRefreshControlfor customization - Infinite scroll: Use
onEndReachedwithonEndReachedThreshold, guard against duplicate calls - Scroll methods:
scrollToIndex,scrollToOffset,scrollToEndfor programmatic navigation - Scroll events: Use
onScrollwithscrollEventThrottlefor custom behaviors - Multi-column:
numColumnsfor grid layouts, calculate item sizes from screen width - Inverted:
invertedprop for chat interfaces, data should be newest-first - Sticky headers:
stickyHeaderIndicesfor headers that stay visible - Keyboard:
keyboardDismissModeandkeyboardShouldPersistTapsfor input handling
Feature Quick Reference
flowchart LR
subgraph Refresh["Data Loading"]
R1["Pull-to-refresh"]
R2["Infinite scroll"]
end
subgraph Navigation["Navigation"]
N1["scrollToIndex"]
N2["scrollToOffset"]
N3["scrollToEnd"]
end
subgraph Layout["Layout"]
L1["numColumns"]
L2["inverted"]
L3["stickyHeaderIndices"]
end
subgraph Events["Events"]
E1["onScroll"]
E2["onEndReached"]
E3["keyboard modes"]
end
style R1 fill:#c8e6c9
style R2 fill:#c8e6c9
style N1 fill:#bbdefb
style N2 fill:#bbdefb
style N3 fill:#bbdefb
style L1 fill:#fff3cd
style L2 fill:#fff3cd
style L3 fill:#fff3cd
π What's Next?
Now that you're a FlatList expert, the next lesson covers SectionListβFlatList's sibling for grouped data with section headers. You'll learn when to choose SectionList over FlatList, how to structure sectioned data, and how to create sticky section headers for contacts, settings, and grouped content screens.