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 race —
isReorderingRef없으면 mutation 직후 refetch가 옛 순서를 들고 와useEffect가 local을 덮는다(또 다른 튕김). 서버가 새 순서를 반환한다면onSuccess에서 바로 받아 이 가드를 통째로 없앨 수 있다. - stable id —
SortableContext·자식key·useSortable({ id })가 전부 같은 id여야 한다. 인덱스를 key로 쓰지 않는다. DragOverlay잔여 튕김 —dropAnimation={null}로 없앨 수 있다. 진단도 겸한다: 이걸로 사라지면 위 원인이 맞다.
교훈
라이브러리가 렌더 직후 DOM을 재는 동작을 하면, 상태 갱신과 화면 반영 사이에 지연을 넣는 채널로 상태를 바꿀 때 어긋난다. 상태가 언제 바뀌는지가 아니라 화면이 언제 바뀌는지가 기준이다.
v6.3.1 기준. v10+는
OptimisticSortingPlugin이 기본 활성화라 기제가 다르다 → 재검증 필요.
Footnotes
-
“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) ↩
-
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) ↩