dnd-kit Sortable + react-query에서 drop 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다. 1~2프레임짜리 튕김이고, DragOverlay를 쓰면 더 두드러진다. onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 그대로다.

왜 그런가

dnd-kit의 drop 애니메이션은 놓는 즉시 목적지를 잰다. 그 순간 화면이 아직 옛 순서면 옛 자리를 목적지로 재고, 거기로 애니메이션한 다음 점프한다.1

그러니 문제는 새 순서가 언제 화면에 닿느냐다. 두 경로의 시점이 다르다.

  • onDragEnd 안에서 부른 setState — 그 이벤트 처리 안에서 반영된다.
  • queryClient.setQueryData — 캐시는 즉시 바뀌지만, 구독자에게 알리는 건 react-query의 notifyManager를 거친다. 기본 스케줄러가 setTimeout(fn, 0)이라 리렌더가 다음 매크로태스크로 밀린다.2

캐시는 바로 바뀌는데 화면은 한 틱 뒤에 바뀐다. 그 사이에 dnd-kit이 잰다.

그래서 이렇게 한다

렌더의 source-of-truth를 local state에 두고, 캐시 갱신은 서버 동기화 부수효과로 분리한다.

function SortableList() {
  const { data: serverItems = [] } = useQuery({ queryKey: ['items'], queryFn: fetchItems })
  const [items, setItems] = useState<Item[]>(serverItems)

  const isReorderingRef = useRef(false)
  useEffect(() => {
    if (!isReorderingRef.current) setItems(serverItems)
  }, [serverItems])

  const reorderMutation = useMutation({
    mutationFn: reorderItems,
    onSettled: () => {
      isReorderingRef.current = false
      queryClient.invalidateQueries({ queryKey: ['items'] })
    },
  })

  const handleDragEnd = ({ active, over }: DragEndEvent) => {
    if (!over || active.id === over.id) return
    const next = arrayMove(items, idxOf(active.id), idxOf(over.id))
    isReorderingRef.current = true
    setItems(next)
    reorderMutation.mutate(next)
  }
  // <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...
}

주의:

  • invalidate raceisReorderingRef 없으면 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local을 덮는다(또 다른 튕김). 서버가 새 순서를 반환한다면 onSuccess에서 바로 받아 이 가드를 통째로 없앨 수 있다.
  • stable idSortableContext·자식 key·useSortable({ id })가 전부 같은 id여야 한다. 인덱스를 key로 쓰지 않는다.
  • DragOverlay 잔여 튕김dropAnimation={null}로 없앨 수 있다. 진단도 겸한다: 이걸로 사라지면 위 원인이 맞다.

교훈

라이브러리가 렌더 직후 DOM을 재는 동작을 하면, 상태 갱신과 화면 반영 사이에 지연을 넣는 채널로 상태를 바꿀 때 어긋난다. 상태가 언제 바뀌는지가 아니라 화면이 언제 바뀌는지가 기준이다.

v6.3.1 기준. v10+는 OptimisticSortingPlugin이 기본 활성화라 기제가 다르다 → 재검증 필요.

Footnotes

  1. “the drop animation for DragOverlay measures the destination position ASAP, and so it catches the 1 or 2 frames before my reorder is applied, and the item animates back to its original position.” (dnd-kit #833)

  2. notifyManager.schedule“schedules a function to be run on the next batch. By default, the batch is run with a setTimeoutsetSchedulerqueueMicrotask·requestAnimationFrame 으로 바꿀 수 있다. (notifyManager — TanStack Query)