DnD reordering, unbloated.

A tiny, headless React hook for native drag-and-drop reordering. No dependencies. No bloat. Just works.

use-sortable-list.ts
import { useCallback, useEffect, useRef, useState } from "react";

export function useSortableList<T>(
  items: T[],
  onReorder: (newItems: T[]) => void,
) {
  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
  const [isTouch, setIsTouch] = useState(false);
  const hasHandleRegistered = useRef(false);
  const isHandleAction = useRef(false);

  // Refs for custom pointer logic (Mobile)
  const itemsRef = useRef(items);
  const draggedIndexRef = useRef<number | null>(null);
  const lastUpdate = useRef<number>(0);

  // 1. Detect device type on mount
  useEffect(() => {
    setIsTouch("ontouchstart" in window || navigator.maxTouchPoints > 0);
  }, []);

  // 2. Sync refs for mobile logic
  useEffect(() => {
    itemsRef.current = items;
    draggedIndexRef.current = draggedIndex;
  }, [items, draggedIndex]);

  // --- MOBILE LOGIC (Pointer Events + Manual Collision) ---
  const onPointerMove = useCallback(
    (e: PointerEvent) => {
      const sourceIndex = draggedIndexRef.current;
      if (sourceIndex === null) return;

      const now = Date.now();
      if (now - lastUpdate.current < 100) return; // Throttle jitter

      const element = document.elementFromPoint(e.clientX, e.clientY);
      const targetItem = element?.closest("[data-sortable-index]");

      if (targetItem) {
        const targetIndex = Number(
          (targetItem as HTMLElement).dataset.sortableIndex,
        );
        if (targetIndex !== sourceIndex) {
          lastUpdate.current = now;
          const newList = [...itemsRef.current];
          const [draggedItem] = newList.splice(sourceIndex, 1);
          newList.splice(targetIndex, 0, draggedItem);
          setDraggedIndex(targetIndex);
          onReorder(newList);
        }
      }
    },
    [onReorder],
  );

  const onPointerUp = useCallback(() => {
    setDraggedIndex(null);
    isHandleAction.current = false;
    window.removeEventListener("pointermove", onPointerMove);
    window.removeEventListener("pointerup", onPointerUp);
  }, [onPointerMove]);

  const startCustomDrag = (index: number) => {
    setDraggedIndex(index);
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerup", onPointerUp);
  };

  // --- DESKTOP LOGIC (Native HTML5 Drag & Drop) ---
  const onNativeDragStart = (e: React.DragEvent, index: number) => {
    if (hasHandleRegistered.current && !isHandleAction.current) {
      e.preventDefault();
      return;
    }
    setDraggedIndex(index);
  };

  const onNativeDragEnter = (targetIndex: number) => {
    const sourceIndex = draggedIndexRef.current;
    if (sourceIndex === null || sourceIndex === targetIndex) return;

    const newList = [...itemsRef.current];
    const [draggedItem] = newList.splice(sourceIndex, 1);
    newList.splice(targetIndex, 0, draggedItem);
    setDraggedIndex(targetIndex);
    onReorder(newList);
  };

  const onNativeDragEnd = () => {
    setDraggedIndex(null);
    isHandleAction.current = false;
  };

  // --- PUBLIC API ---
  const getItemProps = (index: number) => {
    const commonProps = {
      "data-sortable-index": index,
      isDragging: draggedIndex === index,
    };

    if (isTouch) {
      return {
        ...commonProps,
        onPointerDown: () => {
          if (!hasHandleRegistered.current) startCustomDrag(index);
        },
        style: {
          touchAction: "none" as const,
          userSelect: "none" as const,
          pointerEvents: (draggedIndex === index
            ? "none"
            : "auto") as React.CSSProperties["pointerEvents"],
        },
      };
    }

    return {
      ...commonProps,
      draggable: true,
      onDragStart: (e: React.DragEvent) => onNativeDragStart(e, index),
      onDragEnter: () => onNativeDragEnter(index),
      onDragEnd: onNativeDragEnd,
      onDragOver: (e: React.DragEvent) => e.preventDefault(),
      onPointerDown: () => {
        isHandleAction.current = false;
      },
    };
  };

  const getHandleProps = (index: number) => {
    hasHandleRegistered.current = true;
    return {
      onPointerDown: (e: React.PointerEvent) => {
        e.stopPropagation();
        isHandleAction.current = true;
        if (isTouch) startCustomDrag(index);
      },
      style: { cursor: "grab", touchAction: "none" as const },
    };
  };

  return { getItemProps, getHandleProps, isDragging: draggedIndex !== null };
}

Standard Usage

Drag anywhere on the item to reorder.

  1. Complete documentation

    Dev
    DragIcon
  2. Review pull requests

    Dev
    DragIcon
  3. Update dependencies

    Maintenance
    DragIcon
  4. Lunch with team

    Social
    DragIcon

Handle-Only Usage

Dragging is restricted to the handle. Content remains selectable.

  1. system_architecture.pdf

    Modified 2h ago

  2. production_logs.txt

    Modified 2h ago

  3. branding_assets.zip

    Modified 2h ago

Zero Dependencies

Built using the browser's native Drag and Drop API. No third-party physics engines or heavy libraries.

Headless Design

You own the markup and the styles. We just provide the logic and event handlers to sync your state.

Mobile Ready

Leverages Pointer Events to ensure smooth handle-based dragging on touch devices and tablets.

Did you like this project?

Please give it a star on GitHub. Thank you!

You might also be interested in use-local-state.