Back to Tutorials

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.

May 25, 2026 18 min read Paddy Byrne
Expo React Native Reanimated Gesture Handler Drag and Drop

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 rowHeight match 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.