Module 5: Lists and Performance
FlatList Fundamentals
Your go-to component for performant lists of any size
π― Learning Objectives
- Understand the FlatList API and its required props
- Implement proper key extraction for optimal performance
- Master the renderItem pattern and its parameters
- Add headers, footers, separators, and empty states
- Handle common FlatList gotchas that trip up developers
- Build real-world list patterns from scratch
FlatList Anatomy
FlatList might look simple at first glance, but it's a sophisticated component with many moving parts. Before diving into the API, let's understand its overall structure and how the pieces fit together.
Every FlatList consists of these conceptual parts:
- Data source: The array of items to render
- Key extractor: How to uniquely identify each item
- Render function: How to turn each data item into UI
- List chrome: Optional headers, footers, separators
- Empty state: What to show when there's no data
- Virtualization engine: The magic that makes it all performant (handled internally)
Let's explore each of these in detail.
The Required Props
FlatList has only two truly required props, but understanding them deeply is essential for building performant lists.
import { FlatList } from 'react-native';
// Minimal FlatList - just data and renderItem
<FlatList
data={myArray}
renderItem={({ item }) => <ItemComponent item={item} />}
/>
π The Two Required Props
data: An array of items to render. Can be any shapeβFlatList doesn't care what's inside.
renderItem: A function that receives each item and returns a React element to display.
The data Prop
The data prop accepts any array. FlatList iterates through it, passing each element to your renderItem function:
// Simple array of strings
const names = ['Alice', 'Bob', 'Charlie'];
<FlatList
data={names}
renderItem={({ item }) => <Text>{item}</Text>}
/>
// Array of objects (most common)
const users = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
{ id: '3', name: 'Charlie', email: 'charlie@example.com' },
];
<FlatList
data={users}
renderItem={({ item }) => (
<View>
<Text>{item.name}</Text>
<Text>{item.email}</Text>
</View>
)}
/>
// Array from API response
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
fetchPosts().then(setPosts);
}, []);
<FlatList
data={posts}
renderItem={({ item }) => <PostCard post={item} />}
/>
β οΈ Don't Mutate Data
FlatList uses reference equality to detect changes. If you mutate the array directly (like data.push(newItem)), FlatList won't re-render. Always create new arrays:
// β Wrong - mutation
data.push(newItem);
setData(data);
// β
Correct - new array
setData([...data, newItem]);
The renderItem Prop
The renderItem function is called for each visible item (plus buffer items). It receives an object with several useful properties:
// renderItem receives an object, not just the item
<FlatList
data={users}
renderItem={(info) => {
// info contains:
// - item: The data item from your array
// - index: The position in the array
// - separators: Functions to update separators (advanced)
const { item, index } = info;
return (
<View style={[
styles.item,
index === 0 && styles.firstItem,
]}>
<Text>{index + 1}. {item.name}</Text>
</View>
);
}}
/>
// Most common pattern: destructure in the parameter
<FlatList
data={users}
renderItem={({ item, index }) => (
<UserCard user={item} position={index} />
)}
/>
flowchart LR
subgraph Data["data array"]
D1["{ id: 1, name: 'Alice' }"]
D2["{ id: 2, name: 'Bob' }"]
D3["{ id: 3, name: 'Charlie' }"]
end
subgraph RenderItem["renderItem calls"]
R1["({ item, index: 0 })"]
R2["({ item, index: 1 })"]
R3["({ item, index: 2 })"]
end
subgraph Output["Rendered UI"]
U1["<UserCard />"]
U2["<UserCard />"]
U3["<UserCard />"]
end
D1 --> R1 --> U1
D2 --> R2 --> U2
D3 --> R3 --> U3
Key Extraction Deep Dive
While technically not required (FlatList will warn but still work), proper key extraction is critical for performance and correctness. Keys help React identify which items have changed, been added, or been removed.
// Default behavior - uses index (not recommended)
<FlatList
data={users}
renderItem={({ item }) => <UserCard user={item} />}
/>
// Warning: VirtualizedList: missing keys for items...
// Explicit keyExtractor (recommended)
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <UserCard user={item} />}
/>
// keyExtractor with index (for arrays without unique IDs)
<FlatList
data={names}
keyExtractor={(item, index) => `name-${index}`}
renderItem={({ item }) => <Text>{item}</Text>}
/>
π¨ Why Index Keys Are Dangerous
Using array indices as keys causes problems when items are reordered, inserted, or deleted:
// Initial render with index keys:
// Index 0 β "Alice" β <UserCard key="0" />
// Index 1 β "Bob" β <UserCard key="1" />
// Index 2 β "Charlie" β <UserCard key="2" />
// After deleting "Alice":
// Index 0 β "Bob" β <UserCard key="0" /> β React thinks this is Alice!
// Index 1 β "Charlie" β <UserCard key="1" /> β React thinks this is Bob!
// Result: Wrong data in components, broken animations, lost state
Key Extraction Patterns
Different data shapes require different key extraction strategies:
// Pattern 1: Objects with ID field (most common)
interface User {
id: string;
name: string;
}
keyExtractor={(item) => item.id}
// Pattern 2: Objects with different ID field name
interface Product {
productId: number;
title: string;
}
keyExtractor={(item) => item.productId.toString()}
// Pattern 3: Composite keys
interface Message {
visiblechatId: string;
messageId: string;
content: string;
}
keyExtractor={(item) => `${item.chatId}-${item.messageId}`}
// Pattern 4: Objects without natural IDs
// Option A: Add IDs when fetching
const fetchItems = async () => {
const response = await api.getItems();
return response.map((item, index) => ({
...item,
_id: `item-${Date.now()}-${index}`,
}));
};
// Option B: Use index only if list is static and never reorders
// This is acceptable for truly static lists like menu items
const menuItems = ['Home', 'Profile', 'Settings'];
keyExtractor={(item, index) => `menu-${index}`} // OK if never changes
// Pattern 5: Using a key property directly
interface Item {
key: string; // FlatList auto-detects this!
title: string;
}
// No keyExtractor needed - FlatList uses item.key automatically
β Key Best Practices
- Keys must be unique within the list (duplicates cause rendering bugs)
- Keys must be stable β the same item should always have the same key
- Keys should be strings β convert numbers with
.toString() - Prefer natural IDs over generated ones when available
- Use
item.keyproperty for automatic extraction
The renderItem Function
Your renderItem function is called frequentlyβevery time an item enters the visible area. Understanding how to write efficient render functions is crucial.
The renderItem Signature
// Full type signature
type ListRenderItem<T> = (info: ListRenderItemInfo<T>) => React.ReactElement;
interface ListRenderItemInfo<T> {
item: T; // The data item
index: number; // Position in the array
separators: {
highlight: () => void; // Highlight adjacent separators
unhighlight: () => void; // Remove highlight
updateProps: (
select: 'leading' | 'trailing',
newProps: any
) => void;
};
}
// Practical usage
const renderItem: ListRenderItem<User> = ({ item, index }) => (
<UserCard user={item} isFirst={index === 0} />
);
Inline vs Extracted Functions
You'll see two patterns for defining renderItemβboth have tradeoffs:
// Pattern 1: Inline function (simple, but recreated each render)
function UserList({ users }) {
return (
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.item}>
<Text>{item.name}</Text>
</View>
)}
/>
);
}
// Pattern 2: Extracted function (stable reference)
function UserList({ users }) {
const renderItem = useCallback<ListRenderItem<User>>(
({ item }) => (
<View style={styles.item}>
<Text>{item.name}</Text>
</View>
),
[] // No dependencies - function never changes
);
return (
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={renderItem}
/>
);
}
// Pattern 3: Separate component (cleanest for complex items)
const UserItem = memo(function UserItem({ user }: { user: User }) {
return (
<View style={styles.item}>
<Text>{user.name}</Text>
</View>
);
});
function UserList({ users }) {
const renderItem = useCallback<ListRenderItem<User>>(
({ item }) => <UserItem user={item} />,
[]
);
return (
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={renderItem}
/>
);
}
π‘ When to Use Each Pattern
| Pattern | Use When |
|---|---|
| Inline | Simple items, small lists, prototyping |
| useCallback | Parent re-renders often, need stable reference |
| Separate + memo | Complex items, performance critical lists |
Passing Extra Data to renderItem
Sometimes your renderItem needs access to data beyond just the item itself:
// Problem: How to pass onPress to renderItem?
function UserList({ users, onUserSelect }) {
// β This works but creates new function every render
return (
<FlatList
data={users}
renderItem={({ item }) => (
<Pressable onPress={() => onUserSelect(item.id)}>
<Text>{item.name}</Text>
</Pressable>
)}
/>
);
}
// β
Better: Extract item component that receives handler
const UserItem = memo(function UserItem({
user,
onSelect
}: {
user: User;
onSelect: (id: string) => void;
}) {
const handlePress = useCallback(() => {
onSelect(user.id);
}, [user.id, onSelect]);
return (
<Pressable onPress={handlePress}>
<Text>{user.name}</Text>
</Pressable>
);
});
function UserList({ users, onUserSelect }) {
const renderItem = useCallback<ListRenderItem<User>>(
({ item }) => <UserItem user={item} onSelect={onUserSelect} />,
[onUserSelect]
);
return (
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={renderItem}
/>
);
}
List Chrome: Headers, Footers, and More
FlatList provides several props for adding "chrome" around your list itemsβheaders, footers, and other decorative or functional elements that appear once in the list.
ListHeaderComponent
Renders once at the top of the list, before any items. Scrolls with the content.
// Simple header
<FlatList
data={products}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.headerTitle}>Featured Products</Text>
<Text style={styles.headerSubtitle}>{products.length} items</Text>
</View>
}
renderItem={({ item }) => <ProductCard product={item} />}
/>
// Header as a component (for complex headers)
const ListHeader = () => (
<View style={styles.header}>
<Image source={require('./banner.png')} style={styles.banner} />
<SearchBar />
<CategoryFilter />
</View>
);
<FlatList
data={products}
ListHeaderComponent={ListHeader}
renderItem={({ item }) => <ProductCard product={item} />}
/>
// Header with dynamic content
function ProductList({ products, category }) {
// Memoize if header depends on props
const ListHeader = useMemo(() => (
<View style={styles.header}>
<Text>{category.name}</Text>
<Text>{products.length} products</Text>
</View>
), [category.name, products.length]);
return (
<FlatList
data={products}
ListHeaderComponent={ListHeader}
renderItem={({ item }) => <ProductCard product={item} />}
/>
);
}
ListFooterComponent
Renders once at the bottom of the list, after all items. Perfect for loading indicators or "end of list" messages.
// Loading indicator in footer
function PostFeed({ posts, isLoading, hasMore }) {
const ListFooter = () => {
if (isLoading) {
return (
<View style={styles.footer}>
<ActivityIndicator size="large" color="#0000ff" />
<Text>Loading more posts...</Text>
</View>
);
}
if (!hasMore) {
return (
<View style={styles.footer}>
<Text style={styles.endMessage}>You've seen all posts!</Text>
</View>
);
}
return null;
};
return (
<FlatList
data={posts}
renderItem={({ item }) => <PostCard post={item} />}
ListFooterComponent={ListFooter}
/>
);
}
// Styled footer with spacing
<FlatList
data={items}
renderItem={renderItem}
ListFooterComponent={<View style={{ height: 100 }} />}
ListFooterComponentStyle={styles.footerContainer}
/>
Header and Footer Styling
Use the style props to add consistent spacing or backgrounds:
<FlatList
data={items}
renderItem={renderItem}
ListHeaderComponent={<Header />}
ListHeaderComponentStyle={{
backgroundColor: '#f5f5f5',
paddingBottom: 16,
}}
ListFooterComponent={<Footer />}
ListFooterComponentStyle={{
paddingVertical: 20,
alignItems: 'center',
}}
/>
Handling Empty States
When data is an empty array, FlatList renders nothing by default. The ListEmptyComponent prop lets you show a meaningful empty state instead.
// Basic empty state
<FlatList
data={searchResults}
renderItem={({ item }) => <ResultCard result={item} />}
ListEmptyComponent={
<View style={styles.empty}>
<Text>No results found</Text>
</View>
}
/>
// Empty state with illustration
const EmptyState = () => (
<View style={styles.emptyContainer}>
<Image
source={require('./empty-inbox.png')}
style={styles.emptyImage}
/>
<Text style={styles.emptyTitle}>Your inbox is empty</Text>
<Text style={styles.emptySubtitle}>
Messages you receive will appear here
</Text>
</View>
);
<FlatList
data={messages}
renderItem={renderMessage}
ListEmptyComponent={EmptyState}
/>
// Context-aware empty state
function TaskList({ tasks, filter }) {
const EmptyState = () => {
if (filter === 'completed') {
return (
<View style={styles.empty}>
<Text>No completed tasks yet</Text>
<Text>Complete a task to see it here!</Text>
</View>
);
}
if (filter === 'today') {
return (
<View style={styles.empty}>
<Text>No tasks for today π</Text>
<Pressable onPress={addTask}>
<Text>Add a task</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.empty}>
<Text>No tasks</Text>
</View>
);
};
return (
<FlatList
data={tasks}
renderItem={renderTask}
ListEmptyComponent={EmptyState}
/>
);
}
β Empty State Best Practices
- Be specific: "No search results for 'xyz'" is better than "No results"
- Provide next steps: Include a call-to-action when appropriate
- Use illustrations: Visual empty states feel more polished
- Consider loading state: Don't show empty state while data is loading
- Match context: Different empty messages for different filters/states
Empty vs Loading States
A common mistake is showing the empty state while data is still loading:
// β Wrong: Shows "No data" while loading
function BadExample() {
const [data, setData] = useState([]);
useEffect(() => {
fetchData().then(setData);
}, []);
return (
<FlatList
data={data}
renderItem={renderItem}
ListEmptyComponent={<Text>No data</Text>} // Shows immediately!
/>
);
}
// β
Correct: Track loading state separately
function GoodExample() {
const [data, setData] = useState<Item[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchData()
.then(setData)
.finally(() => setIsLoading(false));
}, []);
const EmptyComponent = () => {
if (isLoading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" />
<Text>Loading...</Text>
</View>
);
}
return (
<View style={styles.centered}>
<Text>No data available</Text>
</View>
);
};
return (
<FlatList
data={data}
renderItem={renderItem}
ListEmptyComponent={EmptyComponent}
/>
);
}
Item Separators
FlatList provides ItemSeparatorComponent for rendering separators between items. This is cleaner than adding borders to each item and gives you more control.
// Simple line separator
const Separator = () => (
<View style={{
height: 1,
backgroundColor: '#e0e0e0',
marginHorizontal: 16,
}} />
);
<FlatList
data={items}
renderItem={renderItem}
ItemSeparatorComponent={Separator}
/>
// Separator with spacing
const SpacedSeparator = () => (
<View style={{ height: 12 }} />
);
// No separator needed between last item and footer
// ItemSeparatorComponent automatically handles this!
// Contextual separator (different after highlighted item)
function ContactList({ contacts, selectedId }) {
const Separator = ({ highlighted }) => (
<View
style={[
styles.separator,
highlighted && styles.separatorHighlighted,
]}
/>
);
return (
<FlatList
data={contacts}
renderItem={({ item, separators }) => (
<Pressable
onPressIn={() => separators.highlight()}
onPressOut={() => separators.unhighlight()}
>
<ContactRow contact={item} />
</Pressable>
)}
ItemSeparatorComponent={Separator}
/>
);
}
π‘ Separator vs Border
Use ItemSeparatorComponent when:
- You want separators between items but not after the last item
- Separators need to respond to item state (highlighted, selected)
- You want consistent spacing without item knowledge
Use item borders when:
- Every item needs a border (including last item)
- Borders are part of the item's visual design
- Simple cases where separator complexity isn't needed
Horizontal Lists
FlatList easily converts to a horizontal scrolling list with a single prop. This is perfect for carousels, category selectors, and media galleries.
// Basic horizontal list
<FlatList
data={categories}
horizontal={true}
renderItem={({ item }) => (
<Pressable style={styles.categoryChip}>
<Text>{item.name}</Text>
</Pressable>
)}
/>
// Horizontal with configuration
<FlatList
data={movies}
horizontal
showsHorizontalScrollIndicator={false} // Hide scrollbar
contentContainerStyle={styles.horizontalList}
ItemSeparatorComponent={() => <View style={{ width: 12 }} />}
renderItem={({ item }) => <MoviePoster movie={item} />}
/>
// Horizontal carousel with snap
<FlatList
data={featuredItems}
horizontal
pagingEnabled // Snap to item width
showsHorizontalScrollIndicator={false}
snapToInterval={CARD_WIDTH + 16} // Custom snap points
decelerationRate="fast"
renderItem={({ item }) => (
<View style={{ width: CARD_WIDTH }}>
<FeaturedCard item={item} />
</View>
)}
/>
flowchart LR
subgraph Vertical["horizontal={false} (default)"]
direction TB
V1["Item 1"]
V2["Item 2"]
V3["Item 3"]
V4["..."]
V1 --> V2 --> V3 --> V4
end
subgraph Horizontal["horizontal={true}"]
direction LR
H1["Item 1"]
H2["Item 2"]
H3["Item 3"]
H4["..."]
H1 --> H2 --> H3 --> H4
end
style V1 fill:#c8e6c9
style V2 fill:#c8e6c9
style V3 fill:#c8e6c9
style V4 fill:#e8f5e9
style H1 fill:#bbdefb
style H2 fill:#bbdefb
style H3 fill:#bbdefb
style H4 fill:#e3f2fd
Common Horizontal Patterns
// Pattern 1: Category filter chips
function CategoryFilter({ categories, selected, onSelect }) {
return (
<FlatList
data={categories}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }}
ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Pressable
style={[
styles.chip,
selected === item.id && styles.chipSelected,
]}
onPress={() => onSelect(item.id)}
>
<Text style={[
styles.chipText,
selected === item.id && styles.chipTextSelected,
]}>
{item.name}
</Text>
</Pressable>
)}
/>
);
}
// Pattern 2: Image gallery with aspect ratio
function ImageGallery({ images }) {
return (
<FlatList
data={images}
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={SCREEN_WIDTH * 0.8 + 16}
decelerationRate="fast"
contentContainerStyle={{ paddingHorizontal: 16 }}
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
renderItem={({ item }) => (
<Image
source={{ uri: item.url }}
style={{
width: SCREEN_WIDTH * 0.8,
aspectRatio: 16 / 9,
borderRadius: 12,
}}
/>
)}
/>
);
}
// Pattern 3: Story-style avatars
function StoryAvatars({ users }) {
return (
<FlatList
data={users}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingVertical: 12, paddingHorizontal: 8 }}
renderItem={({ item }) => (
<Pressable style={styles.storyContainer}>
<View style={[
styles.storyRing,
item.hasNewStory && styles.storyRingActive,
]}>
<Image
source={{ uri: item.avatar }}
style={styles.storyAvatar}
/>
</View>
<Text style={styles.storyName} numberOfLines={1}>
{item.name}
</Text>
</Pressable>
)}
/>
);
}
β οΈ Horizontal List Gotchas
- Width matters: Items need explicit widths (no flex: 1)
- Separator direction: Use
widthnotheightfor separators - Content padding: Use
contentContainerStylefor horizontal padding - Nested scrolls: Horizontal FlatList inside vertical ScrollView can cause gesture conflicts
Common Gotchas
FlatList has some behaviors that trip up developers, especially those coming from web development. Let's address the most common issues.
Gotcha #1: FlatList Doesn't Fill the Screen
FlatList needs its parent to have a defined height. Without it, FlatList collapses.
// β Problem: FlatList has no height
function BrokenScreen() {
return (
<View> {/* This View has no flex: 1 */}
<FlatList
data={items}
renderItem={renderItem}
/>
</View>
);
}
// β
Solution 1: flex: 1 on parent
function FixedScreen() {
return (
<View style={{ flex: 1 }}>
<FlatList
data={items}
renderItem={renderItem}
/>
</View>
);
}
// β
Solution 2: flex: 1 on FlatList itself
function AlsoFixed() {
return (
<View>
<FlatList
style={{ flex: 1 }}
data={items}
renderItem={renderItem}
/>
</View>
);
}
// β
Best practice: Both have flex: 1
function BestPractice() {
return (
<View style={styles.container}>
<FlatList
style={styles.list}
data={items}
renderItem={renderItem}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
list: {
flex: 1,
},
});
Gotcha #2: Content Doesn't Scroll
If your list content doesn't scroll, the content is shorter than the container.
// β Problem: Only 3 items, don't fill the screen
<FlatList
data={[1, 2, 3]}
renderItem={({ item }) => <SmallItem item={item} />}
/>
// Result: No scrolling (content fits)
// β
This is actually correct behavior!
// FlatList only scrolls when content exceeds container
// If you need it to always scroll (rare):
<FlatList
data={[1, 2, 3]}
renderItem={({ item }) => <SmallItem item={item} />}
alwaysBounceVertical={true} // iOS: allows pull even when content fits
/>
Gotcha #3: Function Props Cause Re-renders
Creating new functions on every render can trigger unnecessary re-renders of list items.
// β Problem: New function created every render
function BadList({ items, onItemPress }) {
return (
<FlatList
data={items}
renderItem={({ item }) => (
<Pressable onPress={() => onItemPress(item.id)}>
<Text>{item.name}</Text>
</Pressable>
)}
/>
);
}
// Every parent re-render creates new renderItem function
// FlatList thinks it needs to re-render everything
// β
Solution: useCallback for stable reference
function GoodList({ items, onItemPress }) {
const renderItem = useCallback(
({ item }) => (
<Pressable onPress={() => onItemPress(item.id)}>
<Text>{item.name}</Text>
</Pressable>
),
[onItemPress]
);
const keyExtractor = useCallback(
(item) => item.id,
[]
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
/>
);
}
Gotcha #4: Extra Data Not Causing Updates
FlatList uses shallow comparison on data to decide when to re-render. If you change something outside of data that affects rendering, use extraData.
// β Problem: Selected state doesn't trigger re-render
function SelectableList({ items }) {
const [selectedId, setSelectedId] = useState(null);
return (
<FlatList
data={items}
renderItem={({ item }) => (
<Pressable
style={[
styles.item,
item.id === selectedId && styles.selected, // Won't update!
]}
onPress={() => setSelectedId(item.id)}
>
<Text>{item.name}</Text>
</Pressable>
)}
/>
);
}
// items didn't change, so FlatList doesn't re-render
// selectedId change is invisible to FlatList
// β
Solution: Use extraData
function SelectableListFixed({ items }) {
const [selectedId, setSelectedId] = useState(null);
return (
<FlatList
data={items}
extraData={selectedId} // Tell FlatList to watch this too
renderItem={({ item }) => (
<Pressable
style={[
styles.item,
item.id === selectedId && styles.selected,
]}
onPress={() => setSelectedId(item.id)}
>
<Text>{item.name}</Text>
</Pressable>
)}
/>
);
}
// Multiple extra values? Use an object or array
<FlatList
data={items}
extraData={{ selectedId, isEditing, theme }}
// or
extraData={[selectedId, isEditing, theme]}
/>
Gotcha #5: VirtualizedList Warning with ScrollView
Nesting FlatList inside ScrollView triggers a warning because both are scroll containers.
// β Warning: VirtualizedLists should never be nested
<ScrollView>
<Text>Some header content</Text>
<FlatList data={items} renderItem={renderItem} />
<Text>Some footer content</Text>
</ScrollView>
// β
Solution 1: Use ListHeaderComponent and ListFooterComponent
<FlatList
data={items}
renderItem={renderItem}
ListHeaderComponent={<Text>Some header content</Text>}
ListFooterComponent={<Text>Some footer content</Text>}
/>
// β
Solution 2: Use scrollEnabled={false} for non-scrolling inner list
<ScrollView>
<Text>Header</Text>
<FlatList
data={items}
renderItem={renderItem}
scrollEnabled={false} // Disables FlatList scroll
/>
<Text>Footer</Text>
</ScrollView>
// β οΈ Warning: This defeats virtualization! Only for small lists.
// β
Solution 3: Use FlatList with mixed content types
const listData = [
{ type: 'header', content: 'Header text' },
...items.map(item => ({ type: 'item', data: item })),
{ type: 'footer', content: 'Footer text' },
];
<FlatList
data={listData}
renderItem={({ item }) => {
if (item.type === 'header') return <Header content={item.content} />;
if (item.type === 'footer') return <Footer content={item.content} />;
return <ItemComponent data={item.data} />;
}}
/>
Gotcha #6: Inverted Lists Start at Bottom
For chat-style interfaces where newest content is at the bottom, use the inverted prop.
// Chat messages - newest at bottom
<FlatList
data={messages}
inverted // Flips the list upside down
renderItem={({ item }) => <MessageBubble message={item} />}
keyExtractor={(item) => item.id}
/>
// β οΈ Note: Your data should still be newest-first
// inverted just renders from bottom to top
const messages = [
{ id: '3', text: 'Newest message' }, // Shows at bottom
{ id: '2', text: 'Middle message' },
{ id: '1', text: 'Oldest message' }, // Shows at top
];
π§ Quick Reference: Common Issues
| Symptom | Solution |
|---|---|
| List doesn't appear | Add flex: 1 to parent View |
| Items don't update | Check extraData, verify keys are stable |
| Performance is slow | Use useCallback for renderItem, memoize item components |
| Nested scroll warning | Use ListHeaderComponent/ListFooterComponent |
| Missing key warning | Add keyExtractor prop |
| Wrong items after update | Keys aren't unique or stable |
TypeScript Patterns
TypeScript makes FlatList development safer and more productive. Here are the patterns you'll use most often.
Basic Typing
import { FlatList, ListRenderItem } from 'react-native';
// Define your data type
interface User {
id: string;
name: string;
email: string;
avatar: string;
}
// Type the FlatList with generic parameter
function UserList() {
const [users, setUsers] = useState<User[]>([]);
// Option 1: Type renderItem inline
return (
<FlatList<User>
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
// item is typed as User
<Text>{item.name}</Text>
)}
/>
);
}
// Option 2: Extract renderItem with proper typing
function UserListExtracted() {
const [users, setUsers] = useState<User[]>([]);
const renderItem: ListRenderItem<User> = useCallback(
({ item, index }) => (
<View>
<Text>{index + 1}. {item.name}</Text>
<Text>{item.email}</Text>
</View>
),
[]
);
return (
<FlatList<User>
data={users}
keyExtractor={(item) => item.id}
renderItem={renderItem}
/>
);
}
Typing keyExtractor
// keyExtractor must return a string
interface Product {
productId: number; // number, not string!
title: string;
}
// β Type error: number is not assignable to string
const badKeyExtractor = (item: Product) => item.productId;
// β
Convert to string
const goodKeyExtractor = (item: Product) => item.productId.toString();
// Or with template literal
const alsoGood = (item: Product) => `product-${item.productId}`;
Typing Item Components
// Separate item component with proper props
interface UserCardProps {
user: User;
onPress: (userId: string) => void;
isSelected: boolean;
}
const UserCard = memo(function UserCard({
user,
onPress,
isSelected
}: UserCardProps) {
const handlePress = useCallback(() => {
onPress(user.id);
}, [user.id, onPress]);
return (
<Pressable
onPress={handlePress}
style={[styles.card, isSelected && styles.cardSelected]}
>
<Image source={{ uri: user.avatar }} style={styles.avatar} />
<View style={styles.info}>
<Text style={styles.name}>{user.name}</Text>
<Text style={styles.email}>{user.email}</Text>
</View>
</Pressable>
);
});
// Usage in FlatList
function SelectableUserList() {
const [users, setUsers] = useState<User[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const handleUserPress = useCallback((userId: string) => {
setSelectedId(userId);
}, []);
const renderItem: ListRenderItem<User> = useCallback(
({ item }) => (
<UserCard
user={item}
onPress={handleUserPress}
isSelected={item.id === selectedId}
/>
),
[handleUserPress, selectedId]
);
return (
<FlatList<User>
data={users}
keyExtractor={(item) => item.id}
renderItem={renderItem}
extraData={selectedId}
/>
);
}
Ref Typing
import { FlatList } from 'react-native';
import { useRef } from 'react';
function ScrollableUserList() {
// Type the ref with the item type
const flatListRef = useRef<FlatList<User>>(null);
const scrollToTop = () => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
};
const scrollToItem = (userId: string) => {
const index = users.findIndex(u => u.id === userId);
if (index !== -1) {
flatListRef.current?.scrollToIndex({ index, animated: true });
}
};
return (
<>
<Button title="Scroll to Top" onPress={scrollToTop} />
<FlatList<User>
ref={flatListRef}
data={users}
keyExtractor={(item) => item.id}
renderItem={renderItem}
/>
</>
);
}
β TypeScript Best Practices
- Always type your data: Define interfaces for list items
- Use generic parameter:
<FlatList<YourType>for full type safety - Type your callbacks:
ListRenderItem<T>for renderItem - Type refs correctly:
useRef<FlatList<T>>(null) - Avoid
any: If item shape is unknown, useunknownand narrow
Hands-On Exercises
Let's build some real-world list patterns to solidify your FlatList skills.
Exercise 1: Basic Contact List
Build a scrollable contact list with avatars, names, and phone numbers.
Requirements:
- Display 20+ contacts from mock data
- Each contact shows: avatar (colored circle with initials), name, phone number
- Use proper key extraction
- Add a separator line between items
- Include a list header showing total count
π‘ Hint
Create a getInitials helper function. Use ItemSeparatorComponent for lines. Generate colors deterministically from names using a hash function.
β Solution
import React, { useCallback, useMemo } from 'react';
import {
FlatList,
View,
Text,
StyleSheet,
ListRenderItem,
} from 'react-native';
interface Contact {
id: string;
name: string;
phone: string;
}
// Generate mock contacts
const generateContacts = (): Contact[] => {
const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Edward', 'Fiona',
'George', 'Hannah', 'Ivan', 'Julia', 'Kevin', 'Laura', 'Michael', 'Nina',
'Oscar', 'Patricia', 'Quinn', 'Rachel', 'Steven', 'Tina', 'Uma', 'Victor'];
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia',
'Miller', 'Davis', 'Rodriguez', 'Martinez', 'Wilson', 'Anderson'];
return Array.from({ length: 25 }, (_, i) => ({
id: `contact-${i}`,
name: `${firstNames[i % firstNames.length]} ${lastNames[i % lastNames.length]}`,
phone: `(${Math.floor(Math.random() * 900) + 100}) ${
Math.floor(Math.random() * 900) + 100}-${
Math.floor(Math.random() * 9000) + 1000}`,
}));
};
// Get initials from name
const getInitials = (name: string): string => {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
// Generate consistent color from string
const stringToColor = (str: string): string => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3',
'#009688', '#4caf50', '#ff9800', '#ff5722', '#795548'];
return colors[Math.abs(hash) % colors.length];
};
// Contact item component
const ContactItem = ({ contact }: { contact: Contact }) => {
const initials = getInitials(contact.name);
const avatarColor = stringToColor(contact.name);
return (
<View style={styles.contactItem}>
<View style={[styles.avatar, { backgroundColor: avatarColor }]}>
<Text style={styles.avatarText}>{initials}</Text>
</View>
<View style={styles.contactInfo}>
<Text style={styles.contactName}>{contact.name}</Text>
<Text style={styles.contactPhone}>{contact.phone}</Text>
</View>
</View>
);
};
// Separator component
const Separator = () => <View style={styles.separator} />;
// Main component
export default function ContactList() {
const contacts = useMemo(() => generateContacts(), []);
const renderItem: ListRenderItem<Contact> = useCallback(
({ item }) => <ContactItem contact={item} />,
[]
);
const keyExtractor = useCallback(
(item: Contact) => item.id,
[]
);
const ListHeader = useMemo(() => (
<View style={styles.header}>
<Text style={styles.headerTitle}>Contacts</Text>
<Text style={styles.headerCount}>{contacts.length} contacts</Text>
</View>
), [contacts.length]);
return (
<View style={styles.container}>
<FlatList<Contact>
data={contacts}
renderItem={renderItem}
keyExtractor={keyExtractor}
ItemSeparatorComponent={Separator}
ListHeaderComponent={ListHeader}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
header: {
padding: 16,
backgroundColor: '#f5f5f5',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
headerTitle: {
fontSize: 24,
fontWeight: 'bold',
},
headerCount: {
fontSize: 14,
color: '#666',
marginTop: 4,
},
contactItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: '#fff',
},
avatar: {
width: 50,
height: 50,
borderRadius: 25,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
contactInfo: {
marginLeft: 12,
flex: 1,
},
contactName: {
fontSize: 16,
fontWeight: '600',
},
contactPhone: {
fontSize: 14,
color: '#666',
marginTop: 2,
},
separator: {
height: 1,
backgroundColor: '#e0e0e0',
marginLeft: 74, // Avatar width + padding
},
});
Exercise 2: Horizontal Category Selector
Build a horizontal scrolling category filter with selectable chips.
Requirements:
- Horizontal FlatList of category chips
- Tap to select a category (single selection)
- Selected chip has different styling (background color, text color)
- Hide the scroll indicator
- Add padding on the sides of the list
π‘ Hint
Use horizontal, showsHorizontalScrollIndicator={false}, and contentContainerStyle for padding. Don't forget extraData for the selected state!
β Solution
import React, { useState, useCallback, useMemo } from 'react';
import {
FlatList,
View,
Text,
Pressable,
StyleSheet,
ListRenderItem,
} from 'react-native';
interface Category {
id: string;
name: string;
icon: string;
}
const CATEGORIES: Category[] = [
{ id: '1', name: 'All', icon: 'π ' },
{ id: '2', name: 'Food', icon: 'π' },
{ id: '3', name: 'Fashion', icon: 'π' },
{ id: '4', name: 'Electronics', icon: 'π±' },
{ id: '5', name: 'Sports', icon: 'β½' },
{ id: '6', name: 'Books', icon: 'π' },
{ id: '7', name: 'Music', icon: 'π΅' },
{ id: '8', name: 'Travel', icon: 'βοΈ' },
{ id: '9', name: 'Art', icon: 'π¨' },
];
interface CategoryChipProps {
category: Category;
isSelected: boolean;
onPress: (id: string) => void;
}
const CategoryChip = ({ category, isSelected, onPress }: CategoryChipProps) => {
const handlePress = useCallback(() => {
onPress(category.id);
}, [category.id, onPress]);
return (
<Pressable
onPress={handlePress}
style={[
styles.chip,
isSelected && styles.chipSelected,
]}
>
<Text style={styles.chipIcon}>{category.icon}</Text>
<Text style={[
styles.chipText,
isSelected && styles.chipTextSelected,
]}>
{category.name}
</Text>
</Pressable>
);
};
export default function CategorySelector() {
const [selectedId, setSelectedId] = useState<string>('1');
const handleCategoryPress = useCallback((id: string) => {
setSelectedId(id);
}, []);
const renderItem: ListRenderItem<Category> = useCallback(
({ item }) => (
<CategoryChip
category={item}
isSelected={item.id === selectedId}
onPress={handleCategoryPress}
/>
),
[selectedId, handleCategoryPress]
);
const keyExtractor = useCallback(
(item: Category) => item.id,
[]
);
const ItemSeparator = useCallback(
() => <View style={{ width: 10 }} />,
[]
);
return (
<View style={styles.container}>
<Text style={styles.title}>Categories</Text>
<FlatList<Category>
data={CATEGORIES}
renderItem={renderItem}
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.listContent}
ItemSeparatorComponent={ItemSeparator}
extraData={selectedId}
/>
<View style={styles.selectedInfo}>
<Text style={styles.selectedText}>
Selected: {CATEGORIES.find(c => c.id === selectedId)?.name}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
paddingVertical: 16,
},
title: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 12,
paddingHorizontal: 16,
},
listContent: {
paddingHorizontal: 16,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: '#f0f0f0',
borderRadius: 20,
borderWidth: 1,
borderColor: '#e0e0e0',
},
chipSelected: {
backgroundColor: '#2196F3',
borderColor: '#2196F3',
},
chipIcon: {
fontSize: 16,
marginRight: 6,
},
chipText: {
fontSize: 14,
fontWeight: '500',
color: '#333',
},
chipTextSelected: {
color: '#fff',
},
selectedInfo: {
marginTop: 16,
paddingHorizontal: 16,
},
selectedText: {
fontSize: 14,
color: '#666',
},
});
Exercise 3: Product List with Empty State
Build a product list that gracefully handles empty and loading states.
Requirements:
- Show a loading state while "fetching" (simulate with setTimeout)
- Show products in a card layout after loading
- Include a search filter that filters products
- Show a meaningful empty state when no products match the search
- Show different empty state when there are no products at all
π‘ Hint
Track both isLoading and searchQuery states. Your ListEmptyComponent should check these to show the appropriate message.
β Solution
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
FlatList,
View,
Text,
TextInput,
Image,
ActivityIndicator,
StyleSheet,
ListRenderItem,
} from 'react-native';
interface Product {
id: string;
name: string;
price: number;
image: string;
category: string;
}
// Mock products
const MOCK_PRODUCTS: Product[] = [
{ id: '1', name: 'Wireless Headphones', price: 79.99, image: 'https://picsum.photos/seed/1/200', category: 'Electronics' },
{ id: '2', name: 'Running Shoes', price: 129.99, image: 'https://picsum.photos/seed/2/200', category: 'Sports' },
{ id: '3', name: 'Coffee Maker', price: 49.99, image: 'https://picsum.photos/seed/3/200', category: 'Home' },
{ id: '4', name: 'Backpack', price: 59.99, image: 'https://picsum.photos/seed/4/200', category: 'Fashion' },
{ id: '5', name: 'Smart Watch', price: 199.99, image: 'https://picsum.photos/seed/5/200', category: 'Electronics' },
{ id: '6', name: 'Yoga Mat', price: 29.99, image: 'https://picsum.photos/seed/6/200', category: 'Sports' },
];
// Product card component
const ProductCard = ({ product }: { product: Product }) => (
<View style={styles.card}>
<Image source={{ uri: product.image }} style={styles.productImage} />
<View style={styles.productInfo}>
<Text style={styles.productName}>{product.name}</Text>
<Text style={styles.productCategory}>{product.category}</Text>
<Text style={styles.productPrice}>${product.price.toFixed(2)}</Text>
</View>
</View>
);
export default function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
// Simulate API fetch
useEffect(() => {
const timer = setTimeout(() => {
setProducts(MOCK_PRODUCTS);
setIsLoading(false);
}, 1500);
return () => clearTimeout(timer);
}, []);
// Filter products based on search
const filteredProducts = useMemo(() => {
if (!searchQuery.trim()) return products;
const query = searchQuery.toLowerCase();
return products.filter(
p => p.name.toLowerCase().includes(query) ||
p.category.toLowerCase().includes(query)
);
}, [products, searchQuery]);
const renderItem: ListRenderItem<Product> = useCallback(
({ item }) => <ProductCard product={item} />,
[]
);
const keyExtractor = useCallback(
(item: Product) => item.id,
[]
);
// Empty state component
const EmptyComponent = useCallback(() => {
if (isLoading) {
return (
<View style={styles.emptyContainer}>
<ActivityIndicator size="large" color="#2196F3" />
<Text style={styles.emptyText}>Loading products...</Text>
</View>
);
}
if (searchQuery && products.length > 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>π</Text>
<Text style={styles.emptyTitle}>No results found</Text>
<Text style={styles.emptyText}>
No products match "{searchQuery}"
</Text>
<Text style={styles.emptyHint}>
Try a different search term
</Text>
</View>
);
}
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>π¦</Text>
<Text style={styles.emptyTitle}>No products available</Text>
<Text style={styles.emptyText}>
Check back later for new arrivals!
</Text>
</View>
);
}, [isLoading, searchQuery, products.length]);
// Header with search
const ListHeader = useMemo(() => (
<View style={styles.header}>
<Text style={styles.headerTitle}>Products</Text>
<TextInput
style={styles.searchInput}
placeholder="Search products..."
value={searchQuery}
onChangeText={setSearchQuery}
placeholderTextColor="#999"
/>
{!isLoading && (
<Text style={styles.resultCount}>
{filteredProducts.length} of {products.length} products
</Text>
)}
</View>
), [searchQuery, filteredProducts.length, products.length, isLoading]);
return (
<View style={styles.container}>
<FlatList<Product>
data={filteredProducts}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={ListHeader}
ListEmptyComponent={EmptyComponent}
contentContainerStyle={styles.listContent}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
listContent: {
padding: 16,
flexGrow: 1,
},
header: {
marginBottom: 16,
},
headerTitle: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 12,
},
searchInput: {
backgroundColor: '#fff',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
fontSize: 16,
borderWidth: 1,
borderColor: '#e0e0e0',
},
resultCount: {
marginTop: 8,
fontSize: 14,
color: '#666',
},
card: {
backgroundColor: '#fff',
borderRadius: 12,
overflow: 'hidden',
flexDirection: 'row',
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
productImage: {
width: 100,
height: 100,
},
productInfo: {
flex: 1,
padding: 12,
justifyContent: 'center',
},
productName: {
fontSize: 16,
fontWeight: '600',
},
productCategory: {
fontSize: 13,
color: '#666',
marginTop: 2,
},
productPrice: {
fontSize: 18,
fontWeight: 'bold',
color: '#2196F3',
marginTop: 8,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
},
emptyIcon: {
fontSize: 48,
marginBottom: 16,
},
emptyTitle: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 8,
},
emptyText: {
fontSize: 16,
color: '#666',
textAlign: 'center',
},
emptyHint: {
fontSize: 14,
color: '#999',
marginTop: 8,
},
});
Summary
You've now mastered the fundamentals of FlatListβReact Native's primary tool for rendering performant lists. Let's recap the essential concepts.
π― Key Takeaways
- Two required props:
data(your array) andrenderItem(how to render each item) - Always use keyExtractor: Unique, stable string keys prevent rendering bugs
- List chrome:
ListHeaderComponent,ListFooterComponent,ItemSeparatorComponent - Handle empty states:
ListEmptyComponentwith loading/empty differentiation - Horizontal lists: Just add
horizontal={true} - extraData: Tell FlatList about state changes outside of
data - Performance: Use
useCallbackfor stable renderItem references - TypeScript: Use
<FlatList<YourType>for full type safety
flowchart TB
subgraph Required["Required Props"]
data["data: YourType[]"]
renderItem["renderItem: ({ item }) => JSX"]
end
subgraph Recommended["Highly Recommended"]
keyExtractor["keyExtractor: (item) => string"]
end
subgraph Chrome["List Chrome"]
header["ListHeaderComponent"]
footer["ListFooterComponent"]
separator["ItemSeparatorComponent"]
empty["ListEmptyComponent"]
end
subgraph Config["Configuration"]
horizontal["horizontal"]
extraData["extraData"]
inverted["inverted"]
end
Required --> FlatList
Recommended --> FlatList
Chrome --> FlatList
Config --> FlatList
style data fill:#c8e6c9
style renderItem fill:#c8e6c9
style keyExtractor fill:#fff3cd
π What's Next?
Now that you understand FlatList basics, the next lesson dives into performance optimization. You'll learn about getItemLayout, windowSize, maxToRenderPerBatch, and other props that turn a good list into a great one. We'll also cover memoization strategies and how to measure list performance.