skip to content

In React Native Gesture Handler 3, how do you build swipe-to-delete episode rows with ReanimatedSwipeable and keep only one row open?

level: middleimportance: should knowfreq 30%

answer

  1. import from its own subpath
  2. renderRightActions gets progress and translation
  3. rightThreshold defaults to half the panel
  4. friction and overshootFriction
  5. ref methods: close, openLeft, openRight, reset

basics

~20 s

Wrap each row in ReanimatedSwipeable and render the delete button with renderRightActions, animating it from the progress and translation shared values. Keep a ref to the open row and call close() on it when another row starts opening.

solid answer

~40 s

`ReanimatedSwipeable`, imported from `react-native-gesture-handler/ReanimatedSwipeable`, needs Reanimated installed and the app inside `GestureHandlerRootView`. It puts the row in a horizontally pannable container; `renderRightActions(progress, translation, methods)` renders what is revealed on a left swipe, where `progress` is a shared value from `0` (closed) to `1` (open) that keeps growing on overshoot, so the action animates in `useAnimatedStyle` without re-rendering. Tune feel with `friction` (default `1`), `rightThreshold` (default half the actions panel), `overshootRight` and `overshootFriction` (docs suggest 8 or more for a native feel). Callbacks such as `onSwipeableWillOpen` and `onSwipeableOpen` receive the direction and run on the JS side, so state updates are fine there. For one open row at a time, keep a ref to the currently open row's `SwipeableMethods` and call `close()` on it in the next row's `onSwipeableWillOpen`.

code

tsx · 62 lines
tsx
import { useRef } from 'react';
import { FlatList, Pressable, StyleSheet, Text } from 'react-native';
import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable';
import Animated, { useAnimatedStyle, type SharedValue } from 'react-native-reanimated';

type Episode = { id: string; title: string };

function DeleteAction({ progress, onDelete }: { progress: SharedValue<number>; onDelete: () => void }) {
  const style = useAnimatedStyle(() => ({
    opacity: Math.min(progress.value, 1),
    transform: [{ scale: 0.8 + 0.2 * Math.min(progress.value, 1) }],
  }));
  return (
    <Animated.View style={[styles.action, style]}>
      <Pressable accessibilityRole="button" onPress={onDelete}>
        <Text style={styles.actionText}>Delete</Text>
      </Pressable>
    </Animated.View>
  );
}

export function Downloads({ episodes, onDelete }: { episodes: Episode[]; onDelete: (id: string) => void }) {
  const openRow = useRef<SwipeableMethods | null>(null);
  const rowRefs = useRef(new Map<string, SwipeableMethods>());

  return (
    <FlatList
      data={episodes}
      keyExtractor={(e) => e.id}
      renderItem={({ item }) => (
        <ReanimatedSwipeable
          ref={(r) => {
            if (r) rowRefs.current.set(item.id, r);
            else rowRefs.current.delete(item.id);
          }}
          friction={2}
          rightThreshold={40}
          overshootFriction={8}
          onSwipeableWillOpen={() => {
            const me = rowRefs.current.get(item.id) ?? null;
            if (openRow.current && openRow.current !== me) openRow.current.close();
            openRow.current = me;
          }}
          onSwipeableClose={() => {
            if (openRow.current === rowRefs.current.get(item.id)) openRow.current = null;
          }}
          renderRightActions={(progress) => (
            <DeleteAction progress={progress} onDelete={() => onDelete(item.id)} />
          )}
        >
          <Text style={styles.row}>{item.title}</Text>
        </ReanimatedSwipeable>
      )}
    />
  );
}

const styles = StyleSheet.create({
  row: { padding: 16, backgroundColor: 'white' },
  action: { width: 88, justifyContent: 'center', alignItems: 'center', backgroundColor: 'red' },
  actionText: { color: 'white' },
});

go deeper

for a junior

Recall the import path, that the app needs GestureHandlerRootView, and that renderRightActions renders what a left swipe reveals.

for a middle

Explain the progress and translation shared values, the threshold and friction props with their defaults, and the ref methods close, openLeft, openRight and reset.

for a senior

Coordinate rows so one stays open, keep vertical scrolling reliable with drag offsets, animate actions on the UI thread, and plan an accessible alternative to the swipe.

for a principal

Decide whether destructive actions belong behind a swipe at all, and set a consistent swipe vocabulary across the app's lists.

## What `ReanimatedSwipeable` is `ReanimatedSwipeable` is Gesture Handler's component for swipeable rows: the list item slides sideways to reveal actions underneath, like delete or download on an episode in a podcast app's downloads list. It is the drop-in replacement for the older `Swipeable` component, rewritten on Reanimated, and in Gesture Handler 3 it is built on the new hook API. Prerequisites: - Reanimated (and therefore `react-native-worklets`) installed. - The app wrapped in `GestureHandlerRootView`. - Import from its own subpath: `import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'`. ## Rendering the actions Two render props define what is revealed: - `renderLeftActions` for a swipe to the right, - `renderRightActions` for a swipe to the left. Each receives three arguments: 1. **`progress`**: a Reanimated shared value, `0` when closed and `1` when fully open, approaching infinity as the row overshoots. 2. **`translation`**: a shared value with the row's horizontal offset from its closed position. 3. **`swipeableMethods`**: the same methods the ref exposes. Because `progress` and `translation` are shared values, the action button can scale, fade or slide in `useAnimatedStyle` on the UI thread while the finger moves, with no React renders. Render them in a small component so hooks are used legally. ## Tuning the feel | Prop | Default | Effect | |---|---|---| | `friction` | `1` | How much the panel lags the finger; `2` moves at half speed | | `rightThreshold` / `leftThreshold` | Half the actions panel's width | Release distance past which it opens (or closes) | | `overshootRight` / `overshootLeft` | `true` when that side's render function exists | Whether the row can be pulled past the actions | | `overshootFriction` | `1` (no friction) | Drag resistance while overshooting; docs suggest `8` or more | | `dragOffsetFromLeft` / `dragOffsetFromRight` | `10` / `-10` | Horizontal travel needed before a swipe activates | The drag offsets set how far the finger must travel horizontally (10 points either way by default) before the swipe activates, which leaves a mostly vertical drag to the list's scroll. In Gesture Handler 3, `dragOffsetFromRight` takes a negative value, and several props (`enabled`, `hitSlop`, the drag offsets) also accept shared values. ## Callbacks and threads The lifecycle callbacks receive the swipe direction: - `onSwipeableOpenStartDrag`, `onSwipeableCloseStartDrag` when a drag begins; - `onSwipeableWillOpen`, `onSwipeableWillClose` when the open or close animation starts; - `onSwipeableOpen`, `onSwipeableClose` when it finishes. The component dispatches them to the JS side, so calling `setState` or a delete mutation from them is fine. ## Keeping one row open Users expect that opening a second row closes the first. `ReanimatedSwipeable` does not coordinate rows by itself, so: 1. Give each row a ref typed `SwipeableMethods`. 2. Keep a ref (not state) holding the currently open row's methods at list level. 3. In a row's `onSwipeableWillOpen`, call `close()` on the stored row if it is a different one, then store this row. 4. Clear the stored row in `onSwipeableClose`. The ref methods are: - `close()` animates the row shut; - `openLeft()` and `openRight()` open it programmatically; - `reset()` clears the swiping state **without** animation, which is useful when a row component is reused for different data. ## Deleting safely When the delete action fires, remove the item from your data and let the row unmount; keying the list by episode id ensures the next row does not inherit the open state. If you animate the removal, Reanimated's `exiting` and `layout` props on the row's container belong to the layout-animation toolkit rather than the swipeable. ## What it does not cover Swipes are invisible to screen readers, so every swipe action also needs an accessible alternative, such as a custom accessibility action or a visible button. Coordinating a swipeable with other gestures uses its `simultaneousWith`, `requireToFail` and `block` props, which follow Gesture Handler's composition rules.

  • Why doesn't a vertical scroll of the episode list accidentally open ReanimatedSwipeable rows?
    The swipe activates only after the finger travels `dragOffsetFromLeft` (default `10`) or `dragOffsetFromRight` (default `-10`) horizontally from the start point. A mostly vertical drag is usually taken by the list's scroll before that happens. If rows still open during scrolling, raise those offsets.
  • What is the difference between close() and reset() on a ReanimatedSwipeable ref?
    `close()` animates the row back to its closed position and fires the close callbacks' lifecycle. `reset()` clears the swiping state immediately without any animation, which suits a row being reused for different data, where an animated close would be visible and wrong.

saying these in an interview costs you the question

  • ReanimatedSwipeable works without GestureHandlerRootView at the app root.
  • Only one row can be open at a time by default, with no extra code.
  • The progress value stops at 1, so overshoot cannot be animated.
  • onSwipeableOpen runs on the UI thread, so setState inside it throws.
  • dragOffsetFromRight takes a positive value in Gesture Handler 3.
  • A swipe action needs no alternative for screen reader users.