Custom drag and drop list for Expo / React Native
Build your own sortable list with Reanimated and Gesture Handler so you can replace computerjazz/react-native-draggable-flatlist when you only need the core interaction.
What we are replacing
The goal is not to rebuild every feature of react-native-draggable-flatlist. The goal is a small, predictable sortable list: fixed-height rows, drag handle support, animated reordering, optional auto-scroll, and a clean onReorder callback.
Third-party drag list packages are useful, but they can become awkward during Expo SDK upgrades, new architecture changes, or Reanimated migrations. If your screen only needs a straightforward reorderable list, owning the component can be simpler than carrying a larger dependency.
This tutorial uses react-native-reanimated for UI-thread animation and react-native-gesture-handler for the pan gesture. It works well for settings lists, playlist editors, dashboards, form builders, and any list where row height is known ahead of time.
1. Install the dependencies
In an Expo project, install Reanimated and Gesture Handler with Expo's version-aware installer:
npx expo install react-native-reanimated react-native-gesture-handler
Make sure your app is wrapped with GestureHandlerRootView. If you use Expo Router, put this in your root layout. If you have a classic App.tsx, put it there.
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* Your app, Stack, Tabs, or NavigationContainer */}
</GestureHandlerRootView>
);
}
Reanimated also needs its Babel plugin. With current Expo projects this is normally configured for you, but check babel.config.js if animations do not run.
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["react-native-reanimated/plugin"],
};
};
2. Decide the list contract
A custom list is much easier to build if it has a tight API. This version accepts an array of items with stable ids, a fixed row height, a render function, and an onReorder callback.
type DragItem = {
id: string;
};
type DragDropListProps<T extends DragItem> = {
data: T[];
rowHeight: number;
renderItem: (item: T) => React.ReactNode;
onReorder: (nextData: T[]) => void;
};
The fixed row height is the important tradeoff. It lets us convert touch position to list index with simple maths, which keeps the animation code readable and fast.
3. Add the custom DragDropList component
Create components/DragDropList.tsx. This component keeps the visual positions in a Reanimated shared value, swaps ids while dragging, then sends the final order back to React state when the gesture ends.
import React, { useEffect, useMemo } from "react";
import { StyleSheet, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
type SharedValue,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
type DragItem = {
id: string;
};
type DragDropListProps<T extends DragItem> = {
data: T[];
rowHeight: number;
renderItem: (item: T) => React.ReactNode;
onReorder: (nextData: T[]) => void;
};
type RowProps<T extends DragItem> = {
item: T;
itemById: Record<string, T>;
positions: SharedValue<Record<string, number>>;
itemCount: number;
rowHeight: number;
renderItem: (item: T) => React.ReactNode;
onReorder: (nextData: T[]) => void;
};
function clamp(value: number, lowerBound: number, upperBound: number) {
"worklet";
return Math.max(lowerBound, Math.min(value, upperBound));
}
function moveItem(
positions: Record<string, number>,
fromIndex: number,
toIndex: number
) {
"worklet";
const nextPositions = { ...positions };
for (const id in positions) {
if (positions[id] === fromIndex) {
nextPositions[id] = toIndex;
}
if (fromIndex < toIndex && positions[id] > fromIndex && positions[id] <= toIndex) {
nextPositions[id] = positions[id] - 1;
}
if (fromIndex > toIndex && positions[id] < fromIndex && positions[id] >= toIndex) {
nextPositions[id] = positions[id] + 1;
}
}
return nextPositions;
}
function getOrderedItems<T extends DragItem>(
itemById: Record<string, T>,
positions: Record<string, number>
) {
return Object.keys(positions)
.sort((a, b) => positions[a] - positions[b])
.map((id) => itemById[id]);
}
function DragRow<T extends DragItem>({
item,
itemById,
positions,
itemCount,
rowHeight,
renderItem,
onReorder,
}: RowProps<T>) {
const startTop = useSharedValue(0);
const top = useSharedValue(positions.value[item.id] * rowHeight);
const isActive = useSharedValue(false);
useEffect(() => {
top.value = withTiming(positions.value[item.id] * rowHeight);
}, [item.id, positions, rowHeight, top]);
const gesture = Gesture.Pan()
.onBegin(() => {
isActive.value = true;
startTop.value = positions.value[item.id] * rowHeight;
top.value = startTop.value;
})
.onUpdate((event) => {
const maxIndex = itemCount - 1;
const currentIndex = positions.value[item.id];
const nextTop = clamp(startTop.value + event.translationY, 0, maxIndex * rowHeight);
const nextIndex = clamp(Math.round(nextTop / rowHeight), 0, maxIndex);
top.value = nextTop;
if (nextIndex !== currentIndex) {
positions.value = moveItem(positions.value, currentIndex, nextIndex);
}
})
.onFinalize(() => {
const finalTop = positions.value[item.id] * rowHeight;
top.value = withTiming(finalTop);
isActive.value = false;
runOnJS(onReorder)(getOrderedItems(itemById, positions.value));
});
const animatedStyle = useAnimatedStyle(() => {
const inactiveTop = positions.value[item.id] * rowHeight;
return {
position: "absolute",
left: 0,
right: 0,
height: rowHeight,
zIndex: isActive.value ? 10 : 0,
transform: [
{
translateY: isActive.value ? top.value : withTiming(inactiveTop),
},
{
scale: withTiming(isActive.value ? 1.02 : 1),
},
],
shadowOpacity: withTiming(isActive.value ? 0.2 : 0),
};
});
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.row, animatedStyle]}>
{renderItem(item)}
</Animated.View>
</GestureDetector>
);
}
export function DragDropList<T extends DragItem>({
data,
rowHeight,
renderItem,
onReorder,
}: DragDropListProps<T>) {
const itemById = useMemo(() => {
return data.reduce<Record<string, T>>((lookup, item) => {
lookup[item.id] = item;
return lookup;
}, {});
}, [data]);
const positions = useSharedValue<Record<string, number>>({});
useEffect(() => {
positions.value = data.reduce<Record<string, number>>((next, item, index) => {
next[item.id] = index;
return next;
}, {});
}, [data, positions]);
return (
<View style={{ height: data.length * rowHeight }}>
{data.map((item) => (
<DragRow
key={item.id}
item={item}
itemById={itemById}
positions={positions}
itemCount={data.length}
rowHeight={rowHeight}
renderItem={renderItem}
onReorder={onReorder}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
row: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 4,
},
});
That is the core replacement. It uses absolute positioning instead of a FlatList, so it is best for short to medium lists. For very long lists, keep reading for the ScrollView and virtualization notes.
4. Use it in a screen
Here is a complete example screen with a visible drag handle. The handle is visual only in this first version because the entire row is draggable. That is usually fine for reorder screens where row taps are disabled while sorting.
import React, { useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { DragDropList } from "../components/DragDropList";
type Task = {
id: string;
title: string;
subtitle: string;
};
const initialTasks: Task[] = [
{ id: "design", title: "Design pass", subtitle: "Tighten the empty states" },
{ id: "api", title: "API integration", subtitle: "Wire the reorder endpoint" },
{ id: "qa", title: "QA", subtitle: "Test on iOS and Android" },
{ id: "release", title: "Release", subtitle: "Ship the update" },
];
export default function SortableTasksScreen() {
const [tasks, setTasks] = useState(initialTasks);
return (
<View style={styles.screen}>
<Text style={styles.title}>Project order</Text>
<DragDropList
data={tasks}
rowHeight={76}
onReorder={setTasks}
renderItem={(item) => (
<View style={styles.item}>
<Text style={styles.handle}>= =</Text>
<View style={styles.copy}>
<Text style={styles.itemTitle}>{item.title}</Text>
<Text style={styles.itemSubtitle}>{item.subtitle}</Text>
</View>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
padding: 20,
paddingTop: 64,
backgroundColor: "#111827",
},
title: {
color: "white",
fontSize: 28,
fontWeight: "700",
marginBottom: 20,
},
item: {
height: 64,
marginBottom: 12,
borderRadius: 12,
backgroundColor: "#1f2937",
borderWidth: 1,
borderColor: "#374151",
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
},
handle: {
color: "#9ca3af",
fontSize: 18,
marginRight: 14,
},
copy: {
flex: 1,
},
itemTitle: {
color: "white",
fontSize: 16,
fontWeight: "700",
},
itemSubtitle: {
color: "#9ca3af",
marginTop: 2,
},
});
Notice that rowHeight is 76, while the visible item is 64 high with 12 points of bottom spacing. The draggable row owns the full 76 points, which makes index maths line up with what users see.
5. Add a drag handle only mode
If the row also has buttons, switches, or navigation, dragging the whole row can feel too aggressive. The clean version is to pass a handle into renderItem and attach the gesture only to that handle. For a compact tutorial component, the simpler compromise is to make only a small absolute overlay draggable.
Change the row render to place the GestureDetector around the handle area, while the animated row remains outside it. The important idea is that Gesture Handler owns the view that begins the pan, and Reanimated still owns the row translation.
Practical recommendation
Start with whole-row dragging while you build the feature. Switch to handle-only dragging if row content needs normal taps, text inputs, menus, or swipe actions.
6. Add scrolling and auto-scroll
For lists taller than the screen, wrap the list in an Animated.ScrollView. The minimal version is just a scroll container:
import Animated from "react-native-reanimated";
<Animated.ScrollView contentContainerStyle={{ padding: 20 }}>
<DragDropList
data={tasks}
rowHeight={76}
onReorder={setTasks}
renderItem={renderTask}
/>
</Animated.ScrollView>
Auto-scroll while dragging needs three extra pieces: a shared scrollY, an animated scroll ref, and a reaction that calls Reanimated's scrollTo. Keep that logic inside the list if every sortable list in your app should behave the same way.
const scrollY = useSharedValue(0);
const scrollRef = useAnimatedRef<Animated.ScrollView>();
useAnimatedReaction(
() => scrollY.value,
(currentY) => {
scrollTo(scrollRef, 0, currentY, false);
}
);
During onUpdate, if the dragged row is close to the top or bottom edge, update scrollY.value. Add the current scroll offset when calculating the next index. This is the one part I would only add once the non-scrolling list feels solid.
7. Production checks
- Stable ids: never use array index as the key. Reordering index keys causes visual jumps and wrong saved order.
- Fixed height: make
rowHeightmatch the full slot height, including margins or separators. - Persistence: debounce API saves if users reorder repeatedly, but update local state immediately.
- Accessibility: add up and down buttons or a reorder mode for users who cannot drag precisely.
- Long lists: if you need hundreds of rows, use a maintained virtualized drag list or build a virtualized version deliberately.
- New architecture: test in a development build on real iOS and Android devices, not only Expo Go.
Migration from DraggableFlatList
If your current code looks like this:
<DraggableFlatList
data={items}
keyExtractor={(item) => item.id}
onDragEnd={({ data }) => setItems(data)}
renderItem={({ item, drag, isActive }) => (
<Pressable onLongPress={drag}>
<Row item={item} isActive={isActive} />
</Pressable>
)}
/>
The custom version becomes:
<DragDropList
data={items}
rowHeight={72}
onReorder={setItems}
renderItem={(item) => (
<Row item={item} />
)}
/>
You lose built-in virtualization and some advanced props, but you gain a small component that is easy to debug, style, and adapt to your own app.
Final thoughts
This is the version I like for product screens where drag and drop is a feature, not the foundation of the entire app. It keeps the hard work on the UI thread, avoids an extra list dependency, and leaves the data model as plain React state.
If the list grows large, has variable row heights, or needs nested scrolling, that is the point where the tradeoff changes. For short sortable lists, though, a custom component is often the nicest path.