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
useFieldArrayidentity.
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.
npm install react-group-sortableReact 18 and 19 are supported. React Hook Form is an optional peer and is only needed when importing
react-group-sortable/react-hook-form.
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.
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.
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();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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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
normalizeGroupsexplicitly when repairing imported data. - Membership operation rejected: provide an immutable
setGroupIdimplementation. - Touch scrolls instead of dragging: use
DragHandle, or settouch-action: noneon 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
nextItemscallback 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.
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';- API reference
- Architecture
- Accessibility
- React Hook Form
- Touch/manual device testing
- Known limitations
- Publishing
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:publishThe package is ESM-only, ships declarations and source maps, and is licensed under MIT.