Skip to content

Repository files navigation

react-group-sortable

Headless, group-aware drag-and-drop sorting for React. Items can move, groups move atomically, and business rules remain valid without index-based array repair code.

The package combines three layers:

  • a framework-agnostic immutable transformation engine;
  • controlled React components and a headless hook over dnd-kit sensors;
  • an optional React Hook Form bridge that preserves useFieldArray identity.

Why this package exists

A generic sortable list can move one row. It usually does not know that two adjacent rows form an atomic superset, that a group must contain exactly two items, or that moving a row must preserve an unsubmitted form error. react-group-sortable owns those semantics. Preview and commit use the same typed operation and constraint result, so rejected drops never expose a half-updated array.

Core guarantees:

  • item and group identity comes from stable business IDs, never array indexes;
  • successful operations are immutable and deterministic;
  • rejected operations return the original array reference;
  • a flat group remains contiguous and a group move preserves member order;
  • multi-step membership/lifecycle changes commit once or not at all;
  • frozen input arrays and objects are supported;
  • the React API is controlled and SSR-safe.

Installation

npm install react-group-sortable

React 18 and 19 are supported. React Hook Form is an optional peer and is only needed when importing react-group-sortable/react-hook-form.

Basic usage

import { useState } from 'react';
import { DragHandle, GroupSortable } from 'react-group-sortable';

interface Row {
  id: string;
  title: string;
  groupId: string | null;
}

export function SortableRows({ initialRows }: { initialRows: readonly Row[] }) {
  const [rows, setRows] = useState(initialRows);

  return (
    <GroupSortable<Row, string, string>
      items={rows}
      getItemId={(row) => row.id}
      getGroupId={(row) => row.groupId}
      setGroupId={(row, groupId) => ({ ...row, groupId })}
      onItemsChange={(nextRows) => setRows(nextRows)}
      renderItem={({ item, sortable }) => (
        <article>
          <DragHandle sortable={sortable} aria-label={`Move ${item.title}`}></DragHandle>
          {item.title}
        </article>
      )}
      renderGroup={({ group, children, sortable }) => (
        <section aria-label={`Group ${group.id}`}>
          <DragHandle sortable={sortable} aria-label={`Move group ${group.id}`}>
            Move group
          </DragHandle>
          {children}
        </section>
      )}
    />
  );
}

The default groupDragMode="block" treats a group as one top-level sortable. Use groupDragMode="configurable" when the group handle should move the block while member handles may sort, leave, or enter groups.

Grouping and lifecycle

Flat data represents membership with a nullable group ID. Members with the same non-null ID must be contiguous. Invalid split groups are reported; they are never silently repaired during a drop.

<GroupSortable
  items={rows}
  getItemId={(row) => row.id}
  getGroupId={(row) => row.groupId}
  setGroupId={(row, groupId) => ({ ...row, groupId })}
  groupRules={{ minItems: 2, maxItems: 2, unwrapBelowMin: true }}
  groupDragMode="configurable"
  allowSortWithinGroup
  allowDropIntoGroup
  allowCreateGroup
  createGroupId={() => crypto.randomUUID()}
  onItemsChange={setRows}
  renderItem={renderItem}
/>

For the fixed-pair configuration above:

  • a third member is rejected with reason: "group-max-items";
  • extracting one member dissolves the one-item remainder;
  • both former members receive groupId: null;
  • dragging the group moves both rows atomically and preserves their internal order.

setGroupId is required for operations that change membership. Read-only grouping and whole-group movement can omit it.

Explicit group actions

The headless hook exposes useful imperative actions, while the same operations are also pure core exports:

const sortable = useGroupSortable(options);

sortable.actions.createGroup(['squat', 'press'], 'superset-2');
sortable.actions.dissolveGroup('superset-1');
sortable.actions.extractFromGroup('curl', 3);
sortable.actions.insertIntoGroup('squat', 'superset-1', 1);
sortable.actions.moveGroup('superset-2', 0);
sortable.actions.cancelDrag();

Pure transformation engine

Core has no React or browser dependency:

import { createFlatGroupAdapter, moveGroup } from 'react-group-sortable/core';

const adapter = createFlatGroupAdapter<Row, string, string>({
  getItemId: (row) => row.id,
  getGroupId: (row) => row.groupId,
  setGroupId: (row, groupId) => ({ ...row, groupId }),
});

const result = moveGroup(rows, { groupId: 'g1', toBlockIndex: 3 }, { adapter });

if (result.ok) {
  save(result.items, result.metadata);
} else {
  showError(result.reason, result.message);
}

Important exports include moveItem, moveGroup, sortWithinGroup, createGroup, dissolveGroup, insertIntoGroup, extractFromGroup, removeItem, applyOperation, projectDrop, validateStructure, normalizeGroups, normalizeGroupIds, normalizeGroupLifecycle, moveNestedNode, transferBetweenLists, and createSortableHistory.

Indexes are explicit:

  • flatIndex: position in the application array;
  • blockIndex: position among standalone items and whole groups;
  • groupItemIndex: position inside one group.

Constraints and permissions

Built-in structural checks run before custom constraints. A constraint returns { allowed: true } or a structured rejection:

const cannotMovePublished = ({ activeItem }) =>
  activeItem?.status === 'published'
    ? { allowed: false, reason: 'published-item', message: 'Published rows are locked.' }
    : { allowed: true };

<GroupSortable constraints={[cannotMovePublished]} />;

Fine-grained props include canDragItem, canDragGroup, canDropBefore, canDropAfter, canDropIntoGroup, canCreateGroup, canMoveOutOfGroup, and canMoveBetweenGroups. onDropRejected receives the reason, message, active descriptor, target, active item, and target group ID.

Cross-list movement and copy mode

Use GroupSortableBoard when lists need one shared drag context. It supports same-list sorting, move/copy transfers, groups as blocks, and truly empty destination lists.

<GroupSortableBoard
  lists={lists}
  getItemId={(field) => field.id}
  getGroupId={(field) => field.groupId}
  setGroupId={(field, groupId) => ({ ...field, groupId })}
  transferMode={(from) => (from === 'palette' ? 'copy' : 'move')}
  cloneItem={(field) => ({ ...field, id: crypto.randomUUID() })}
  onListsChange={(nextLists) => setLists(nextLists)}
  renderList={({ list, children, isOver }) => (
    <section data-over={isOver || undefined}>
      <h2>{list.id}</h2>
      {children}
    </section>
  )}
  renderItem={renderField}
/>

Business item and group IDs must be unique across a board. Copy functions must produce new IDs. The lower-level transferBetweenLists helper is useful for Redux reducers and server-side command handling.

React Hook Form

The optional bridge reorders with useFieldArray.move and patches only changed group fields with setValue. It does not call replace for a reorder.

import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useRHFGroupSortable } from 'react-group-sortable/react-hook-form';

const { control, setValue } = useForm<FormValues>({ defaultValues });
const { fields, move } = useFieldArray({ control, name: 'workouts' });
const values = useWatch({ control, name: 'workouts' });

const bridge = useRHFGroupSortable<Workout, string, number, FormValues>({
  fields,
  values,
  name: 'workouts',
  groupFieldName: 'superSetId',
  getItemId: (item) => item.id,
  getGroupId: (item) => item.superSetId,
  move,
  setValue,
});

<GroupSortable
  items={bridge.items}
  getRenderKey={bridge.getRenderKey}
  onItemsChange={bridge.onItemsChange}
  // ...the same identity, grouping, rules and render props
/>;

Always pass watched/current values. RHF's generated field key and your business ID have separate jobs; the bridge supports a domain property named id even when RHF uses its default generated field.id. See the RHF guide.

Inputs and drag handles

The default activator is an explicit handle. Inputs, textareas, selects, buttons, links, and contenteditable descendants do not accidentally start whole-row dragging. Customize detection with isInteractiveElement or opt into dragActivator="row".

DragHandle applies touch-action: none so real touch gestures reach the pointer sensor. If you spread sortable.dragHandleProps onto your own element, apply the same CSS yourself.

Keyboard and accessibility

Every DragHandle is a native button with dnd-kit ARIA attributes:

  • Space or Enter picks up and drops;
  • arrow keys move to the next valid geometric target;
  • Escape cancels without changing data;
  • live regions announce pickup, movement, invalid targets, drop, and cancellation.

Use announcements to localize instructions and messages. Locked handles expose aria-disabled="true". See accessibility.

Touch, mouse, RTL, grids, and scrolling

Pointer and keyboard sensors are enabled together. The default activation distance is 6px. activationConstraint can use either distance or delay/tolerance. Dynamic rectangles are measured throughout a drag. Nested scroll containers are prioritized over window; pass autoScroll={false} or dnd-kit's AutoScrollOptions to customize it.

Set dir="rtl" for RTL horizontal keyboard direction. orientation="horizontal" and layout="grid" select the matching sortable strategy. Variable-size rectangle collision is used; see known grid and virtualization limits before using complex masonry layouts.

Overlay, placeholder, and animation

renderDragOverlay renders an active preview. renderPlaceholder can replace the active content while its wrapper reserves the measured width and height—including the full height of a group.

<GroupSortable
  renderDragOverlay={({ active }) => <Preview id={active.id} />}
  renderPlaceholder={({ type, dimensions }) => (
    <div style={{ height: dimensions?.height }} aria-hidden="true" data-type={type} />
  )}
  dropAnimation={{ duration: 180, easing: 'ease-out' }}
/>

Headless state is also exposed through data-sortable-* attributes for CSS animation.

Controlled updates and persistence

Application data is authoritative. commitMode="drop" emits one accepted transformation on drop; "continuous" emits each distinct valid projection. Persist metadata.operation or the returned array after onItemsChange. If external state removes the active item/group, the hook cancels safely. Other external changes are resolved again by ID rather than stale captured indexes.

Next.js and SSR

The package does not read browser globals during module initialization. Core can be imported in server code. Interactive components belong in a client component:

'use client';

import { GroupSortable } from 'react-group-sortable';

An actual renderToString test and a clean installed-consumer SSR smoke test are part of the release gate.

Performance

Core operations are O(n) and use ID maps plus visual blocks. On the recorded benchmark machine, moving one item among 1,000 standalone items averaged 0.165ms; moving one group among 500 two-item groups averaged 1.006ms. These are local measurements, not universal latency guarantees.

The current bundle closures are approximately 10.7KiB gzip for core, 20.0KiB gzip for the React entry including core, and 1.6KiB gzip for the RHF entry. Runtime peers/dependencies are external. CI enforces larger regression budgets. See benchmarks.

Troubleshooting

  • Duplicate ID: ensure every item ID is stable and unique. Never use the current array index.
  • Split group: members with one flat group ID must be contiguous. Call normalizeGroups explicitly when repairing imported data.
  • Membership operation rejected: provide an immutable setGroupId implementation.
  • Touch scrolls instead of dragging: use DragHandle, or set touch-action: none on a custom activator.
  • Input starts a drag: keep the default handle activator or extend isInteractiveElement.
  • RHF state moves to another row: use bridge.getRenderKey; do not confuse a business ID with RHF's generated field key.
  • A controlled drag appears to revert: commit the exact nextItems callback result and avoid rebuilding IDs during render.
  • Virtualized target cannot be reached: unmounted DOM rows cannot participate in rectangle collision; expose estimated/off-screen targets in a custom headless integration.

API and documentation

Package exports:

import {
  GroupSortable,
  GroupSortableBoard,
  DragHandle,
  useGroupSortable,
} from 'react-group-sortable';
import { moveGroup, createGroup, projectDrop } from 'react-group-sortable/core';
import { useRHFGroupSortable } from 'react-group-sortable/react-hook-form';

Development

npm ci
npm run dev
npm run check
npm run test:e2e
npm run test:fuzz
npm run test:a11y
npm run benchmark
npm run check:publish

The package is ESM-only, ships declarations and source maps, and is licensed under MIT.

About

Headless, group-aware drag-and-drop sorting for React with deterministic immutable transformations.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages