paginated-flatlist
v1.0.2
Published
A high-performance, reusable paginated list component for React Native with infinite scroll, pull-to-refresh, and skeleton loading
Downloads
258
Maintainers
Readme
paginated-flatlist
A high-performance, feature-rich paginated list component for React Native built with TypeScript. Supports infinite scroll, pull-to-refresh, error handling, and dynamic filtering out of the box.
Perfect for building lists of restaurants, dishes, products, or any dynamic data that needs pagination.
✨ Features
- ⚡ High Performance - Built on Shopify's FlashList for optimal rendering
- ♾️ Infinite Scroll - Automatically load more items as users scroll to the end
- 🔄 Pull-to-Refresh - Native pull-to-refresh gesture support with loading states
- 📱 External Refresh Control - Use your own header/tab refresh button instead of pull-to-refresh
- 🎨 Fully Customizable - Flexible rendering for headers, empty states, footers, and items
- ⚙️ Automatic State Management - Handles loading, error states, pagination automatically
- 🔍 Filter-Ready - Designed to work seamlessly with dynamic filters and search
- 📦 TypeScript First - Fully typed for better developer experience
- 🚀 Zero Custom Dependencies - Only essential peer dependencies required
- ♿ Accessibility Built-in - Proper loading indicators and error messages
📦 Installation
npm install paginated-flatlist react-native @shopify/flash-listor with yarn:
yarn add paginated-flatlist react-native @shopify/flash-listor with pnpm:
pnpm add paginated-flatlist react-native @shopify/flash-listRequirements
- React Native >= 0.81.0
- React >= 18.2.0 or ^19.0.0
- @shopify/flash-list >= 2.1.0
🚀 Quick Start
Basic Usage
import React from 'react'
import { Text, View } from 'react-native'
import { PaginatedList } from 'paginated-flatlist'
interface User {
id: string
name: string
email: string
}
export default function UserListScreen() {
const fetchUsers = async (page: number, pageSize: number): Promise<User[]> => {
const response = await fetch(
`https://api.example.com/users?page=${page}&limit=${pageSize}`
)
return response.json()
}
return (
<PaginatedList<User>
fetchData={fetchUsers}
pageSize={20}
renderItem={({ item }) => (
<View style={{ padding: 16, borderBottomWidth: 1, borderBottomColor: '#eee' }}>
<Text style={{ fontSize: 16, fontWeight: '600' }}>{item.name}</Text>
<Text style={{ fontSize: 14, color: '#666' }}>{item.email}</Text>
</View>
)}
keyExtractor={(item) => item.id}
/>
)
}📚 Real-World Example: Restaurant Dishes List
This example demonstrates the typical use case - filtering dishes by restaurant with tag-based filtering:
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { View, Text, Pressable } from 'react-native'
import { PaginatedList, type PaginatedListRef } from 'paginated-flatlist'
interface Dish {
_id: string
name: string
description: string
price: number
imageUrl: string
}
interface FilterParams {
kioskId: string
tags: string[]
search: string
}
export default function KioskDishesScreen({ kioskId }: { kioskId: string }) {
const [filters, setFilters] = useState<FilterParams>({
kioskId,
tags: [],
search: '',
})
// Ref to control list from external refresh button
const paginatedListRef = useRef<PaginatedListRef>(null)
// Fetch function with current filters
const fetchDishes = useCallback(
async (page: number, pageSize: number): Promise<Dish[]> => {
const query = new URLSearchParams({
page: page.toString(),
limit: pageSize.toString(),
kioskId: filters.kioskId,
tags: filters.tags.join(','),
search: filters.search,
})
const response = await fetch(`https://api.example.com/dishes?${query}`)
if (!response.ok) throw new Error('Failed to fetch dishes')
return response.json()
},
[filters],
)
// Refresh list when filters change
useEffect(() => {
paginatedListRef.current?.handleRefresh()
}, [filters])
const handleExternalRefresh = async () => {
try {
await paginatedListRef.current?.handleRefresh()
} catch (error) {
console.error('Refresh failed:', error)
}
}
const handleTagFilter = (selectedTagIds: string[]) => {
setFilters((prev) => ({
...prev,
tags: selectedTagIds,
}))
}
return (
<View style={{ flex: 1 }}>
{/* Custom header with refresh button */}
<View style={{ padding: 16, backgroundColor: '#f5f5f5' }}>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>Dishes</Text>
<Pressable onPress={handleExternalRefresh}>
<Text style={{ color: '#007AFF', marginTop: 8 }}>Refresh</Text>
</Pressable>
</View>
{/* Tag filters */}
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<TagFilterComponent onSelect={handleTagFilter} />
</View>
{/* Paginated list with external refresh control */}
<PaginatedList<Dish>
ref={paginatedListRef}
fetchData={fetchDishes}
pageSize={10}
keyExtractor={(item) => item._id}
renderItem={({ item }) => (
<DishCard dish={item} />
)}
useExternalRefreshControl={true}
ListEmptyComponent={
<View style={{ padding: 20, alignItems: 'center' }}>
<Text style={{ color: '#999' }}>No dishes found</Text>
</View>
}
/>
</View>
)
}📖 API Reference
Props
| Prop | Type | Default | Required | Description |
|------|------|---------|----------|-------------|
| fetchData | (page: number, pageSize: number) => Promise<T[]> | - | ✅ | Function to fetch paginated data. Should return an array of items. |
| renderItem | ({ item, index }) => JSX.Element | - | ✅ | Render function for each list item |
| pageSize | number | 10 | ❌ | Number of items per page/request |
| keyExtractor | (item: T, index: number) => string | Uses array index | ❌ | Function to extract unique key for each item (improves performance) |
| ListHeaderComponent | JSX.Element | undefined | ❌ | Component rendered at the top of the list (above all items) |
| ListEmptyComponent | JSX.Element | Default empty message | ❌ | Component shown when list is empty |
| ListFooterComponent | JSX.Element | Loading indicator | ❌ | Component shown at bottom of list |
| horizontal | boolean | false | ❌ | Enable horizontal scrolling instead of vertical |
| onRefresh | () => void \| Promise<void> | undefined | ❌ | Callback triggered when user pulls to refresh (before fetchData) |
| useExternalRefreshControl | boolean | false | ❌ | When true, disables built-in pull-to-refresh and lets parent handle refresh |
Ref Methods
Access advanced functionality using useRef<PaginatedListRef>():
const listRef = useRef<PaginatedListRef>(null)
// Programmatically refresh the entire list (resets to page 1)
await listRef.current?.handleRefresh()
// Check if list is currently refreshing
const isRefreshing = listRef.current?.refreshing🎯 Common Use Cases
1. Simple Product List
<PaginatedList<Product>
fetchData={async (page, pageSize) => {
const res = await fetch(`/api/products?page=${page}&limit=${pageSize}`)
return res.json()
}}
renderItem={({ item }) => <ProductCard product={item} />}
pageSize={15}
/>2. With Header and Custom Empty State
<PaginatedList<User>
fetchData={fetchUsers}
renderItem={renderUserItem}
ListHeaderComponent={
<View style={{ padding: 16, backgroundColor: '#f0f0f0' }}>
<Text style={{ fontSize: 20, fontWeight: 'bold' }}>Users</Text>
<Text style={{ color: '#666' }}>Total: 1,234 users</Text>
</View>
}
ListEmptyComponent={
<View style={{ padding: 40, alignItems: 'center' }}>
<Text style={{ fontSize: 16, color: '#999' }}>
No users found. Try adjusting your filters.
</Text>
</View>
}
/>3. With Filters and Dynamic Updates
const [filters, setFilters] = useState({ category: '', search: '' })
const listRef = useRef<PaginatedListRef>(null)
// Update list when filters change
useEffect(() => {
listRef.current?.handleRefresh()
}, [filters])
const fetchData = useCallback(
(page: number, pageSize: number) =>
fetchItems(page, pageSize, filters),
[filters],
)
return (
<PaginatedList<Item>
ref={listRef}
fetchData={fetchData}
renderItem={renderItem}
/>
)4. External Refresh Control (Header Button)
const listRef = useRef<PaginatedListRef>(null)
return (
<View style={{ flex: 1 }}>
<Header
onRefresh={() => listRef.current?.handleRefresh()}
isRefreshing={listRef.current?.refreshing}
/>
<PaginatedList
ref={listRef}
fetchData={fetchData}
renderItem={renderItem}
useExternalRefreshControl={true}
/>
</View>
)5. Horizontal Scrolling (Carousel)
<PaginatedList<Item>
fetchData={fetchItems}
renderItem={({ item }) => <ItemCard item={item} />}
horizontal={true}
pageSize={5}
showsHorizontalScrollIndicator={false}
/>6. Custom Footer Component
<PaginatedList<Item>
fetchData={fetchData}
renderItem={renderItem}
ListFooterComponent={
<View style={{ padding: 20, alignItems: 'center' }}>
<Text style={{ color: '#999' }}>End of list</Text>
</View>
}
/>⚡ Performance Optimization Tips
1. Use keyExtractor for stable keys
// ✅ GOOD - Stable unique identifier
<PaginatedList
keyExtractor={(item) => item.id}
{...props}
/>
// ❌ AVOID - Index-based keys cause re-renders2. Memoize render items for complex components
const DishItem = React.memo(({ item }: { item: Dish }) => (
<View style={{ padding: 12 }}>
<Image source={{ uri: item.imageUrl }} />
<Text>{item.name}</Text>
</View>
))
<PaginatedList
renderItem={({ item }) => <DishItem item={item} />}
{...props}
/>3. Optimize fetchData function
// ✅ GOOD - Memoized with useCallback
const fetchData = useCallback(
(page: number, pageSize: number) =>
fetchFromAPI(page, pageSize, filters),
[filters], // Only recreate when filters change
)
<PaginatedList fetchData={fetchData} {...props} />4. Appropriate page sizes
// Images/complex items - smaller page size
<PaginatedList pageSize={10} {...props} />
// Simple text items - larger page size
<PaginatedList pageSize={50} {...props} />🚨 Error Handling
The component automatically handles errors with user-friendly messages:
const fetchData = async (page: number, pageSize: number) => {
try {
const response = await fetch(
`https://api.example.com/items?page=${page}`
)
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`)
}
return response.json()
} catch (error) {
// Error message will be displayed in the list
throw new Error(
error instanceof Error
? error.message
: 'Failed to fetch items'
)
}
}
<PaginatedList fetchData={fetchData} {...props} />Error UI includes:
- Error message text
- Retry button (automatically calls
handleRefresh) - Preserves previously loaded data
🔧 Troubleshooting
List Not Scrolling
// ❌ Issue: Parent doesn't have flex or height
<View>
<PaginatedList {...props} />
</View>
// ✅ Fix: Add flex to parent
<View style={{ flex: 1 }}>
<PaginatedList {...props} />
</View>Items Not Rendering
// Verify keyExtractor returns unique strings
const fetchData = async (page, pageSize) => {
const data = await fetch(`/api/items?page=${page}`)
console.log('Fetched data:', data) // Debug
return data.json()
}
<PaginatedList
fetchData={fetchData}
keyExtractor={(item) => {
console.log('Key for item:', item._id) // Verify unique keys
return item._id
}}
{...props}
/>Refresh Not Working
// Verify ref is properly attached
const listRef = useRef<PaginatedListRef>(null)
const handleRefresh = () => {
console.log('Refreshing...', listRef.current)
listRef.current?.handleRefresh()
}
<PaginatedList ref={listRef} {...props} />Infinite Loading
// Ensure fetchData returns correct amount of items
const fetchData = async (page: number, pageSize: number) => {
const response = await fetch(
`/api/items?page=${page}&limit=${pageSize}`
)
const data = await response.json()
// Must return array of items (not wrapped object)
console.log('Items returned:', data.length, 'Expected:', pageSize)
return Array.isArray(data) ? data : data.items
}📱 TypeScript Support
Fully typed with TypeScript generics:
import {
PaginatedList,
PaginatedListProps,
PaginatedListRef,
} from 'paginated-flatlist'
// Define your data type
interface Product {
id: string
name: string
price: number
}
// Typed component
<PaginatedList<Product>
fetchData={async (page, pageSize) => {
// TypeScript knows this should return Product[]
return fetch(`/api/products?page=${page}`)
.then(r => r.json())
}}
renderItem={({ item }) => {
// TypeScript knows item is Product
return <Text>{item.name}</Text>
}}
/>
// Typed ref
const listRef = useRef<PaginatedListRef>(null)🎨 Styling & Customization
Fully customizable with React Native styles:
<PaginatedList
// Custom separators
ItemSeparatorComponent={() => (
<View style={{ height: 1, backgroundColor: '#e0e0e0' }} />
)}
// Custom empty state
ListEmptyComponent={
<View style={{ padding: 40, alignItems: 'center' }}>
<Text>No results found</Text>
</View>
}
// Custom footer
ListFooterComponent={
<View style={{ padding: 20 }}>
<Text>End of list</Text>
</View>
}
// FlashList props pass through
contentContainerStyle={{ paddingBottom: 20 }}
scrollEventThrottle={16}
{...props}
/>📊 State Management Integration
Works seamlessly with popular state managers:
// Redux
const items = useSelector(selectItems)
const filters = useSelector(selectFilters)
const fetchData = (page, pageSize) =>
fetchItemsAPI(page, pageSize, filters)
// Zustand
const filters = useFiltersStore((state) => state.filters)
// Context
const { filters } = useContext(FilterContext)🔗 Lifecycle & Hooks
useEffect(() => {
// 1. Component mounts → Initial fetch (page 1)
// 2. User scrolls → onEndReached → fetchData with page++
// 3. User pulls → RefreshControl → handleRefresh
// 4. Filters change → manually call handleRefresh via ref
// 5. Cleanup on unmount
}, [])🤝 Contributing
Contributions are welcome! Please follow these steps:
- Fork the repository
- Create a feature branch:
git checkout -b feature/amazing-feature - Commit changes:
git commit -m 'Add amazing feature' - Push branch:
git push origin feature/amazing-feature - Open a Pull Request
Development
# Install dependencies
npm install
# Build TypeScript
npm run build
# Watch for changes
npm run dev
# Type check
npm run type-check
# Lint code
npm run lint
# Format code
npm run format📝 Changelog
See CHANGELOG.md for version history and breaking changes.
📄 License
MIT © 2024
🆘 Support & Issues
- Issues & Bugs: GitHub Issues
- Discussions: GitHub Discussions
- Repository: GitHub
🙏 Acknowledgments
- Built with Shopify's FlashList for superior performance
- Inspired by React Native community best practices
- Thanks to all contributors and users!
