Skip to content

SortableItem: onItemSnapEnd is always undefined — finalizeDrag never called #236

@tadeu-openadmin

Description

@tadeu-openadmin

Bug Description

SortableItem destructures onItemSnapEnd from sortable._internal at render time (line 129 of SortableItem.tsx), but SortableContainer only sets it via a useLayoutEffect that runs after render (line 217 of SortableContainer.tsx). Since useSortableList creates a new _internal object every render with onItemSnapEnd: undefined (line 1026 of useSortableList.ts), the destructured value is always undefined.

This means finalizeDrag is never called after the snap animation completes, so onReorder never fires and items snap back to their original positions.

Steps to Reproduce

  1. Set up a SortableContainer + SortableItem + useSortableList with a FlatList or FlashList inside a SortableBoardContainer (kanban layout)
  2. Drag an item to a new position — items shift correctly during drag, drop indicator appears
  3. Release the item
  4. Expected: item stays at the new position, onReorder callback fires
  5. Actual: item snaps back to its original position, onReorder never fires

Root Cause

In SortableItem.tsx:

// Line 129 — destructured at render time, gets `undefined`
const { ..., onItemSnapEnd, ... } = sortable._internal;

// Line 223-225 — closure captures `undefined`, so finalizeDrag is never called
onSnapEnd={(snapData) => {
  onItemSnapEnd?.();  // always undefined!
  draxViewProps.onSnapEnd?.(snapData);
}}

In SortableContainer.tsx:

// Line 216-218 — sets onItemSnapEnd AFTER render, on a new _internal object each time
useLayoutEffect(() => {
  sortable._internal.onItemSnapEnd = finalizeDrag;
}, [sortable._internal, finalizeDrag]);

In useSortableList.ts:

// Line 1026 — new _internal object created every render with undefined
onItemSnapEnd: undefined as (() => void) | undefined,

The type definition even has a comment describing the intended behavior (line 813-814 of types.ts):

/** Called by SortableItem's onSnapEnd to finalize the drag.
 *  Stored as a ref so the latest finalizeDrag is always called,
 *  even if SortableItem has a stale _internal reference. */

The intent was correct — onItemSnapEnd should act as a ref-like property read at call time — but the implementation eagerly destructures it.

Fix

One-line change in SortableItem.tsx — read onItemSnapEnd from sortable._internal at call time instead of at destructure time:

        onSnapEnd={(snapData) => {
-          onItemSnapEnd?.();
+          sortable._internal.onItemSnapEnd?.();
          draxViewProps.onSnapEnd?.(snapData);
        }}

This ensures the callback reads the finalizeDrag function that SortableContainer set in its useLayoutEffect, rather than the undefined captured during destructuring.

Environment

  • react-native-drax: 1.1.0
  • react-native: 0.79
  • react-native-reanimated: 4.2.2
  • expo: 55
  • Using FlashList (same behavior expected with FlatList)
  • Layout: SortableBoardContainer > SortableContainer > FlashList > SortableItem (kanban board with multiple columns)

Workaround

We're using a bun patch (patches/react-native-drax@1.1.0.patch) with the fix above. Same patch can be applied via patch-package for npm/yarn users.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions