dnd-kit Sortable + react-query에서 drop 직후 튕김(flicker) = 렌더를 react-query 캐시로 하면, dnd-kit의 drop 측정이 의존하는 React commit phase(setState)를 안 타기 때문. → 렌더는 local state, 캐시는 부수효과로 격리.

현상

drop 직후 1~2프레임 동안 아이템이 원래 위치로 돌아갔다가 새 위치로 점프. onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 발생하고, DragOverlay에서 더 두드러진다. (내 코드 문제 아님 — dnd-kit 알려진 동작)

원인

dnd-kit의 drop 애니메이션이 destination을 ASAP 측정하는데, react-query 캐시 직접 업데이트는 dnd-kit이 의존하는 React commit 사이클을 안 탄다 → reorder 적용 전 프레임을 catch해 원위치로 애니메이션 후 점프. (#833·#921·#1522 — 답변자들도 결국 별도 local state로 우회)

해결

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

function SortableList() {
  const { data: serverItems = [] } = useQuery({ queryKey: ['items'], queryFn: fetchItems })
  const [items, setItems] = useState<Item[]>(serverItems) // 렌더용 source of truth

  // 드래그 직후 refetch가 옛 순서로 덮어쓰는 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 = ({ 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)               // 1) local state 먼저 — dnd-kit이 이걸 보고 측정
    reorderMutation.mutate(next) // 2) 서버 동기화는 별개
  }
  // <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...
}

주의:

  • invalidate raceisReorderingRef 없으면 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local을 덮음(또 다른 튕김). 가장 깔끔한 변형: mutationFn이 새 순서를 반환 → onSuccess에서 setItems(data), invalidate 제거.
  • stable idSortableContext·자식 key·useSortable({ id })가 모두 같은 id. 인덱스 key 금지.
  • DragOverlay 잔여 flickerdropAnimation={null}로 제거(겸 진단: 사라지면 #833 확정).

교훈

라이브러리가 React commit lifecycle에 암묵적으로 의존하면, 그 옆 채널(캐시 직접 변경)로 상태를 바꿀 때 어긋난다 — 렌더의 source-of-truth를 commit을 타는 한 곳으로 모은다.

참고