React Native Pull to Refresh List
A FlatList with pull-to-refresh functionality, custom refresh indicator, loading states, and empty state placeholder for React Native.
Expo Snack
Code
import React, { useState, useCallback } from "react";
import {
View,
Text,
FlatList,
RefreshControl,
ActivityIndicator,
StyleSheet,
ListRenderItem,
} from "react-native";
// ---------- PullToRefreshList Component ----------
interface PullToRefreshListProps<T> {
data: T[];
renderItem: ListRenderItem<T>;
onRefresh: () => void;
refreshing: boolean;
emptyMessage?: string;
ListHeaderComponent?: React.ReactElement;
loading?: boolean;
keyExtractor?: (item: T, index: number) => string;
}
function PullToRefreshList<T>({
data,
renderItem,
onRefresh,
refreshing,
emptyMessage = "No items to display",
ListHeaderComponent,
loading = false,
keyExtractor,
}: PullToRefreshListProps<T>) {
const renderEmpty = () => {
if (loading) return null;
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>📋</Text>
<Text style={styles.emptyText}>{emptyMessage}</Text>
</View>
);
};
const renderFooter = () => {
if (!loading) return null;
return (
<View style={styles.footerContainer}>
<ActivityIndicator size="small" color="#60a5fa" />
<Text style={styles.footerText}>Loading more...</Text>
</View>
);
};
const renderSeparator = () => <View style={styles.separator} />;
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={styles.list}
contentContainerStyle={data.length === 0 ? styles.emptyList : undefined}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#60a5fa"
titleColor="#94a3b8"
colors={["#60a5fa", "#818cf8"]}
progressBackgroundColor="#1e293b"
/>
}
ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
ItemSeparatorComponent={renderSeparator}
/>
);
}
// ---------- Demo App ----------
interface Contact {
id: string;
name: string;
email: string;
}
const CONTACTS: Contact[] = [
{ id: "1", name: "Alice Johnson", email: "alice.johnson@email.com" },
{ id: "2", name: "Bob Martinez", email: "bob.martinez@email.com" },
{ id: "3", name: "Carol Chen", email: "carol.chen@email.com" },
{ id: "4", name: "David Kim", email: "david.kim@email.com" },
{ id: "5", name: "Eva Rossi", email: "eva.rossi@email.com" },
{ id: "6", name: "Frank Nguyen", email: "frank.nguyen@email.com" },
{ id: "7", name: "Grace Patel", email: "grace.patel@email.com" },
{ id: "8", name: "Henry Larsson", email: "henry.larsson@email.com" },
{ id: "9", name: "Irene Tanaka", email: "irene.tanaka@email.com" },
{ id: "10", name: "Jack O'Brien", email: "jack.obrien@email.com" },
{ id: "11", name: "Karen Schmidt", email: "karen.schmidt@email.com" },
{ id: "12", name: "Leo Fernandez", email: "leo.fernandez@email.com" },
{ id: "13", name: "Mia Andersson", email: "mia.andersson@email.com" },
{ id: "14", name: "Noah Williams", email: "noah.williams@email.com" },
{ id: "15", name: "Olivia Brown", email: "olivia.brown@email.com" },
];
function shuffle<T>(array: T[]): T[] {
const copy = [...array];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function getInitials(name: string): string {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
const AVATAR_COLORS = [
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#14b8a6",
"#f43f5e",
"#a855f7",
"#6366f1",
"#0ea5e9",
"#84cc16",
"#d946ef",
];
const renderContact: ListRenderItem<Contact> = ({ item, index }) => {
const color = AVATAR_COLORS[index % AVATAR_COLORS.length];
return (
<View style={styles.contactRow}>
<View style={[styles.avatar, { backgroundColor: color }]}>
<Text style={styles.avatarText}>{getInitials(item.name)}</Text>
</View>
<View style={styles.contactInfo}>
<Text style={styles.contactName}>{item.name}</Text>
<Text style={styles.contactEmail}>{item.email}</Text>
</View>
</View>
);
};
export default function App() {
const [contacts, setContacts] = useState<Contact[]>(CONTACTS);
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setContacts((prev) => shuffle(prev));
setRefreshing(false);
}, 1500);
}, []);
return (
<View style={styles.container}>
<PullToRefreshList
data={contacts}
renderItem={renderContact}
onRefresh={handleRefresh}
refreshing={refreshing}
keyExtractor={(item) => item.id}
emptyMessage="No contacts found"
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.headerTitle}>Contacts</Text>
<Text style={styles.headerSubtitle}>Pull down to refresh ({contacts.length})</Text>
</View>
}
/>
</View>
);
}
// ---------- Styles ----------
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
},
list: {
flex: 1,
},
emptyList: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
},
emptyContainer: {
alignItems: "center",
paddingVertical: 48,
},
emptyIcon: {
fontSize: 48,
marginBottom: 12,
},
emptyText: {
color: "#64748b",
fontSize: 16,
},
footerContainer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
paddingVertical: 16,
gap: 8,
},
footerText: {
color: "#94a3b8",
fontSize: 13,
},
separator: {
height: 1,
backgroundColor: "#1e293b",
marginLeft: 72,
},
header: {
paddingTop: 60,
paddingBottom: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: "#1e293b",
},
headerTitle: {
fontSize: 28,
fontWeight: "700",
color: "#f1f5f9",
marginBottom: 4,
},
headerSubtitle: {
fontSize: 14,
color: "#64748b",
},
contactRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 20,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: "center",
alignItems: "center",
},
avatarText: {
color: "#ffffff",
fontSize: 15,
fontWeight: "600",
},
contactInfo: {
marginLeft: 14,
flex: 1,
},
contactName: {
color: "#e2e8f0",
fontSize: 16,
fontWeight: "500",
marginBottom: 2,
},
contactEmail: {
color: "#64748b",
fontSize: 13,
},
});React Native Pull to Refresh List
A reusable FlatList wrapper that provides built-in pull-to-refresh behavior, a custom refresh indicator, loading states with an activity indicator footer, and an empty state placeholder. Drop it into any React Native screen to get a polished scrollable list experience with minimal setup.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
data | T[] | Yes | Array of items to render in the list. |
renderItem | ListRenderItem<T> | Yes | Function that returns the JSX for each list item. |
onRefresh | () => void | Yes | Callback triggered when the user pulls to refresh. |
refreshing | boolean | Yes | Whether the refresh indicator is currently active. |
emptyMessage | string | No | Custom message displayed when the list has no items. Defaults to "No items to display". |
ListHeaderComponent | React.ReactElement | No | Optional header rendered above the list content. |
loading | boolean | No | When true, shows an activity indicator at the bottom of the list. |
keyExtractor | (item: T, index: number) => string | No | Function to extract a unique key for each item. |
Usage
import PullToRefreshList from "./PullToRefreshList";
function MyScreen() {
const [items, setItems] = useState(data);
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = () => {
setRefreshing(true);
fetchData().then((newData) => {
setItems(newData);
setRefreshing(false);
});
};
return (
<PullToRefreshList
data={items}
renderItem={({ item }) => <MyListItem item={item} />}
onRefresh={handleRefresh}
refreshing={refreshing}
emptyMessage="Nothing here yet"
/>
);
}
How it works
The component wraps React Native’s FlatList and attaches a RefreshControl with custom tint and title colors. When the user pulls the list past the scroll threshold, the onRefresh callback fires and the refresh indicator appears until refreshing is set back to false.
A footer ActivityIndicator is conditionally rendered when the loading prop is true, useful for paginated lists that load more data as the user scrolls. When the data array is empty and no loading is in progress, the component renders a centered placeholder view with the emptyMessage text, giving users clear feedback that the list is intentionally empty rather than broken.
An ItemSeparatorComponent renders a subtle divider between rows automatically, keeping the list visually clean without requiring manual separator logic in each renderItem implementation.