Setup — and the one line everything depends on
npx expo install react-native-gesture-handler react-native-reanimated// app/_layout.tsx
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack />
</GestureHandlerRootView>
);
}If the swipe does nothing — no movement, no error, no warning — this is why, about nine times out of ten. And style={{ flex: 1 }} is not decorative: without it the root view collapses to zero height and your entire app renders blank, which is a different and briefly terrifying bug.
A row that swipes
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Swipeable from 'react-native-gesture-handler/ReanimatedSwipeable';
import Reanimated, { SharedValue, useAnimatedStyle } from 'react-native-reanimated';
function DeleteAction({
drag, onPress,
}: { drag: SharedValue<number>; onPress: () => void }) {
// drag is negative as the row moves left; +80 puts the action back at rest
const style = useAnimatedStyle(() => ({
transform: [{ translateX: drag.value + 80 }],
}));
return (
<Reanimated.View style={[styles.actionWrap, style]}>
<Pressable style={styles.action} onPress={onPress} accessibilityLabel="Delete">
<Text style={styles.actionText}>Delete</Text>
</Pressable>
</Reanimated.View>
);
}
export function TaskRow({ task, onDelete }) {
return (
<Swipeable
friction={2} // 1 feels twitchy; 2 tracks the thumb
rightThreshold={40} // how far before it snaps open
overshootRight={false} // no rubber-band past the action
renderRightActions={(_progress, drag) => (
<DeleteAction drag={drag} onPress={() => onDelete(task.id)} />
)}
>
<View style={styles.row}>
<Text style={styles.title}>{task.title}</Text>
</View>
</Swipeable>
);
}
const styles = StyleSheet.create({
row: { backgroundColor: '#1c1c1c', padding: 16, minHeight: 60, justifyContent: 'center' },
title: { color: 'rgba(255,255,255,0.85)', fontSize: 15 },
actionWrap: { width: 80 },
action: { flex: 1, backgroundColor: '#dc2626', alignItems: 'center', justifyContent: 'center' },
actionText: { color: '#fff', fontWeight: '600' },
});Two things worth noticing. The row has an opaque background — a transparent row lets the red action show through underneath at rest, which looks like a rendering bug. And renderRightActions gets a live drag shared value, so the action tracks the finger on the UI thread rather than appearing in one jump when the threshold is crossed. That difference is most of what makes it feel native.
Only one row open at a time
Swipeable knows nothing about its siblings, so by default a user can leave four rows half-open and the list looks wrecked. Every polished implementation tracks the open row at the list level:
export default function TaskList({ tasks, onDelete }) {
const openRef = useRef<SwipeableMethods | null>(null);
const registerOpen = useCallback((row: SwipeableMethods | null) => {
if (openRef.current && openRef.current !== row) {
openRef.current.close();
}
openRef.current = row;
}, []);
return (
<FlatList
data={tasks}
keyExtractor={(t) => t.id}
renderItem={({ item }) => (
<TaskRow task={item} onDelete={onDelete} onOpen={registerOpen} />
)}
// closing on scroll is what iOS Mail does
onScrollBeginDrag={() => { openRef.current?.close(); openRef.current = null; }}
/>
);
}
// inside TaskRow:
const rowRef = useRef<SwipeableMethods>(null);
<Swipeable
ref={rowRef}
onSwipeableWillOpen={() => onOpen(rowRef.current)}
onSwipeableWillClose={() => onOpen(null)}
...
/>Closing on scroll is the detail that separates this from a demo. Nobody wants to scroll a list with an open red panel following them down the screen.
Delete instantly, offer undo
The instinct is to confirm. Resist it. A modal after a swipe cancels the speed the gesture existed to provide, and people tap through confirmations without reading them anyway — so it costs the common case and barely protects the rare one. Remove the row immediately, hold the delete for a few seconds, and let the user take it back.
const pending = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const deleteTask = useCallback((id: string) => {
const removed = tasks.find((t) => t.id === id);
setTasks((prev) => prev.filter((t) => t.id !== id)); // gone from the UI now
const timer = setTimeout(() => {
pending.current.delete(id);
api.deleteTask(id).catch(() => {
setTasks((prev) => [...prev, removed]); // server said no — put it back
toast('Could not delete. Restored.');
});
}, 5000);
pending.current.set(id, timer);
toast('Task deleted', {
action: {
label: 'Undo',
onPress: () => {
clearTimeout(pending.current.get(id));
pending.current.delete(id);
setTasks((prev) => [...prev, removed]);
},
},
});
}, [tasks]);
// flush anything still pending if the screen unmounts
useEffect(() => () => {
pending.current.forEach((timer) => clearTimeout(timer));
}, []);The cleanup matters. If the user navigates away inside the five seconds, that timer either fires against an unmounted component or the delete silently never happens, depending on how you wrote it. Decide which behaviour you want — most apps should commit the delete on unmount rather than drop it — and write it down explicitly instead of leaving it to timing.
The traps, in the order you’ll hit them
- Nothing swipes. No
GestureHandlerRootView, or it isn’t at the true root. - The action shows through the row. The row has no background colour, so the red panel is visible underneath at rest.
- Swipe fights the vertical scroll. Raise
frictionandrightThreshold— a hair-trigger row opens every time someone scrolls with a slightly diagonal thumb. - Wrong row deleted after scrolling. You captured the index instead of the id, and virtualization reused the row component for different data. Always close over the id.
- The row stays open after delete. Call
close()before removing the item, or the exit animation runs on a row that’s already gone. - It works on iOS, feels wrong on Android. Swipe-to-delete is an iOS idiom; Android users often expect long-press-to-select instead. On Android, keep the swipe but add a long-press selection mode rather than relying on the gesture alone.
One accessibility note: a swipe is invisible to a screen reader. Add an accessibilityActionfor delete on the row, or the feature simply doesn’t exist for those users.
The shortcut: generate the wiring, keep the taste
The gesture config, the open-row bookkeeping, the optimistic-delete timer — none of it is a product decision. The product decisions are how long undo lasts and whether a delete is recoverable at all, and the only way to judge the gesture itself is with a thumb on a real screen.
Describe the screen in ShipNative — “a task list where swiping a row left deletes it with an undo toast” — and it wires the gesture root, the swipeable rows, the animated action and the undo, then runs it on your phone. Adjust the feel by prompting; export the full Expo project whenever you want it.
Build it free
Describe your app in one sentence and have it running on your phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.