Skip to main content

Overview

When building sortable interfaces, you need to keep your application state in sync with drag operations. There are two approaches:
  1. Using the move helper from @dnd-kit/helpers — a convenience function that takes your items and a drag event and returns a new array with the item moved to its new position. It supports flat arrays and grouped records, handles canceled drags, and works with optimistic sorting out of the box. This is covered in the Multiple sortable lists guide.
  2. Manual state management — using the sortable properties and type guards for full control over state updates.
This guide covers the second approach. Before reading this guide, make sure you’re familiar with optimistic sorting, which is enabled by default and affects how source and target behave during drag operations.

Understanding optimistic sorting

The OptimisticSortingPlugin is enabled by default for all sortable items. It optimistically reorders DOM elements during a drag so the UI feels responsive without requiring React re-renders on every dragover event. A key consequence is that source and target in the drag operation will refer to the same element during a drag. This means you cannot compare source.id and target.id to determine what moved. Instead, use the sortable-specific properties on the source:
PropertyDescription
indexThe current position (updated as the item moves)
initialIndexThe position when the drag started
groupThe current group
initialGroupThe group when the drag started
These properties are available on the source when it is a sortable element. Use the isSortable type guard to narrow the type.
You can call event.preventDefault() in onDragOver to prevent the OptimisticSortingPlugin from optimistically updating for that specific event. This is useful when you want to conditionally block certain moves (for example, preventing items from being dragged into a specific group).

Single list without the move helper

With optimistic sorting, you only need to handle onDragEnd. The OptimisticSortingPlugin takes care of visual feedback during the drag.
import {useState} from 'react';
import {DragDropProvider} from '@dnd-kit/react';
import {useSortable, isSortable} from '@dnd-kit/react/sortable';

function SortableItem({id, index}) {
  const {ref} = useSortable({id, index});

  return <li ref={ref}>{id}</li>;
}

export default function App() {
  const [items, setItems] = useState([1, 2, 3, 4, 5]);

  return (
    <DragDropProvider
      onDragEnd={(event) => {
        if (event.canceled) return;

        const {source} = event.operation;

        if (isSortable(source)) {
          const {initialIndex, index} = source;

          if (initialIndex !== index) {
            setItems((items) => {
              const newItems = [...items];
              const [removed] = newItems.splice(initialIndex, 1);
              newItems.splice(index, 0, removed);
              return newItems;
            });
          }
        }
      }}
    >
      <ul>
        {items.map((id, index) => (
          <SortableItem key={id} id={id} index={index} />
        ))}
      </ul>
    </DragDropProvider>
  );
}

Multiple lists without the move helper

For multiple lists, use initialGroup and group to detect whether the item stayed in the same list or moved to a different one:
import {useState, useRef} from 'react';
import {DragDropProvider} from '@dnd-kit/react';
import {useSortable, isSortable} from '@dnd-kit/react/sortable';

function SortableItem({id, index, column}) {
  const {ref} = useSortable({
    id,
    index,
    group: column,
    type: 'item',
    accept: 'item',
  });

  return <li ref={ref}>{id}</li>;
}

export default function App() {
  const [items, setItems] = useState({
    A: ['A1', 'A2', 'A3'],
    B: ['B1', 'B2'],
    C: [],
  });
  const snapshot = useRef(structuredClone(items));

  return (
    <DragDropProvider
      onDragStart={() => {
        snapshot.current = structuredClone(items);
      }}
      onDragEnd={(event) => {
        if (event.canceled) {
          setItems(snapshot.current);
          return;
        }

        const {source} = event.operation;

        if (isSortable(source)) {
          const {initialIndex, index, initialGroup, group} = source;

          if (initialGroup == null || group == null) return;

          setItems((items) => {
            if (initialGroup === group) {
              // Same group: reorder within the list
              const groupItems = [...items[group]];
              const [removed] = groupItems.splice(initialIndex, 1);
              groupItems.splice(index, 0, removed);
              return {...items, [group]: groupItems};
            }

            // Cross-group transfer
            const sourceItems = [...items[initialGroup]];
            const [removed] = sourceItems.splice(initialIndex, 1);
            const targetItems = [...items[group]];
            targetItems.splice(index, 0, removed);
            return {
              ...items,
              [initialGroup]: sourceItems,
              [group]: targetItems,
            };
          });
        }
      }}
    >
      {Object.entries(items).map(([column, columnItems]) => (
        <ul key={column}>
          {columnItems.map((id, index) => (
            <SortableItem key={id} id={id} index={index} column={column} />
          ))}
        </ul>
      ))}
    </DragDropProvider>
  );
}
When updating state in onDragOver (rather than onDragEnd), save a snapshot in onDragStart so you can revert if the drag is canceled.

Comparison with the move helper

move helperManual state management
SetupOne line: setItems(items => move(items, event))More code, but full control
When to updateTypically in onDragOver or onDragEndTypically in onDragEnd only
ID matchingMatches items by item === id or item.id === idYou control the logic entirely
Optimistic sortingWorks with and withoutBest with optimistic sorting enabled
Custom data structuresLimited to arrays and records of arraysAny data structure
Use the move helper when your data structure matches its expectations (flat arrays or Record<string, array>). Use manual state management when you need more control, have custom data structures, or use computed IDs.

Type guards

isSortable

Checks whether a Draggable or Droppable is a sortable instance, narrowing the type to expose index, initialIndex, group, and initialGroup:
import {isSortable} from '@dnd-kit/react/sortable';

const {source, target} = event.operation;

if (isSortable(source)) {
  source.index;        // number
  source.initialIndex; // number
  source.group;        // string | number | undefined
  source.initialGroup; // string | number | undefined
}

isSortableOperation

Narrows both source and target of a drag operation at once:
import {isSortableOperation} from '@dnd-kit/react/sortable';

const {operation} = event;

if (isSortableOperation(operation)) {
  operation.source.initialIndex; // typed
  operation.target.index;        // typed
}

Integration with external state

When using sortable lists alongside data fetching libraries like React Query, TanStack Query, or SWR, you may encounter duplicate items after a refetch. This happens when optimistic sorting has moved DOM elements during the drag, and then a refetch replaces the data while the drag state is still active. To avoid this, only sync your local items state with fetched data when no drag is in progress:
import {useState, useEffect, useRef} from 'react';
import {DragDropProvider} from '@dnd-kit/react';

function SortableList() {
  const {data: fetchedItems} = useQuery({queryKey: ['items'], queryFn: fetchItems});
  const [items, setItems] = useState(fetchedItems ?? []);
  const isDragging = useRef(false);

  useEffect(() => {
    if (fetchedItems && !isDragging.current) {
      setItems(fetchedItems);
    }
  }, [fetchedItems]);

  return (
    <DragDropProvider
      onDragStart={() => { isDragging.current = true; }}
      onDragEnd={(event) => {
        isDragging.current = false;

        if (event.canceled) {
          // Reset to server state on cancel
          setItems(fetchedItems ?? []);
          return;
        }

        // Update local state, then sync with server
        setItems((items) => move(items, event));
      }}
    >
      {items.map((item, index) => (
        <SortableItem key={item.id} id={item.id} index={index} />
      ))}
    </DragDropProvider>
  );
}
The key principle is maintaining a single source of truth: render from your local items state (not directly from the query data), and only update it from the query when no drag is active.