You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Tanstack Table React行拖拽排序功能失效问题排查

TanStack Table v8 拖拽排序功能无法工作的问题排查与修复

我正在React.js中开发一款支持行选择、拖拽排序(drag&drop)、筛选、分页等功能的轻量级表格,用于处理数百条数据。目前聚焦拖拽排序功能,但基于Tanstack Table v8的实现无法正常工作。已参考官方可行示例复刻,但仍未解决问题,请问是否遗漏了某些配置项?

我的实现代码

// https://tanstack.com/table/v8/docs/framework/react/examples/row-selection

import React from "react";
import {
  ColumnDef,
  getCoreRowModel,
  useReactTable,
  flexRender,
} from "@tanstack/react-table";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import styles from "./Table.module.css";

// Drag handle for rows
function DragHandle({ rowId }) {
  const { attributes, listeners } = useSortable({ id: rowId });

  return (
    <button
      {...attributes}
      {...listeners}
      className={styles.dragHandle}
      title="Drag to reorder"
    >
      🟰
    </button>
  );
}

// Row Component
function Row({ row }) {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({
      id: row.id,
    });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <tr
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className={styles.row}
    >
      {row.getVisibleCells().map((cell, index) => (
        <td key={cell.id} className={styles.cell}>
          {index === 0 ? <DragHandle rowId={row.id} /> : null}
          {flexRender(cell.column.columnDef.cell, cell.getContext())}
        </td>
      ))}
    </tr>
  );
}

// Table Component
function Table({ data, setData, columns }) {
  const tableColumns = React.useMemo(() => [...columns], [columns]);

  const table = useReactTable({
    data,
    columns: tableColumns,
    getCoreRowModel: getCoreRowModel(),
  });

  const handleDragEnd = (event) => {
    const { active, over } = event;

    if (!active?.id || !over?.id || active.id === over.id) {
      return;
    }

    setData((oldData) => {
      const oldIndex = oldData.findIndex((row) => row.id === active.id);
      const newIndex = oldData.findIndex((row) => row.id === over.id);

      if (oldIndex !== -1 && newIndex !== -1) {
        return arrayMove(oldData, oldIndex, newIndex);
      }

      return oldData;
    });
  };

  return (
    <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext
        items={data.map((row) => row.id)}
        strategy={verticalListSortingStrategy}
      >
        <table className={styles.table}>
          <thead>
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id} className={styles.headerRow}>
                {headerGroup.headers.map((header) => (
                  <th key={header.id} className={styles.headerCell}>
                    {flexRender(
                      header.column.columnDef.header,
                      header.getContext()
                    )}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <Row key={row.id} row={row} />
            ))}
          </tbody>
        </table>
      </SortableContext>
    </DndContext>
  );
}

export default Table;

官方示例代码

import React, { CSSProperties } from 'react'
import ReactDOM from 'react-dom/client'

import './index.css'

import {
  ColumnDef,
  Row,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from '@tanstack/react-table'
import { makeData, Person } from './makeData'

// needed for table body level scope DnD setup
import {
  DndContext,
  KeyboardSensor,
  MouseSensor,
  TouchSensor,
  closestCenter,
  type DragEndEvent,
  type UniqueIdentifier,
  useSensor,
  useSensors,
} from '@dnd-kit/core'
import { restrictToVerticalAxis } from '@dnd-kit/modifiers'
import {
  arrayMove,
  SortableContext,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable'

// needed for row & cell level scope DnD setup
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'

// Cell Component
const RowDragHandleCell = ({ rowId }: { rowId: string }) => {
  const { attributes, listeners } = useSortable({
    id: rowId,
  })
  return (
    // Alternatively, you could just set up dnd events on the rows themselves
    <button {...attributes} {...listeners}>
      🟰
    </button>
  )
}

// Row Component
const DraggableRow = ({ row }: { row: Row<Person> }) => {
  const { transform, transition, setNodeRef, isDragging } = useSortable({
    id: row.original.userId,
  })

  const style: CSSProperties = {
    transform: CSS.Transform.toString(transform), //let dnd-kit do its thing
    transition: transition,
    opacity: isDragging ? 0.8 : 1,
    zIndex: isDragging ? 1 : 0,
    position: 'relative',
  }
  return (
    // connect row ref to dnd-kit, apply important styles
    <tr ref={setNodeRef} style={style}>
      {row.getVisibleCells().map(cell => (
        <td key={cell.id} style={{ width: cell.column.getSize() }}>
          {flexRender(cell.column.columnDef.cell, cell.getContext())}
        </td>
      ))}
    </tr>
  )
}

// Table Component
function App() {
  const columns = React.useMemo<ColumnDef<Person>[]>(
    () => [
      // Create a dedicated drag handle column. Alternatively, you could just set up dnd events on the rows themselves.
      {
        id: 'drag-handle',
        header: 'Move',
        cell: ({ row }) => <RowDragHandleCell rowId={row.id} />,
        size: 60,
      },
      {
        accessorKey: 'firstName',
        cell: info => info.getValue(),
      },
      {
        accessorFn: row => row.lastName,
        id: 'lastName',
        cell: info => info.getValue(),
        header: () => <span>Last Name</span>,
      },
      {
        accessorKey: 'age',
        header: () => 'Age',
      },
      {
        accessorKey: 'visits',
        header: () => <span>Visits</span>,
      },
      {
        accessorKey: 'status',
        header: 'Status',
      },
      {
        accessorKey: 'progress',
        header: 'Profile Progress',
      },
    ],
    []
  )
  const [data, setData] = React.useState(() => makeData(20))

  const dataIds = React.useMemo<UniqueIdentifier[]>(
    () => data?.map(({ userId }) => userId),
    [data]
  )

  const rerender = () => setData(() => makeData(20))

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getRowId: row => row.userId, //required because row indexes will change
    debugTable: true,
    debugHeaders: true,
    debugColumns: true,
  })

  // reorder rows after drag & drop
  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event
    if (active && over && active.id !== over.id) {
      setData(data => {
        const oldIndex = dataIds.indexOf(active.id)
        const newIndex = dataIds.indexOf(over.id)
        return arrayMove(data, oldIndex, newIndex) //this is just a splice util
      })
    }
  }

  const sensors = useSensors(
    useSensor(MouseSensor, {}),
    useSensor(TouchSensor, {}),
    useSensor(KeyboardSensor, {})
  )

  return (
    // NOTE: This provider creates div elements, so don't nest inside of <table> elements
    <DndContext
      collisionDetection={closestCenter}
      modifiers={[restrictToVerticalAxis]}
      onDragEnd={handleDragEnd}
      sensors={sensors}
    >
      <div className="p-2">
        <div className="h-4" />
        <div className="flex flex-wrap gap-2">
          <button onClick={() => rerender()} className="border p-1">
            Regenerate
          </button>
        </div>
        <div className="h-4" />
        <table>
          <thead>
            {table.getHeaderGroups().map(headerGroup => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map(header => (
                  <th key={header.id} colSpan={header.colSpan}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody>
            <SortableContext
              items={dataIds}
              strategy={verticalListSortingStrategy}
            >
              {table.getRowModel().rows.map(row => (
                <DraggableRow key={row.id} row={row} />
              ))}
            </SortableContext>
          </tbody>
        </table>
        <pre>{JSON.stringify(data, null, 2)}</pre>
      </div>
    </DndContext>
  )
}

const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')

ReactDOM.createRoot(rootElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

问题排查与修复方案

对比你的代码和官方示例,以下几个关键配置项是你遗漏的:

1. 缺少getRowId配置项

TanStack Table默认使用数组索引作为行ID,但拖拽排序会改变行的索引位置,导致表格无法正确识别行的唯一性。

  • 修复方法:在useReactTable配置中添加getRowId,指定数据中唯一标识字段:
const table = useReactTable({
  data,
  columns: tableColumns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: row => row.id, // 新增该行
});

2. SortableContext的位置错误

官方示例中SortableContext嵌套在<tbody>内部,而你的代码将它包裹了整个<table>元素。表格DOM结构有严格层级要求,SortableContext生成的div元素会破坏表格合法结构,导致拖拽行为异常。

  • 修复方法:将SortableContext移动到<tbody>标签内部:
<tbody>
  <SortableContext
    items={data.map((row) => row.id)}
    strategy={verticalListSortingStrategy}
  >
    {table.getRowModel().rows.map((row) => (
      <Row key={row.id} row={row} />
    ))}
  </SortableContext>
</tbody>

3. 未配置拖拽传感器和轴限制

你的DndContext缺少sensors配置(识别鼠标、触摸、键盘拖拽事件)和modifiers配置(限制拖拽为垂直方向),会导致拖拽触发困难或行为不符合预期。

  • 修复方法:添加相关导入并配置:
// 导入缺失的模块
import {
  KeyboardSensor,
  MouseSensor,
  TouchSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";

// 在Table组件内部创建sensors
const sensors = useSensors(
  useSensor(MouseSensor, {}),
  useSensor(TouchSensor, {}),
  useSensor(KeyboardSensor, {})
);

// 更新DndContext配置
<DndContext
  collisionDetection={closestCenter}
  onDragEnd={handleDragEnd}
  sensors={sensors} // 新增
  modifiers={[restrictToVerticalAxis]} // 新增
>

4. 拖拽行的样式缺失关键属性

官方示例的拖拽行添加了position: relative、zIndex和opacity等样式,确保拖拽时行的视觉层级和透明度正常,避免视觉错乱。

  • 修复方法:更新Row组件的style配置:
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
  useSortable({
    id: row.id,
  });

const style = {
  transform: CSS.Transform.toString(transform),
  transition,
  opacity: isDragging ? 0.8 : 1,
  zIndex: isDragging ? 1 : 0,
  position: 'relative',
};

5. DragHandle与Row的事件冲突

你的Row组件同时绑定了useSortable的attributes和listeners,而DragHandle也绑定了相同事件,会导致事件冒泡和冲突,拖拽触发逻辑混乱。

  • 修复方法:移除Row组件上的attributes和listeners绑定,只保留DragHandle上的拖拽事件:
function Row({ row }) {
  const { setNodeRef, transform, transition, isDragging } = useSortable({
    id: row.id,
  });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.8 : 1,
    zIndex: isDragging ? 1 : 0,
    position: 'relative',
  };

  return (
    <tr
      ref={setNodeRef}
      style={style}
      className={styles.row}
    >
      {/* ... 其他代码不变 */}
    </tr>
  );
}

内容的提问来源于stack exchange,提问作者Martin

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 11:24:51