dnd-kit drop 직후 튕김 — setQueryData 는 화면을 한 틱 늦게 바꾼다

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

① 최소 재현: Sortable 리스트에 useQuery 를 붙이고 CDP Input.dispatchMouseEvent 로 드래그한다. ② 숫자로 잡기: requestAnimationFrame 마다 DragOverlay·아이템의 top 을 기록하는 프로브를 먼저 걸고, drop 뒤 최종 배치와 다른 프레임이 있으면 튕김이다. ③ 가설을 하나씩 끈다: dropAnimation={null} 로 사라지면 drop 애니메이션이 즉시 재는 탓, setState 와 setQueryData 를 번갈아 돌려 후자만 튕기면 캐시 알림 경로, notifyManager.setScheduler(queueMicrotask) 로 사라지면 원인 확정. ④ 고친다: 렌더는 local state, 캐시 갱신은 부수효과로. ⑤ 회귀 확인: ② 와 같은 프로브를 refetch 뒤까지 돌린다. 다시 튕기면 refetch 가 옛 순서를 덮음 → isReorderingRef 가드, id 흔들림 → stable id, DragOverlay 만 남음 → dropAnimation={null}.

③에서 확정되는 원인 — drop 애니메이션은 놓는 즉시 목적지를 재는데1, setQueryData의 리렌더는 notifyManager의 setTimeout(0)을 거쳐 한 틱 늦다.2

drop 하면 onDragEnd 가 돌고, dnd-kit 의 drop 애니메이션은 곧바로(ASAP) 목적지를 잰다. setState 로 바꾸면 그 이벤트 처리 안에서 새 순서가 렌더되어, 잴 때 이미 새 자리라 새 자리로 애니메이션한다. setQueryData 로 바꾸면 캐시는 즉시 바뀌지만 구독자 알림은 notifyManager 의 기본 스케줄러 setTimeout(0) 을 거쳐 다음 매크로태스크에 리렌더된다. 그 사이 dnd-kit 이 옛 순서를 재서 옛 자리로 애니메이션하고, 리렌더 순간 새 자리로 점프한다 — 1~2프레임 튕김. 캐시는 바로, 화면은 한 틱 뒤다. 눈금은 비례가 아니다.

④의 코드:

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)}> ...
}

라이브러리가 렌더 직후 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 setTimeout” — setScheduler 로 queueMicrotask·requestAnimationFrame 으로 바꿀 수 있다. (notifyManager — TanStack Query) ↩