Tech Verse Logo
Enable dark mode
React Drag and Drop with dnd-kit and Laravel

React Drag and Drop with dnd-kit and Laravel

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

Why React DnD and HTML5 Drag and Drop Fail

HTML5 native drag and drop API was designed in a different era. It relies on DOM mutation events that don't play nicely with React's virtual DOM. Libraries like react-dnd force you into complex drag sources and drop targets boilerplate. React-beautiful-dnd was a community default for years, but Atlassian deprecated it, and it breaks under React 18 strict mode and React 19 concurrent rendering.

If you want accessible react drag and drop that doesn't melt your UI when a user drags an item across 20 elements, @dnd-kit is the right pick. It separates drag logic from render logic, weighs under 10kb modularly, and supports screen readers and keyboard navigation right out of the box.

Setting Up dnd-kit with React 19 and Next.js 16

When implementing react drag and drop in Next.js 16 with React 19, hydration mismatches will bite you immediately if you render drag containers on the server. @dnd-kit generates dynamic IDs for accessible ARIA attributes. If the server generates one ID and the client generates another during hydration, React 19 throws hydration error #418.

You can solve this by ensuring your drag component mounts after the initial client render, or by supplying explicit id props to your sensors and droppable contexts.

Configuring Mouse, Touch, and Keyboard Sensors

Default pointer events in @dnd-kit react instantly on mouse down. That ruins click events on child elements like edit buttons or links inside your list items. You need explicit activation constraints so a click stays a click while a drag requires intent.

Here is a production-ready Client Component in Next.js 16 using @dnd-kit/core v6.3.1 and @dnd-kit/sortable v8.0.0:

'use client';

import React, { useState, useEffect } from 'react';
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent
} from '@dnd-kit/core';
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
  useSortable
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

interface Task {
  id: number;
  title: string;
  position: number;
}

function SortableItem({ task }: { task: Task }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging
  } = useSortable({ id: task.id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
    padding: '12px 16px',
    marginBottom: '8px',
    backgroundColor: '#ffffff',
    border: '1px solid #e5e7eb',
    borderRadius: '6px',
    cursor: 'grab',
  };

  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      <span className="font-medium">{task.title}</span>
    </div>
  );
}

export function TaskList({ initialTasks }: { initialTasks: Task[] }) {
  const [items, setItems] = useState<Task[]>(initialTasks);
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8,
      },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  async function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    if (!over || active.id === over.id) return;

    const oldIndex = items.findIndex((item) => item.id === active.id);
    const newIndex = items.findIndex((item) => item.id === over.id);

    const reordered = arrayMove(items, oldIndex, newIndex).map((item, index) => ({
      ...item,
      position: index + 1,
    }));

    setItems(reordered);

    try {
      const response = await fetch('/api/tasks/reorder', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          orders: reordered.map((item) => ({ id: item.id, position: item.position })),
        }),
      });

      if (!response.ok) {
        setItems(initialTasks);
        console.error('Failed to sync reordered tasks to backend');
      }
    } catch (err) {
      setItems(initialTasks);
      console.error('Network error persisting order:', err);
    }
  }

  if (!mounted) return null;

  return (
    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
        <div className="max-w-md mx-auto p-4">
          {items.map((task) => (
            <SortableItem key={task.id} task={task} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

Persisting Reordered Items to Laravel 12

Updating drag and drop order in a database is where many applications collapse under load. The bad pattern is looping over every item in Laravel and executing individual SQL queries: updating 50 rows in 50 roundtrips takes about 250ms total. Don't do that.

Instead, pass an array of key-value pairs like [{id: 1, position: 1}, {id: 2, position: 2}] and execute a bulk update inside a single SQL query using a CASE statement within a database transaction.

Building the Laravel 12 Endpoint

Here is how to handle the backend payload in Laravel 12 on PHP 8.3 cleanly. We wrap the batch operation in a single database transaction and build a single query with bindings to prevent SQL injection vulnerabilities.

<?php

namespace App\Http\Controllers;

use App\Models\Task;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class TaskReorderController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'orders' => ['required', 'array'],
            'orders.*.id' => ['required', 'integer', 'exists:tasks,id'],
            'orders.*.position' => ['required', 'integer', 'min:1'],
        ]);

        $orders = $validated['orders'];
        if (empty($orders)) {
            return response()->json(['message' => 'No updates provided'], 400);
        }

        $ids = array_column($orders, 'id');
        $cases = [];
        $bindings = [];

        foreach ($orders as $item) {
            $cases[] = 'WHEN id = ? THEN ?';
            $bindings[] = $item['id'];
            $bindings[] = $item['position'];
        }

        $idsPlaceholder = implode(',', array_fill(0, count($ids), '?'));
        $bindings = array_merge($bindings, $ids);

        $sql = "UPDATE tasks SET position = CASE " . implode(' ', $cases) . " END WHERE id IN ($idsPlaceholder)";

        DB::transaction(function () use ($sql, $bindings) {
            DB::update($sql, $bindings);
        });

        return response()->json(['status' => 'success']);
    }
}

Accessibility That Actually Works

Most drag-and-drop implementations on the web are invisible to screen reader users and unusable for anyone relying on a keyboard. @dnd-kit solves this with built-in live region announcements for ARIA support.

When a user tab-focuses a sortable item and presses Space or Enter, @dnd-kit picks up the item. Up and Down arrow keys move the item in the list, while Space or Enter drops it in place. Escape cancels the operation and resets the position.

To make this work reliably for users:

  • Always supply an accessible label or aria-describedby attribute explaining how keyboard drag works.
  • Ensure your interactive drag handle is a focusable HTML element like a <button> if only a portion of the row is draggable.
  • Don't override keydown listeners without forwarding event handling back to @dnd-kit keyboard sensors.

Production Gotchas: Hydration, Strict Mode, and Latency

There are three common traps when running @dnd-kit in Next.js 16 and React 19 production apps.

1. Hydration Mismatches in Server Components

Because @dnd-kit uses internal counters for auto-generating container and item accessibility IDs, rendering DndContext directly inside Next.js Server Components causes React 19 hydration error #418. Keep your DndContext strictly inside 'use client' components, and defer rendering until after mount if you see ID drift across SSR reloads.

2. Optimistic Rolls and Error Recovery

Network latency will make your UI feel sluggish if you wait for Laravel's response before updating DOM positions. Update the client state immediately with arrayMove. If the HTTP POST request fails due to network drop or validation errors, roll back to initialTasks and show a toast. Never leave the UI state out of sync with your persistent storage.

3. Touch Device Scrolling Conflicts

On iOS and Android, touch drag often triggers native page scrolling instead of moving the list item. Setting touch-action: none in CSS on the draggable handle tells the mobile browser to surrender gesture control to pointer events, fixing erratic drags on mobile browsers.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI