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

핵심 포인트

  1. 렌더는 local state, 캐시는 부수 효과setQueryData 결과를 렌더에 직접 쓰면 flicker. setState는 dnd-kit이 의존하는 React commit 사이클을 탐.
  2. invalidate race 방지isReorderingRef 없이는 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local state를 덮어씀. 이게 또 다른 튕김으로 보일 수 있음. onSettled에서 플래그 내리고 invalidate.
  3. 서버 응답으로 정렬 결과를 받으면 더 깔끔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 v6.3.1 기준
  • v10+는 OptimisticSortingPlugin이 기본 활성화되어 메커니즘이 달라짐. 마이그레이션 시 재검증 필요