React API

Components, props, refs, and state patterns for @snap-engine/snapsort-react.

The React package wraps SnapSort’s core objects in React components. Use this page when React owns the rendered arrays and SnapSort should provide drag, drop, ordering, and animation behavior.

Install

npm install @snap-engine/core @snap-engine/snapsort @snap-engine/snapsort-react react react-dom

react and react-dom are peer dependencies of @snap-engine/snapsort-react.

Import

import {
  Engine,
  Container,
  Item,
  Handle,
  useSnapSortAwaitMutation,
} from "@snap-engine/snapsort-react";

Container and Item are the only components — every container picks its drag behavior with a mode config field.

Import core types when you need typed callbacks or refs:

import type { Engine as CoreEngine } from "@snap-engine/core";
import type {
  CanDropEvent,
  Container as SnapSortContainer,
  ContainerConfig,
  DragEndEvent,
  DragStartEvent,
  DropTargetChangeEvent,
  Item as SnapSortItem,
  ItemInsertEvent,
  ItemMoveEvent,
  ItemSnapshotMetadata,
} from "@snap-engine/snapsort";

The core Container/Item classes are aliased here (SnapSortContainer/SnapSortItem) to avoid colliding with the React component names of the same name.

Engine

Engine creates or uses a core Engine, assigns it to the rendered DOM root, installs a collision engine, and provides it through React context.

<Engine id="tasks" style={{ minHeight: 320 }}>
  <Container config={{ direction: "column", groupID: "tasks" }}>
    <Item metadata={{ itemId: "task-1" }}>Task 1</Item>
  </Container>
</Engine>

Engine Props

type EngineProps = {
  children: ReactNode;
  className?: string;
  debug?: boolean;
  engine?: CoreEngine | null;
  id?: string;
  style?: CSSProperties;
};
PropDefaultDescription
childrenRequiredSnapSort containers and app UI.
classNameundefinedExtra class string applied to the engine root.
debugfalseEnables the SnapEngine debug renderer while true.
enginenullOptional existing @snap-engine/core engine instance.
id"snap-canvas"ID applied to the engine root element.
styleundefinedExtra inline styles merged onto the engine root.

Use useSnapSortEngine() inside descendants when you need the current core engine.

Modes

Every container is created from the same Container component; pick its drag behavior with config.mode. mode defaults to "euclidean".

ModeBest for
"euclidean" (default)Lists, boards, and general reordering.
"progressive"Sentence builders, token banks, and wrapped variable-width rows.
"insertion"File trees, outlines, and other drop-marker UIs where the placeholder is a line instead of a full item.

Item never needs a mode — the same Item works under a container of any mode.

<Container config={{ direction: "column", groupID: "tasks" }}>
  <Item metadata={{ itemId: "task-1" }}>Task 1</Item>
</Container>

<Container config={{ mode: "progressive", direction: "row", groupID: "sentence" }}>
  <Item metadata={{ itemId: "word-1" }}>hello</Item>
</Container>

<Container config={{ mode: "insertion", direction: "column", groupID: "outline" }}>
  <Item metadata={{ itemId: "section-1" }}>Section 1</Item>
</Container>

Mode is a property of the whole drag tree, resolved from the root container when a drag starts — nest containers of the same mode.

State Backed Lists

When React owns the item arrays, update state from callbacks.onItemMove. The React adapter wraps mutation callbacks in flushSync and supplies an awaitMutation callback by default, so SnapSort can read committed DOM after React updates.

onItemMove is preferred over the lower-level onItemInsert whenever a drop is really “move this item from one position to another” — it hands you both the source and destination as one event instead of making you reconstruct the move from an insert alone.

import { useCallback, useState } from "react";
import { Engine, Container, Item } from "@snap-engine/snapsort-react";
import type { ItemMoveEvent } from "@snap-engine/snapsort";

type Task = { id: string; label: string };
type Column = { id: string; label: string; tasks: Task[] };

const initialColumns: Column[] = [
  {
    id: "todo",
    label: "To Do",
    tasks: [
      { id: "task-1", label: "Sketch layout" },
      { id: "task-2", label: "Wire behavior" },
    ],
  },
  {
    id: "done",
    label: "Done",
    tasks: [{ id: "task-3", label: "Create branch" }],
  },
];

export function TaskBoard() {
  const [columns, setColumns] = useState(initialColumns);

  const moveTask = useCallback((taskId: string, columnId: string, index: number) => {
    setColumns((currentColumns) => {
      let movedTask: Task | null = null;

      const withoutTask = currentColumns.map((column) => {
        const sourceIndex = column.tasks.findIndex((task) => task.id === taskId);
        if (sourceIndex === -1) return column;

        const nextTasks = column.tasks.slice();
        [movedTask] = nextTasks.splice(sourceIndex, 1);
        return { ...column, tasks: nextTasks };
      });

      if (!movedTask) return currentColumns;

      return withoutTask.map((column) => {
        if (column.id !== columnId) return column;

        const nextTasks = column.tasks.slice();
        nextTasks.splice(Math.max(0, Math.min(index, nextTasks.length)), 0, movedTask);
        return { ...column, tasks: nextTasks };
      });
    });
  }, []);

  const handleItemMove = useCallback(
    (event: ItemMoveEvent) => {
      const itemId = event.itemMetadata.itemId;
      const columnId = event.to.containerMetadata.columnId;

      if (typeof itemId !== "string" || typeof columnId !== "string") return;
      moveTask(itemId, columnId, event.to.index);
    },
    [moveTask],
  );

  return (
    <Engine id="task-board">
      <Container config={{ direction: "row", name: "board-root", noDrop: true }}>
        {columns.map((column) => (
          <Container
            key={column.id}
            metadata={{ columnId: column.id }}
            config={{
              direction: "column",
              groupID: "tasks",
              callbacks: {
                onItemMove: handleItemMove,
              },
            }}
          >
            <h2>{column.label}</h2>

            {column.tasks.map((task) => (
              <Item key={task.id} metadata={{ itemId: task.id }}>
                <article>{task.label}</article>
              </Item>
            ))}
          </Container>
        ))}
      </Container>
    </Engine>
  );
}

Use callbacks.onItemRemove when container.removeItem(id) or your own controls should also remove data from React state. Drag moves and moveItem() are driven by onItemMove when it’s defined, and fall back to onItemInsert on the destination container otherwise.

Mutation Timing

The React adapter handles the framework boundary for component callbacks:

  • onItemInsert, onItemRemove, onGhostInsert, and onGhostRemove are wrapped in flushSync.
  • callbacks.awaitMutation defaults to useSnapSortAwaitMutation().
  • State updates in SnapSort callbacks should be normal synchronous setState calls.

Do not put SnapSort-driven state updates behind startTransition, timers, animation frames, or async promise chains. SnapSort needs the DOM committed before it continues from framework mutation into layout reads.

useSnapSortAwaitMutation() is exported for advanced cases where you wire core objects directly or want to pass an explicit awaitMutation callback:

const awaitMutation = useSnapSortAwaitMutation();

<Container
  config={{
    direction: "column",
    groupID: "tasks",
    callbacks: {
      onItemMove: handleItemMove,
      awaitMutation,
    },
  }}
/>

Imperative Moves

Container components forward refs to their core container object. Call moveItem(itemId, targetContainer, index) with the same ID used in metadata.itemId.

import { useRef } from "react";
import { Engine, Container, Item } from "@snap-engine/snapsort-react";
import type { Container as SnapSortContainer } from "@snap-engine/snapsort";

export function ClickMove() {
  const source = useRef<SnapSortContainer>(null);
  const target = useRef<SnapSortContainer>(null);

  function moveToTarget(id: string) {
    if (!source.current || !target.current) return;
    source.current.moveItem(id, target.current, target.current.numberOfItems);
  }

  return (
    <Engine id="click-move">
      <Container ref={source} config={{ direction: "column", groupID: "cards" }}>
        <Item metadata={{ itemId: "card-1" }}>
          <button type="button" onClick={() => moveToTarget("card-1")}>
            Move card
          </button>
        </Item>
      </Container>

      <Container ref={target} config={{ direction: "column", groupID: "cards" }} />
    </Engine>
  );
}

When your list is backed by React state, keep the same onItemMove (or onItemInsert) callback on the relevant containers so imperative moves and drag moves update the rendered arrays the same way.

Container

Props

type ContainerProps = {
  children?: ReactNode;
  className?: string;
  config: ContainerConfig;
  containerObject?: SnapSortContainer | null;
  locked?: boolean;
  metadata?: Record<string, unknown>;
  style?: CSSProperties;
};
PropDefaultDescription
childrenundefinedItem and nested container content.
className""Extra class string applied to the rendered element.
configRequiredSnapSort container configuration.
containerObjectnullOptional existing core container object. When provided, the component does not own creation.
lockedtrueKeeps the container itself from being dragged when nested. Set to false for draggable nested containers.
metadata{}Metadata assigned to the container object and exposed in callback events.
styleundefinedExtra inline styles merged onto the rendered container element.

Container renders a div with snapsort-container plus a mode-specific class: snapsort-mode-euclidean, snapsort-mode-progressive, or snapsort-mode-insertion.

Config

OptionDefaultDescription
mode"euclidean"Drag/drop mode for this tree: "euclidean", "progressive", or "insertion".
strategyResolved from modeAdvanced: a custom { dropTarget, lifecycle } strategy pair, overriding mode.
direction"column"Main layout direction: "column" or "row".
groupID"default-group"Containers with the same group can exchange items.
mainAxisAlign"start"Main-axis alignment for virtual placement. Supports "start" and "center".
nameGeneratedHuman-readable container name.
animationDefault 100ms animationsReorder, drop, and click-move animation settings. Set to null to disable configured animations.
disableFlipfalseDisables FLIP movement animation.
noDropfalsePrevents the container from being a drop target. Useful for root layout containers.
dropAreafalseTreats the container as an explicit drop area for collision filtering.
callbacksDefault DOM callbacksHooks for DOM, framework state, and custom ghost behavior.

Callbacks

CallbackWhen it runs
onItemMove(event)SnapSort moves an item to a new container/index. Preferred over onItemInsert for state-backed lists — carries both from and to locations. Falls back to onItemInsert on the destination container when not defined.
onItemInsert(event)SnapSort inserts an item into a container (the primitive onItemMove falls back to).
onItemRemove(event)SnapSort removes an item through removeItem(id).
onDragStart(event)A drag is starting, before any ghost/state changes. Return false to veto the drag.
onDragEnd(event)A drag has ended and its mutation has been committed. event.destination is null when the item returned to its source or the drag was cancelled.
onDropTargetChange(event)The prospective drop container/index changed during a drag. Fires on the root container.
canDrop(event)Consulted while resolving candidates for this container; return false to reject drops into it for the current drag. Evaluated once per container per resolution, not once per candidate slot — keep it cheap.
onGhostInsert(event)SnapSort inserts or moves a ghost item or insertion marker.
onGhostRemove(event)SnapSort removes a ghost item or insertion marker.
createGhost(event)SnapSort needs a ghost/marker element. event.kind is "flow" (euclidean/progressive) or "marker" (insertion). Return an element, null, or void.
awaitMutation()SnapSort waits for framework-owned DOM updates before reading final layout. Defaults to useSnapSortAwaitMutation().

onItemMove, onItemInsert, onItemRemove, onGhostInsert, and onGhostRemove are wrapped in flushSync by the React adapter (see Mutation Timing above). onDragStart and canDrop are called directly (unwrapped) since their return value is needed synchronously and they can run far more often per drag.

Item

Props

type ItemProps = {
  children: ReactNode;
  className?: string;
  itemObject?: SnapSortItem | null;
  metadata?: ItemSnapshotMetadata;
  style?: CSSProperties;
};
PropDefaultDescription
childrenRequiredRendered item content.
className""Extra class string applied to the rendered element.
itemObjectnullOptional existing core item object. When provided, the component does not destroy it on unmount.
metadata{}Metadata assigned to the item object. Use metadata.itemId for stable keyed state.
styleundefinedExtra inline styles merged onto the rendered item element.

Item forwards a ref to its core item object and renders a div with snapsort-item — the same component and class work under any container mode.

Handle

Handle registers a child element as an input alias for the nearest item.

<Item metadata={{ itemId: "task-1" }}>
  <article>
    <Handle className="drag-handle">Drag</Handle>
    <span>Task 1</span>
  </article>
</Item>

Props

type HandleProps = {
  children: ReactNode;
  className?: string;
  style?: CSSProperties;
};

Handle forwards a ref to the rendered HTMLDivElement and renders a div with snapsort-handle plus the supplied className.

Subpath Exports

The package exports the full API from @snap-engine/snapsort-react, plus these subpaths:

Import pathExports
@snap-engine/snapsort-react/EngineEngine, SnapSortEngine, EngineContext, useSnapSortEngine
@snap-engine/snapsort-react/ContainerContainer components and ContainerObjectContext
@snap-engine/snapsort-react/ItemItem components and ItemObjectContext
@snap-engine/snapsort-react/HandleHandle
@snap-engine/snapsort-react/useSnapSortAwaitMutationuseSnapSortAwaitMutation