React Native Swipe List Actions with Undo and Reanimated
By Native Animation · Updated
A React Native swipe list uses stable row IDs, a horizontal gesture within vertical scrolling, and host-owned pending actions with undo. React Native swipe actions expose contextual operations without making every list row permanently wider. The useful distinction is between revealing an action and committing it. Revealing lets someone inspect a choice; committing changes application data. Treat those as separate states so a small exploratory drag does not unexpectedly delete a record.
This guide focuses on list actions, not card-deck decisions. If your interface asks people to accept or reject whole cards, the swipe cards tutorial covers that different interaction. For a task or inbox row, preserve the surrounding list and make the action outcome explicit.
Swipe actions belong to the row and the host
Plan a row that rests closed, reveals contextual actions during a horizontal drag, settles open or closed on release, and performs a requested action once. Deleting then removes the item from application state. An undo path can restore it using a stable record ID and the original ordering information.
The Swipe to Delete Row is the published catalog example. Its reveal, close, and remove controls let your application drive the same visual states without requiring a swipe. The README documents onDelete and onArchive; neither callback replaces your persistence or error handling.
Prerequisites and compatibility
The catalog's current version baseline is Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, and Gesture Handler ~3.3.0. Check the selected piece's dependency table for its exact requirements, including Worklets when listed. Use Expo's compatible installer rather than selecting native package versions independently.
The SDK 58 documentation and the piece's README are the setup references. Keep gesture input and presentation separate from application requests. Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. Test how your integrated list competes for gestures on the target devices.
Coordinate a horizontal swipe with vertical scrolling
Direction thresholds and interrupted gestures
For a custom row, Gesture Handler 3.3's Pan API can wait for horizontal intent and fail before activation when vertical travel exceeds a chosen range. This standalone teaching surface illustrates cancellation; it does not delete records:
The Gesture.* builder API remains available in Gesture Handler 3.3, but is deprecated in favor of its hook API. The example retains the builders.
import { Text } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
export function CancelableSwipeSurface() {
const x = useSharedValue(0);
const gesture = Gesture.Pan().activeOffsetX([-16, 16]).failOffsetY([-10, 10])
.onUpdate((event) => { x.set(Math.max(-100, Math.min(100, event.translationX))); })
.onFinalize(() => { x.set(withSpring(0, { stiffness: 260, damping: 30, mass: 1 })); });
const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.get() }] }));
return <GestureDetector gesture={gesture}>
<Animated.View style={[{ minHeight: 64, padding: 16, backgroundColor: '#FFFFFF' }, style]}>
<Text>Drag sideways, then release to cancel.</Text>
</Animated.View>
</GestureDetector>;
}The thresholds are example tuning values in points, not universal recommendations or measurements. onFinalize handles both a completed gesture and a cancellation; it restores the surface in either case. For an action worklet, call a JS-scope handler once at the commit boundary with scheduleOnRN from react-native-worklets, not a React setter on every update.
The catalog row does not expose activeOffsetX or failOffsetY as props. Its current source uses a horizontal activation offset and release thresholds; integrating a stricter vertical-failure or system-cancellation policy means editing the copied source and testing it. Do not assume the custom snippet silently changes the catalog implementation. Test diagonal drags, vertical flings, interrupted touches, and parent scroll on devices.
One open row or several
The row exposes reveal and close ref methods, but no onOpenChange callback. You can enforce one open row for host-driven Reveal actions by closing the other mounted refs before revealing a new ID. Observing gesture-driven reveal or enforcing the same policy for every gesture needs a copied-source adapter; it is not an undocumented prop. Clean up refs when virtualized cells unmount, and keep application state keyed by ID.
Reserve horizontal swiping for contextual actions when the list scrolls vertically. A competing horizontal carousel may be better served by an explicit action menu. Drag reordering is a separate interaction from revealing row actions.
Reveal versus full-swipe commit
A short horizontal movement should be reversible. Once the action area is visible, let the person tap Archive or Delete with a clear label. A full-swipe shortcut can serve experienced users, but it needs a deliberate threshold and an outcome people can recover from.
The catalog row grows its delete affordance during the drag and commits past its documented threshold. Review that behavior against your product's risk. Removing a local task with Undo is different from deleting an account, revoking access, or sending an irreversible request. Those operations need a deliberate confirmation flow beyond a decorative row collapse.
The gesture should remain interruptible before commitment. Reversing direction or releasing below the threshold should return to a coherent revealed or closed state. Do not use a timer to infer that someone has approved an action simply because they paused while dragging.
Integrate the published row
After adding the piece with the Native Animation CLI, wrap your row content in the component. Its contract accepts children, onDelete, onArchive, and actionWidth, along with action colors. A ref exposes reveal(), close(), and remove(); consult the delivered README for types and compatibility aliases.
Use callbacks keyed to the record ID, not the row's current index. Filtering, sorting, and virtualization can change indexes while a gesture is active. For example, a task identified as task-42 should remain the deletion target even if another task disappears above it.
The delete callback runs after the visual collapse. Decide whether your product uses optimistic removal or waits for persistence, and keep that policy in application code. If you restore an item after a failed request, explain the failure and make the restored row usable; a visual disappearance is not proof that a server accepted the request.
Keep IDs stable while rows move
Add the piece, then import SwipeToDeleteRow from the generated folder:
npx nativeanimation add swipe-to-delete-rowPlace the example beside the generated piece folder, or adjust the relative import to its location in your project.
A row adapter can keep record identity explicit without coupling the animation to a particular backend:
import SwipeToDeleteRow from './swipe-to-delete-row';
import type { ReactNode } from 'react';
type TaskActionsProps = {
taskId: string;
children: ReactNode;
onRemove: (id: string) => void;
onArchive: (id: string) => void;
};
export function TaskActions({
taskId, children, onRemove, onArchive,
}: TaskActionsProps) {
return (
<SwipeToDeleteRow
onDelete={() => onRemove(taskId)}
onArchive={() => onArchive(taskId)}
>
{children}
</SwipeToDeleteRow>
);
}The host callbacks own optimistic state, pending requests, and recovery. Keep an explicit button or menu connected to the same host operations so the gesture is an optional shortcut.
Confirm a destructive action deliberately
Pending delete and undo window
A useful React Native swipe list separates its canonical records from the visible rows. The complete local example below keeps a pending record in the canonical array, hides it from FlatList, and offers Undo until the person explicitly chooses Finish removal. The Undo window has no timeout. Finishing removes the local record; there is no simulated network request or claim of server persistence.
Install the catalog row with the CLI and its SDK-compatible dependencies using npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets. Mount GestureHandlerRootView at the app root. Save this file beside the copied swipe-to-delete-row folder. It targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Gesture Handler 3.3.
import { useRef, useState } from 'react';
import { Button, FlatList, Text, View } from 'react-native';
import { SwipeToDeleteRow, type SwipeToDeleteRowRef } from './swipe-to-delete-row';
type Task = { id: string; title: string };
type Pending = { id: string; title: string; action: 'Delete' | 'Archive' };
const initialTasks: Task[] = [
{ id: 'task-42', title: 'Review delivery details' },
{ id: 'task-73', title: 'Check the packing list' },
{ id: 'task-91', title: 'Confirm the address' },
];
export function LocalSwipeList() {
const [tasks, setTasks] = useState(initialTasks);
const [pending, setPending] = useState<Pending[]>([]);
const pendingIds = useRef(new Set<string>());
const rows = useRef(new Map<string, SwipeToDeleteRowRef>());
const [message, setMessage] = useState('');
function stage(task: Task, action: Pending['action']) {
if (pendingIds.current.has(task.id)) return;
pendingIds.current.add(task.id);
setPending((items) => [...items, { ...task, action }]);
setMessage(action + ' pending: ' + task.title);
}
function undo(item: Pending) {
pendingIds.current.delete(item.id);
setPending((items) => items.filter((entry) => entry.id !== item.id));
setMessage('Restored: ' + item.title);
}
function finish(item: Pending) {
pendingIds.current.delete(item.id);
setTasks((items) => items.filter((entry) => entry.id !== item.id));
setPending((items) => items.filter((entry) => entry.id !== item.id));
setMessage(item.action + ' finished locally: ' + item.title);
}
const hidden = new Set(pending.map((item) => item.id));
return (
<View style={{ flex: 1, padding: 24, gap: 12 }}>
<Text accessibilityLiveRegion="polite">{message}</Text>
<Button title="Reverse list order" onPress={() => setTasks((items) => [...items].reverse())} />
<FlatList data={tasks.filter((task) => !hidden.has(task.id))}
keyExtractor={(task) => task.id} ListEmptyComponent={<Text>No visible tasks.</Text>}
renderItem={({ item }) => (
<SwipeToDeleteRow key={item.id} ref={(handle) => {
if (handle) rows.current.set(item.id, handle);
else rows.current.delete(item.id);
}} accessibilityLabel={'Actions for ' + item.title}
onDelete={() => stage(item, 'Delete')}
onArchive={() => stage(item, 'Archive')}>
<View style={{ padding: 16, gap: 8, backgroundColor: '#FFFFFF' }}>
<Text>{item.title}</Text>
<Button title={'Delete ' + item.title}
onPress={() => rows.current.get(item.id)?.remove()} />
<Button title={'Archive ' + item.title} onPress={() => stage(item, 'Archive')} />
<Button title={'Cancel swipe for ' + item.title}
onPress={() => rows.current.get(item.id)?.close()} />
</View>
</SwipeToDeleteRow>
)} />
{pending.map((item) => <View key={item.id} style={{ gap: 8 }}>
<Text>{item.action} pending: {item.title}</Text>
<Button title={'Undo ' + item.action.toLowerCase() + ' for ' + item.title}
onPress={() => undo(item)} />
<Button title={'Finish removal of ' + item.title} onPress={() => finish(item)} />
</View>)}
</View>
);
}Try task-42 → swipe or tap Delete → pending → Reverse list order → Undo. The same record returns at its position in the current canonical order. Because filtering unmounted its old row, restoration mounts a fresh, usable row instead of reusing an internally collapsed cell. The Set rejects duplicate callback intents before a React render; FlatList keys use IDs, never indexes. Local Archive also stages a removal here; a real archive operation should update your domain's archived state.
This small demonstration keeps pending actions outside FlatList. For a large pending queue, build a bounded, scrollable recovery surface; do not let hundreds of Undo controls push the list offscreen.
Failed requests and restored rows
For persistence, replace Finish removal with a host-owned pending request. Keep the canonical item or a tombstone until the server accepts, disable duplicate submissions for that ID, and restore it on failure with a readable error. Distinguish undoable, saving, success, and failure states; disable Undo during an irreversible final request or implement a real compensating restore endpoint. A timeout alone cannot undo a completed server deletion.
If other clients can reorder or replace the list, store stable neighboring IDs or reconcile against the latest server order. Never insert blindly at an obsolete index. After a failed deletion, unmount the collapsed row and mount a fresh instance for the restored ID: the current piece's reset alias only closes horizontal translation, and is not an API for reversing a completed collapse.
Plan undo and failure states
For an optimistic deletion, save enough information to reverse it before mutating the list. A practical undo record includes the item, its stable ID, and its relative position. If the data has changed while Undo is visible, restore by identity and a reasonable neighboring position rather than blindly reusing an obsolete array index.
Make repeated activation harmless. Disable or ignore a second request for the same pending record at the application boundary. Do not depend on the animation being long enough to prevent duplicate network operations.
An undo message needs a readable action and a predictable lifetime. If recovery is essential, do not make a brief toast the only path. For server-backed data, decide what happens when Undo arrives after the deletion has already been persisted; this is a data policy, not something a swipe component can choose for you.
Accessibility alternatives to swiping
Provide a tap-accessible route to every swipe action. An overflow menu, explicit action button, or equivalent row controls can expose Delete and Archive without requiring a particular drag distance. Label actions with enough context to distinguish records when several rows are visible.
Keep the reading order stable and test focus after deletion. The next logical row or a list-level control should remain reachable. A shrinking visual copy should not be announced as a second record, and color alone should not distinguish a destructive action from an archival one.
Under Reduce Motion, preserve the outcome with a still state or short fade instead of large horizontal travel and an extended collapse. Test these states in your integrated screen with the actual system preference, not just in a recorded browser loop.
Common pitfalls
- Deleting by array index. Reordering can apply the action to the wrong record.
- Calling persistence from every gesture update. Commit a discrete intent once; visual progress is not application data.
- Treating a revealed action as confirmation. Opening controls must remain reversible.
- Relying on swipe as the only input. Expose equivalent named actions for assistive technology and ordinary taps.
- Assuming collapse means success. Handle pending, failure, and undo states in the host application.
Use Swipe to Delete Row
Use Swipe to Delete Row for a contextual list-action surface. It supplies the gesture and visual transition; you supply record identity, persistence, recovery, and any required confirmation. Keep those responsibilities visible in your integration tests.
FAQ
Is a swipe row the same as a swipe card deck?
No. A row reveals operations on an item while preserving a list. A deck makes a decision about the current card and advances to another one.
Where should Undo live?
In the application layer that owns the list and persistence. The animation component cannot restore data it does not own.
Can a preview verify gesture behavior on iOS and Android?
Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. A browser preview shows the interaction; test your own integrated list with native scrolling, accessibility, and your final data flow.