dnd-kit Sortable + API 연동 시 drop 직후 튕김(flicker) 문제
dnd-kit v6.x + TanStack Query 조합에서 드래그로 순서 변경 후 mutation을 트리거하면, drop 직후 한 프레임 동안 아이템이 원래 위치로 돌아갔다가 새 위치로 점프하는 현상.
증상
onDragEnd에서setQueryData로 캐시를 즉시 업데이트 (낙관적 업데이트)- 이론상 동기 업데이트라 즉시 새 순서로 렌더되어야 함
- 실제로는 drop 시점에 1~2 프레임 동안 원래 위치로 보였다가 새 위치로 settle
- DragOverlay를 쓰면 더 두드러짐
원인
dnd-kit 자체의 알려진 동작이며, 본인 코드 문제가 아님.
- dnd-kit#833 — DragOverlay의 drop 애니메이션이 destination position을 ASAP로 측정. reorder가 적용되기 전 1~2프레임을 catch해서 원래 위치로 애니메이션 후 점프
- dnd-kit#921 — react-query 캐시를 직접 업데이트하면 sorting이 flicker. 의미 없는 React state를 같이 set하면 해결되는 것처럼 보임 → dnd-kit이 drop 애니메이션 flush에 React state lifecycle에 의존한다는 추정
- dnd-kit Discussions#1522 — v6/v8에서도 동일 증상 재현. 답변자도 결국 별도 local state로 우회
핵심: dnd-kit의 측정 사이클 ↔ React Query 캐시 업데이트 사이클이 불일치. 캐시는 동기 업데이트지만 dnd-kit이 의존하는 commit phase를 타지 않음.
해결 패턴
렌더의 source-of-truth를 캐시가 아니라 local state로 둔다. 캐시 업데이트는 부수 효과(서버 동기화)로 격리.
function SortableList() {
const { data: serverItems = [] } = useQuery({
queryKey: ['items'],
queryFn: fetchItems,
})
// 렌더용 source of truth
const [items, setItems] = useState<Item[]>(serverItems)
// 서버 데이터가 바뀌면 동기화 (단, 드래그 직후 race 가드)
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 = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.findIndex((i) => i.id === active.id)
const newIndex = items.findIndex((i) => i.id === over.id)
const next = arrayMove(items, oldIndex, newIndex)
isReorderingRef.current = true
setItems(next) // 1) local state 먼저 (dnd-kit이 이걸 보고 측정)
reorderMutation.mutate(next) // 2) 서버 동기화는 별개
}
return (
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i.id)}>
{items.map((item) => (
<SortableItem key={item.id} item={item} />
))}
</SortableContext>
</DndContext>
)
}
핵심 포인트
- 렌더는 local state, 캐시는 부수 효과 —
setQueryData결과를 렌더에 직접 쓰면 flicker. setState는 dnd-kit이 의존하는 React commit 사이클을 탐. - invalidate race 방지 —
isReorderingRef없이는 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local state를 덮어씀. 이게 또 다른 튕김으로 보일 수 있음.onSettled에서 플래그 내리고 invalidate. - 서버 응답으로 정렬 결과를 받으면 더 깔끔 —
mutationFn이 새 순서 반환 →onSuccess에서setItems(data)직접 동기화하고 invalidate 제거. race 가장 없음.
추가 점검 포인트
Key/ID 안정성
SortableContext items={...}, 자식의key,useSortable({ id })모두 같은 stable id 사용- 인덱스를 key로 쓰면 dnd-kit과 거의 항상 충돌
DragOverlay 사용 시
- 위 패턴으로도 잔여 flicker가 있으면
dropAnimation={null}또는 duration 단축 - drop animation measure 타이밍이 워낙 빠르므로 (#833) 애니메이션 자체를 줄이는 게 가장 확실
- 진단용으로 한 번
dropAnimation={null}적용해서 튕김이 사라지면 #833 케이스 확정
Mutation 응답 처리
- 백엔드가 update 직후 GET에서 옛 데이터 반환할 가능성이 있으면 mutation 응답 →
setQueryData명시 동기화 권장 invalidateQueries만 호출하면 race condition 재발 가능
관련 링크
- dnd-kit#833 — Asynchronous reordering and drop animation
- dnd-kit#921 — Sorting is not working as expected with react-query
- dnd-kit Discussions#1522 — React Query with DnD Kit: Item Goes Back to Original Position for a Split Second on Drop
버전 노트
- 본 메모는 dnd-kit v6.3.1 기준
- v10+는
OptimisticSortingPlugin이 기본 활성화되어 메커니즘이 달라짐. 마이그레이션 시 재검증 필요