Skip to main content

useSortableList

The useSortableList hook provides the foundational state management and utilities needed to create sortable lists with drag-and-drop reordering capabilities.

Overview

This hook handles position tracking, scroll synchronization, auto-scrolling, and provides helper functions for individual sortable items. It works in conjunction with useSortable to provide a complete sortable solution.

Basic Usage

import { useSortableList } from 'react-native-reanimated-dnd';
import { SortableItem } from 'react-native-reanimated-dnd';

interface Task {
id: string;
title: string;
completed: boolean;
}

function TaskList() {
const [tasks, setTasks] = useState<Task[]>([
{ id: '1', title: 'Learn React Native', completed: false },
{ id: '2', title: 'Build an app', completed: false },
{ id: '3', title: 'Deploy to store', completed: false }
]);

const {
scrollViewRef,
dropProviderRef,
handleScroll,
handleScrollEnd,
contentHeight,
getItemProps,
} = useSortableList({
data: tasks,
itemHeight: 60,
});

return (
<GestureHandlerRootView style={styles.container}>
<DropProvider ref={dropProviderRef}>
<Animated.ScrollView
ref={scrollViewRef}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={{ height: contentHeight }}
onScrollEndDrag={handleScrollEnd}
onMomentumScrollEnd={handleScrollEnd}
>
{tasks.map((task, index) => {
const itemProps = getItemProps(task, index);
return (
<SortableItem key={task.id} {...itemProps}>
<View style={styles.taskItem}>
<Text>{task.title}</Text>
</View>
</SortableItem>
);
})}
</Animated.ScrollView>
</DropProvider>
</GestureHandlerRootView>
);
}

Parameters

UseSortableListOptions<TData>

OptionTypeDefaultDescription
dataTData[]RequiredArray of data items (must extend { id: string })
itemHeightnumberRequiredHeight of each item in pixels
itemKeyExtractor(item: TData, index: number) => string(item) => item.idFunction to extract unique key from item

Return Value

UseSortableListReturn<TData>

PropertyTypeDescription
positionsSharedValue<{[id: string]: number}>Shared value tracking item positions
scrollYSharedValue<number>Current scroll position
autoScrollSharedValue<ScrollDirection>Auto-scroll direction state
scrollViewRefAnimatedRef<ScrollView>Ref for the scroll view
dropProviderRefRefObject<DropProviderRef>Ref for the drop provider
handleScrollfunctionScroll event handler
handleScrollEndfunctionScroll end handler
contentHeightnumberTotal content height
getItemPropsfunctionFunction to get props for individual items

getItemProps Function

The getItemProps function returns props needed for individual sortable items:

const itemProps = getItemProps(item, index);
// Returns:
{
id: string;
positions: SharedValue<{[id: string]: number}>;
lowerBound: SharedValue<number>;
autoScrollDirection: SharedValue<ScrollDirection>;
itemsCount: number;
itemHeight: number;
}

Examples

Basic Sortable List Setup

function BasicTaskList() {
const [tasks, setTasks] = useState([
{ id: '1', title: 'Task 1', priority: 'high' },
{ id: '2', title: 'Task 2', priority: 'medium' },
{ id: '3', title: 'Task 3', priority: 'low' },
]);

const sortableListProps = useSortableList({
data: tasks,
itemHeight: 80,
});

const {
scrollViewRef,
dropProviderRef,
handleScroll,
handleScrollEnd,
contentHeight,
getItemProps,
} = sortableListProps;

const handleReorder = useCallback((id: string, from: number, to: number) => {
setTasks(prevTasks => {
const newTasks = [...prevTasks];
const [movedTask] = newTasks.splice(from, 1);
newTasks.splice(to, 0, movedTask);
return newTasks;
});
}, []);

return (
<GestureHandlerRootView style={styles.container}>
<DropProvider ref={dropProviderRef}>
<View style={styles.header}>
<Text style={styles.title}>My Tasks</Text>
<Text style={styles.subtitle}>{tasks.length} items</Text>
</View>

<Animated.ScrollView
ref={scrollViewRef}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={{ height: contentHeight }}
onScrollEndDrag={handleScrollEnd}
onMomentumScrollEnd={handleScrollEnd}
showsVerticalScrollIndicator={false}
>
{tasks.map((task, index) => {
const itemProps = getItemProps(task, index);
return (
<SortableItem
key={task.id}
{...itemProps}
onMove={handleReorder}
>
<TaskCard task={task} />
</SortableItem>
);
})}
</Animated.ScrollView>
</DropProvider>
</GestureHandlerRootView>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
header: {
padding: 20,
backgroundColor: 'white',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#1f2937',
},
subtitle: {
fontSize: 14,
color: '#6b7280',
marginTop: 4,
},
scrollView: {
flex: 1,
},
});

Custom Key Extractor

interface CustomItem {
uuid: string;
name: string;
order: number;
category: string;
}

function CustomSortableList() {
const [items, setItems] = useState<CustomItem[]>([
{ uuid: 'a1b2c3', name: 'Item 1', order: 0, category: 'work' },
{ uuid: 'd4e5f6', name: 'Item 2', order: 1, category: 'personal' },
{ uuid: 'g7h8i9', name: 'Item 3', order: 2, category: 'work' },
]);

const sortableListProps = useSortableList({
data: items,
itemHeight: 70,
itemKeyExtractor: (item) => item.uuid, // Use uuid instead of id
});

const { getItemProps, ...otherProps } = sortableListProps;

const handleReorder = useCallback((uuid: string, from: number, to: number) => {
setItems(prevItems => {
const newItems = [...prevItems];
const [movedItem] = newItems.splice(from, 1);
newItems.splice(to, 0, movedItem);

// Update order values
return newItems.map((item, index) => ({
...item,
order: index,
}));
});
}, []);

return (
<SortableListContainer {...otherProps}>
{items.map((item, index) => {
const itemProps = getItemProps(item, index);
return (
<SortableItem
key={item.uuid}
{...itemProps}
onMove={handleReorder}
>
<CustomItemCard item={item} />
</SortableItem>
);
})}
</SortableListContainer>
);
}

Advanced List with State Management

function AdvancedSortableList() {
const [items, setItems] = useState(initialItems);
const [isReordering, setIsReordering] = useState(false);
const [reorderCount, setReorderCount] = useState(0);
const [lastReorderTime, setLastReorderTime] = useState(null);

const sortableListProps = useSortableList({
data: items,
itemHeight: 100,
});

const handleReorder = useCallback((id: string, from: number, to: number) => {
const startTime = Date.now();
setIsReordering(true);

setItems(prevItems => {
const newItems = [...prevItems];
const [movedItem] = newItems.splice(from, 1);
newItems.splice(to, 0, movedItem);
return newItems;
});

// Update metrics
setReorderCount(prev => prev + 1);
setLastReorderTime(startTime);

// Analytics
analytics.track('list_reordered', {
itemId: id,
fromPosition: from,
toPosition: to,
totalItems: items.length,
reorderCount: reorderCount + 1,
timestamp: startTime,
});

// Auto-save with debouncing
debouncedSave(items);

// Reset reordering state
setTimeout(() => setIsReordering(false), 300);
}, [items.length, reorderCount]);

const handleDragStart = useCallback((id: string) => {
setIsReordering(true);
hapticFeedback();

// Show global drag indicator
showDragIndicator(true);
}, []);

const handleDragEnd = useCallback((id: string) => {
setIsReordering(false);
hideDragIndicator();
}, []);

return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Advanced List</Text>
<View style={styles.stats}>
<Text style={styles.statText}>
Items: {items.length}
</Text>
<Text style={styles.statText}>
Reorders: {reorderCount}
</Text>
{lastReorderTime && (
<Text style={styles.statText}>
Last: {formatTime(lastReorderTime)}
</Text>
)}
</View>
</View>

{isReordering && (
<View style={styles.reorderingIndicator}>
<ActivityIndicator size="small" color="#3b82f6" />
<Text style={styles.reorderingText}>Reordering...</Text>
</View>
)}

<SortableListContainer {...sortableListProps}>
{items.map((item, index) => {
const itemProps = sortableListProps.getItemProps(item, index);
return (
<SortableItem
key={item.id}
{...itemProps}
onMove={handleReorder}
onDragStart={handleDragStart}
onDrop={handleDragEnd}
>
<AdvancedItemCard
item={item}
isReordering={isReordering}
position={index + 1}
/>
</SortableItem>
);
})}
</SortableListContainer>
</View>
);
}
function PhotoGallery() {
const [photos, setPhotos] = useState(photoData);
const [selectedPhotos, setSelectedPhotos] = useState(new Set());
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');

const itemHeight = viewMode === 'grid' ? 120 : 80;

const sortableListProps = useSortableList({
data: photos,
itemHeight,
});

const handleReorder = useCallback((id: string, from: number, to: number) => {
setPhotos(prevPhotos => {
const newPhotos = [...prevPhotos];
const [movedPhoto] = newPhotos.splice(from, 1);
newPhotos.splice(to, 0, movedPhoto);

// Update photo order in database
updatePhotoOrder(newPhotos.map(photo => photo.id));

return newPhotos;
});

// Analytics for photo reordering
analytics.track('photo_reordered', {
photoId: id,
fromPosition: from,
toPosition: to,
gallerySize: photos.length,
viewMode,
});
}, [photos.length, viewMode]);

const togglePhotoSelection = useCallback((photoId: string) => {
setSelectedPhotos(prev => {
const newSet = new Set(prev);
if (newSet.has(photoId)) {
newSet.delete(photoId);
} else {
newSet.add(photoId);
}
return newSet;
});
}, []);

const deleteSelectedPhotos = useCallback(() => {
setPhotos(prev => prev.filter(photo => !selectedPhotos.has(photo.id)));
setSelectedPhotos(new Set());
}, [selectedPhotos]);

return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Photo Gallery</Text>
<View style={styles.headerActions}>
<Pressable
style={[styles.viewModeButton, viewMode === 'grid' && styles.activeViewMode]}
onPress={() => setViewMode('grid')}
>
<Icon name="grid" size={20} />
</Pressable>
<Pressable
style={[styles.viewModeButton, viewMode === 'list' && styles.activeViewMode]}
onPress={() => setViewMode('list')}
>
<Icon name="list" size={20} />
</Pressable>
</View>
</View>

{selectedPhotos.size > 0 && (
<View style={styles.selectionBar}>
<Text style={styles.selectionText}>
{selectedPhotos.size} selected
</Text>
<Pressable style={styles.deleteButton} onPress={deleteSelectedPhotos}>
<Icon name="trash" size={16} color="white" />
<Text style={styles.deleteButtonText}>Delete</Text>
</Pressable>
</View>
)}

<SortableListContainer {...sortableListProps}>
{photos.map((photo, index) => {
const itemProps = sortableListProps.getItemProps(photo, index);
const isSelected = selectedPhotos.has(photo.id);

return (
<SortableItem
key={photo.id}
{...itemProps}
onMove={handleReorder}
>
<PhotoCard
photo={photo}
viewMode={viewMode}
isSelected={isSelected}
onSelect={() => togglePhotoSelection(photo.id)}
position={index + 1}
/>
</SortableItem>
);
})}
</SortableListContainer>
</View>
);
}

function PhotoCard({ photo, viewMode, isSelected, onSelect, position }) {
return (
<Pressable
style={[
styles.photoCard,
viewMode === 'grid' ? styles.gridCard : styles.listCard,
isSelected && styles.selectedCard
]}
onPress={onSelect}
>
<Image source={{ uri: photo.thumbnail }} style={styles.photoThumbnail} />

<View style={styles.photoInfo}>
<Text style={styles.photoName} numberOfLines={1}>
{photo.name}
</Text>
<Text style={styles.photoDetails}>
{photo.width}×{photo.height}{formatFileSize(photo.size)}
</Text>
<Text style={styles.photoPosition}>
Position: {position}
</Text>
</View>

{isSelected && (
<View style={styles.selectedOverlay}>
<Icon name="check-circle" size={24} color="#3b82f6" />
</View>
)}

<SortableHandle style={styles.dragHandle}>
<Icon name="grip-vertical" size={16} color="#9ca3af" />
</SortableHandle>
</Pressable>
);
}

Playlist Sortable List

function MusicPlaylist() {
const [songs, setSongs] = useState(playlistData);
const [currentSong, setCurrentSong] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);

const sortableListProps = useSortableList({
data: songs,
itemHeight: 70,
});

const handleReorder = useCallback((id: string, from: number, to: number) => {
setSongs(prevSongs => {
const newSongs = [...prevSongs];
const [movedSong] = newSongs.splice(from, 1);
newSongs.splice(to, 0, movedSong);

// Update playlist order in music service
updatePlaylistOrder(newSongs.map(song => song.id));

return newSongs;
});

// Music-specific analytics
analytics.track('song_reordered', {
songId: id,
songTitle: songs.find(s => s.id === id)?.title,
fromPosition: from,
toPosition: to,
playlistLength: songs.length,
isCurrentlyPlaying: isPlaying,
});
}, [songs, isPlaying]);

const playSong = useCallback((song) => {
setCurrentSong(song);
setIsPlaying(true);
musicService.play(song);
}, []);

const togglePlayPause = useCallback(() => {
if (isPlaying) {
musicService.pause();
} else {
musicService.resume();
}
setIsPlaying(!isPlaying);
}, [isPlaying]);

return (
<View style={styles.container}>
<View style={styles.playlistHeader}>
<Text style={styles.playlistTitle}>My Playlist</Text>
<Text style={styles.playlistInfo}>
{songs.length} songs • {formatDuration(getTotalDuration(songs))}
</Text>

<View style={styles.playlistControls}>
<Pressable style={styles.playAllButton} onPress={() => playSong(songs[0])}>
<Icon name="play" size={16} color="white" />
<Text style={styles.playAllText}>Play All</Text>
</Pressable>

<Pressable style={styles.shuffleButton}>
<Icon name="shuffle" size={16} color="#6b7280" />
</Pressable>
</View>
</View>

{currentSong && (
<View style={styles.nowPlaying}>
<Image source={{ uri: currentSong.artwork }} style={styles.nowPlayingArt} />
<View style={styles.nowPlayingInfo}>
<Text style={styles.nowPlayingTitle} numberOfLines={1}>
{currentSong.title}
</Text>
<Text style={styles.nowPlayingArtist} numberOfLines={1}>
{currentSong.artist}
</Text>
</View>
<Pressable style={styles.playPauseButton} onPress={togglePlayPause}>
<Icon name={isPlaying ? "pause" : "play"} size={20} color="#3b82f6" />
</Pressable>
</View>
)}

<SortableListContainer {...sortableListProps}>
{songs.map((song, index) => {
const itemProps = sortableListProps.getItemProps(song, index);
const isCurrentSong = currentSong?.id === song.id;

return (
<SortableItem
key={song.id}
{...itemProps}
onMove={handleReorder}
>
<SongCard
song={song}
position={index + 1}
isCurrentSong={isCurrentSong}
isPlaying={isPlaying && isCurrentSong}
onPlay={() => playSong(song)}
/>
</SortableItem>
);
})}
</SortableListContainer>
</View>
);
}

function SongCard({ song, position, isCurrentSong, isPlaying, onPlay }) {
return (
<Pressable
style={[
styles.songCard,
isCurrentSong && styles.currentSongCard
]}
onPress={onPlay}
>
<Text style={styles.songPosition}>{position}</Text>

<Image source={{ uri: song.artwork }} style={styles.songArtwork} />

<View style={styles.songInfo}>
<Text style={[
styles.songTitle,
isCurrentSong && styles.currentSongTitle
]} numberOfLines={1}>
{song.title}
</Text>
<Text style={styles.songArtist} numberOfLines={1}>
{song.artist}
</Text>
</View>

<Text style={styles.songDuration}>
{formatDuration(song.duration)}
</Text>

{isPlaying && (
<View style={styles.playingIndicator}>
<View style={styles.playingBar} />
<View style={styles.playingBar} />
<View style={styles.playingBar} />
</View>
)}

<SortableHandle style={styles.dragHandle}>
<Icon name="grip-vertical" size={16} color="#9ca3af" />
</SortableHandle>
</Pressable>
);
}

Performance Optimized List

function PerformanceOptimizedList() {
const [items, setItems] = useState(largeDataSet); // 1000+ items
const [searchQuery, setSearchQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);

// Memoize filtered items
const memoizedFilteredItems = useMemo(() => {
if (!searchQuery) return items;
return items.filter(item =>
item.title.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [items, searchQuery]);

useEffect(() => {
setFilteredItems(memoizedFilteredItems);
}, [memoizedFilteredItems]);

const sortableListProps = useSortableList({
data: filteredItems,
itemHeight: 60,
});

// Memoized reorder handler
const handleReorder = useCallback((id: string, from: number, to: number) => {
setItems(prevItems => {
const newItems = [...prevItems];
const [movedItem] = newItems.splice(from, 1);
newItems.splice(to, 0, movedItem);
return newItems;
});
}, []);

// Debounced search
const debouncedSearch = useMemo(
() => debounce((query: string) => setSearchQuery(query), 300),
[]
);

return (
<View style={styles.container}>
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Search items..."
onChangeText={debouncedSearch}
/>
<Text style={styles.resultCount}>
{filteredItems.length} of {items.length} items
</Text>
</View>

<SortableListContainer {...sortableListProps}>
{filteredItems.map((item, index) => {
const itemProps = sortableListProps.getItemProps(item, index);
return (
<MemoizedSortableItem
key={item.id}
{...itemProps}
item={item}
onMove={handleReorder}
/>
);
})}
</SortableListContainer>
</View>
);
}

// Memoized item component for performance
const MemoizedSortableItem = React.memo(({ item, onMove, ...itemProps }) => {
return (
<SortableItem {...itemProps} onMove={onMove}>
<OptimizedItemCard item={item} />
</SortableItem>
);
});

Auto-scrolling

The hook automatically manages auto-scrolling when items are dragged near the edges:

  • Automatic Detection: Detects when dragging near top/bottom edges
  • Smooth Scrolling: Provides natural scrolling experience
  • Configurable Thresholds: Auto-scroll triggers based on proximity
  • Performance Optimized: Uses native driver for smooth animations

Position Management

The hook maintains item positions using shared values:

// Positions are tracked as { [itemId]: position }
const positions = useSharedValue({
'item-1': 0,
'item-2': 1,
'item-3': 2,
});

// Automatically updates when items are reordered

Performance Tips

  • Use React.memo for item components
  • Implement virtualization for large lists (>100 items)
  • Use consistent item heights for better performance
  • Avoid heavy computations in render methods
  • Use useCallback for stable callback references
  • Consider pagination for very large datasets

TypeScript Support

The hook is fully typed with generic support:

interface TaskData {
id: string;
title: string;
priority: 'low' | 'medium' | 'high';
completed: boolean;
}

// Fully typed hook usage
const sortableProps = useSortableList<TaskData>({
data: tasks,
itemHeight: 60,
itemKeyExtractor: (task) => task.id, // Optional, defaults to item.id
});

Integration with Components

This hook is designed to work with sortable components:

import { Sortable } from 'react-native-reanimated-dnd';

// High-level component that uses this hook internally
function SimpleList() {
const [items, setItems] = useState(initialItems);

const handleMove = useCallback((id: string, from: number, to: number) => {
setItems(prevItems => {
const newItems = [...prevItems];
const [movedItem] = newItems.splice(from, 1);
newItems.splice(to, 0, movedItem);
return newItems;
});
}, []);

return (
<Sortable
data={items}
itemHeight={60}
renderItem={({ item, id, positions, ...props }) => (
<SortableItem
key={id}
id={id}
positions={positions}
{...props}
onMove={handleMove}
>
<ItemCard item={item} />
</SortableItem>
)}
/>
);
}

See Also