---
type: snippet
kind: Troubleshooting
tags: ['dnd-kit', 'tanstack-query', 'optimistic-update', 'race-condition', 'react', 'debug']
status: release
ctime: 2026-06-07
mtime: 2026-08-21
generated: { by: claude/opus-5, at: 2026-08-21T01:00:00Z }
verified: { by: claude/opus-5, at: 2026-08-21T00:00:00Z }
sources:
  - id: dnd-kit-833
    resource: https://github.com/clauderic/dnd-kit/issues/833
    title: "dnd-kit #833 — drop animation measures destination ASAP"
  - id: dnd-kit-921
    resource: https://github.com/clauderic/dnd-kit/issues/921
    title: "dnd-kit #921"
  - id: tanstack-notify-manager
    resource: https://tanstack.com/query/latest/docs/reference/notifyManager
    title: "notifyManager — TanStack Query"
  - id: dnd-kit-1522
    resource: https://github.com/clauderic/dnd-kit/discussions/1522
    title: "dnd-kit Discussions #1522"
---

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

## 왜 그런가

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

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

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

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

## 그래서 이렇게 한다

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

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

## 교훈

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

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

[^dnd-kit-833]: *"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](https://github.com/clauderic/dnd-kit/issues/833))

[^notify-manager]: `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](https://tanstack.com/query/latest/docs/reference/notifyManager))
