Published on

React 19 useTransition 무한 로딩 해결법

Authors

React 19로 넘어오면서 useTransition을 이용해 검색, 필터, 페이지네이션 같은 UI를 부드럽게 만들려는 시도가 많아졌습니다. 그런데 실무에서 종종 겪는 문제가 있습니다. 스피너를 isPending에 묶어두었더니 로딩이 끝나지 않고 무한히 도는 현상입니다.

이 글은 단순히 “버그인가요” 수준이 아니라, 왜 isPending이 내려오지 않는지(혹은 내려와도 다시 올라가는지)를 원인 유형별로 분해하고, React 19 및 Next.js App Router 환경에서 안전하게 고치는 패턴을 코드로 정리합니다.

관련해서 RSC 성능과 캐시 특성 때문에 로딩이 체감상 더 길어지는 케이스도 자주 엮입니다. 필요하면 Next.js 14 RSC 느림? TTFB 급증 7가지 해결, Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법도 함께 참고하면 원인 분리가 빨라집니다.

useTransition의 핵심 동작을 다시 잡기

useTransition은 “비동기 작업을 감싸는 훅”이 아닙니다. React 상태 업데이트를 transition으로 표시해 우선순위를 낮추고, 그 transition에 의해 발생한 렌더링이 완료될 때까지 isPendingtrue로 유지합니다.

즉 아래가 핵심입니다.

  • startTransition(() => setState(...)) 같은 상태 업데이트가 있어야 의미가 있습니다.
  • startTransition(async () => { await fetch() })처럼 “비동기만 실행”하면, React 관점에서 추적할 상태 업데이트가 없거나, 추적이 꼬여서 기대와 다르게 보일 수 있습니다.
  • transition 안에서 발생한 업데이트가 계속 이어지면 isPending이 내려오지 않습니다.

증상: isPending이 무한히 true로 남는 대표 패턴

아래는 실무에서 자주 나오는 “무한 로딩” 유형들입니다.

1) transition 안에서 상태 업데이트가 연쇄적으로 계속 발생

가장 흔한 케이스는 useEffect가 transition으로 바뀐 상태를 감지해서 또 상태를 바꾸고, 다시 effect가 돌면서 업데이트가 끊기지 않는 경우입니다.

'use client'

import { useEffect, useMemo, useState, useTransition } from 'react'

export default function SearchBad() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<string[]>([])
  const [isPending, startTransition] = useTransition()

  // 문제: query가 바뀌면 effect가 fetch 후 results를 바꾸고,
  // 그 결과가 또 다른 effect/계산을 유발하면서 연쇄 업데이트가 생길 수 있음
  useEffect(() => {
    let cancelled = false
    ;(async () => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
      const data = (await res.json()) as { items: string[] }
      if (!cancelled) {
        // 여기서 업데이트가 발생하면 pending은 "이 업데이트가 끝날 때까지" 유지
        // 그런데 다른 곳에서 query/results를 다시 만지면 pending이 계속 이어질 수 있음
        setResults(data.items)
      }
    })()

    return () => {
      cancelled = true
    }
  }, [query])

  const onChange = (v: string) => {
    startTransition(() => {
      setQuery(v)
    })
  }

  return (
    <div>
      <input value={query} onChange={(e) => onChange(e.target.value)} />
      {isPending ? <p>Loading...</p> : null}
      <ul>
        {results.map((r) => (
          <li key={r}>{r}</li>
        ))}
      </ul>
    </div>
  )
}

이 코드 자체가 항상 무한 로딩을 만들지는 않지만, 실제 앱에서는 다음이 추가되며 문제가 커집니다.

  • results 변경을 감지해서 URL을 업데이트하는 effect
  • URL 변경을 감지해서 다시 query를 세팅하는 effect
  • useMemo나 파생 상태 계산이 부정확한 의존성으로 또 상태 업데이트를 트리거

결과적으로 transition이 “끝나지 않는 업데이트 체인”으로 변질됩니다.

해결: transition은 “입력 상태 변경”에만, 데이터 로딩은 별도 로딩 상태로

isPending은 “UI 우선순위” 신호로 쓰고, 네트워크 로딩은 별도의 loading으로 분리하는 게 가장 안전합니다.

'use client'

import { useEffect, useState, useTransition } from 'react'

export default function SearchGood() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<string[]>([])
  const [loading, setLoading] = useState(false)
  const [isPending, startTransition] = useTransition()

  useEffect(() => {
    if (!query) {
      setResults([])
      return
    }

    const ac = new AbortController()
    setLoading(true)

    ;(async () => {
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: ac.signal,
        })
        const data = (await res.json()) as { items: string[] }
        setResults(data.items)
      } finally {
        setLoading(false)
      }
    })()

    return () => ac.abort()
  }, [query])

  const onChange = (v: string) => {
    // transition은 "입력 변경" 같은 UI 업데이트에만 사용
    startTransition(() => setQuery(v))
  }

  return (
    <div>
      <input value={query} onChange={(e) => onChange(e.target.value)} />
      {(isPending || loading) && <p>Loading...</p>}
      <ul>
        {results.map((r) => (
          <li key={r}>{r}</li>
        ))}
      </ul>
    </div>
  )
}

포인트는 명확합니다.

  • isPending은 “transition 렌더링이 밀리는 중”을 의미
  • 네트워크는 React 스케줄러가 아니라 브라우저가 처리
  • 둘을 동일한 로딩으로 취급하면 “pending이 끝나지 않는 것처럼 보이는” UX가 생깁니다

2) transition 안에서 URL 라우팅을 반복 트리거

Next.js App Router에서 검색 쿼리를 URL에 반영하려고 router.push 또는 router.replace를 transition으로 감싸는 패턴이 흔합니다.

문제는 다음 조건이 겹칠 때 발생합니다.

  • useEffectsearchParams 변화를 감지해서 상태를 다시 세팅
  • 상태 세팅이 다시 transition을 발생시켜 URL 변경
  • 미세한 문자열 차이(공백, 정렬, 기본값 포함 여부)로 인해 URL이 계속 바뀜

재현 예시

'use client'

import { useEffect, useState, useTransition } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'

export default function QuerySyncBad() {
  const router = useRouter()
  const sp = useSearchParams()
  const [q, setQ] = useState('')
  const [isPending, startTransition] = useTransition()

  // URL -> state
  useEffect(() => {
    setQ(sp.get('q') ?? '')
  }, [sp])

  // state -> URL
  useEffect(() => {
    const next = new URLSearchParams(Array.from(sp.entries()))
    next.set('q', q)

    startTransition(() => {
      router.replace(`?${next.toString()}`)
    })
  }, [q, router, sp])

  return (
    <div>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      {isPending && <p>Loading...</p>}
    </div>
  )
}

이 코드는 sp를 effect 의존성에 넣은 순간, router.replacesp를 바꾸고, sp가 바뀌니 effect가 다시 돌고… 같은 루프에 빠지기 쉽습니다.

해결 1: “정규화된 문자열” 기준으로 변경 여부를 비교

핵심은 “이미 동일한 URL이면 replace를 하지 않는다”입니다.

'use client'

import { useEffect, useMemo, useState, useTransition } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'

function normalizeQuery(q: string) {
  return q.trim()
}

export default function QuerySyncGood() {
  const router = useRouter()
  const sp = useSearchParams()
  const [q, setQ] = useState('')
  const [isPending, startTransition] = useTransition()

  const urlQ = useMemo(() => sp.get('q') ?? '', [sp])

  // URL -> state (필요할 때만)
  useEffect(() => {
    const normalized = normalizeQuery(urlQ)
    if (normalized !== q) setQ(normalized)
    // q를 의존성에 넣으면 또 루프가 될 수 있어, 의도적으로 urlQ만 감시
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [urlQ])

  // state -> URL (정규화 후 비교)
  useEffect(() => {
    const normalized = normalizeQuery(q)
    const current = normalizeQuery(urlQ)
    if (normalized === current) return

    const next = new URLSearchParams(Array.from(sp.entries()))
    if (normalized) next.set('q', normalized)
    else next.delete('q')

    startTransition(() => {
      router.replace(`?${next.toString()}`)
    })
  }, [q, urlQ, router, sp, startTransition])

  return (
    <div>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      {isPending && <p>Loading...</p>}
    </div>
  )
}

해결 2: 입력 이벤트에서만 transition으로 URL 반영

양방향 동기화 effect를 줄이는 게 더 견고합니다.

  • URL에서 초기값만 읽고
  • 이후에는 입력 이벤트에서만 URL을 갱신

이렇게 하면 “effect 루프” 자체가 사라집니다.

3) transition 안에서 async를 사용하고, 완료 시점을 착각

다음 코드는 직관적으로는 “transition 동안 fetch하고 끝나면 pending이 false”처럼 보이지만, React가 추적하는 것은 await가 아니라 상태 업데이트의 렌더 완료입니다.

startTransition(async () => {
  const res = await fetch('/api')
  const data = await res.json()
  setItems(data.items)
})

여기서 문제가 생기는 지점은 주로 두 가지입니다.

  • await 동안은 React가 할 일이 없어서 UI가 기대와 다르게 반응
  • setItems 이후에 또 다른 update가 이어지면 pending이 계속 true

해결: async는 바깥으로 빼고, transition은 “커밋할 업데이트”에만

const load = async () => {
  const res = await fetch('/api')
  return (await res.json()) as { items: string[] }
}

const onClick = async () => {
  setLoading(true)
  try {
    const data = await load()
    startTransition(() => {
      setItems(data.items)
    })
  } finally {
    setLoading(false)
  }
}

이 패턴은 다음 장점이 있습니다.

  • 네트워크 로딩과 transition을 분리
  • transition이 “짧고 예측 가능”해짐
  • 무한 pending이 아니라, 실제 문제는 “계속 업데이트되는 상태”라는 점을 빨리 드러냄

4) 개발 모드에서만 심해지는 경우: Strict Mode 이펙트 2회 실행

React 개발 모드에서 Strict Mode가 켜져 있으면 일부 effect가 의도적으로 2번 실행됩니다. 이때 다음이 함께 있으면 무한 로딩처럼 보일 수 있습니다.

  • effect에서 fetch를 날리고
  • cleanup에서 abort를 안 하거나
  • 응답 도착 후 상태 업데이트가 중첩

해결은 단순합니다.

  • AbortController를 사용해 이전 요청을 중단
  • “마지막 요청만 반영”하도록 request id를 두기
useEffect(() => {
  const ac = new AbortController()
  let alive = true

  ;(async () => {
    const res = await fetch('/api', { signal: ac.signal })
    const data = await res.json()
    if (alive) setItems(data.items)
  })()

  return () => {
    alive = false
    ac.abort()
  }
}, [])

실전 체크리스트: 무한 로딩을 “원인별로” 끊는 법

아래 순서대로 보면 대부분의 케이스가 정리됩니다.

1) isPending을 네트워크 로딩 지표로 쓰고 있지 않은가

  • useTransition은 네트워크 완료를 보장하지 않습니다.
  • 스피너를 isPending 하나에만 묶어두면 “끝나지 않는 것처럼 보이는” UI가 쉽게 나옵니다.

권장:

  • transition 스피너(짧게)
  • fetch 스피너(명확하게)

둘을 분리하세요.

2) transition 안에서 상태 업데이트가 연쇄적으로 이어지지 않는가

다음 패턴이 있으면 의심해야 합니다.

  • transition으로 setX를 했는데, useEffect([x])에서 또 setY
  • useEffect([y])에서 다시 setX
  • URL 동기화가 양방향 effect로 구성

해결:

  • “단방향 데이터 흐름”으로 재구성
  • effect에서 상태를 세팅해야 한다면, 반드시 변경 여부 비교 후에만 setState

3) 라우팅과 결합되어 있지 않은가

Next.js에서 router.replace가 들어가면, 렌더 트리 전체에 영향을 줄 수 있습니다. 특히 RSC가 섞인 화면이면 체감상 pending이 길어집니다.

  • 라우팅은 최소화
  • 동일 URL이면 replace 금지
  • 쿼리 정규화 필수

RSC 지연이 의심되면 Next.js 14 RSC 느림? TTFB 급증 7가지 해결에서 서버 응답 지연을 먼저 제거하세요.

4) 캐시나 stale UI로 인해 “로딩이 끝나지 않은 것처럼” 보이지 않는가

React 19 자체 문제라기보다, RSC 캐시나 fetch 캐시 정책 때문에 UI가 이전 데이터를 계속 보여주며 사용자가 “로딩이 멈춘 것 같다”고 느끼는 경우가 있습니다. 이때는 pending이 아니라 “데이터 갱신이 안 되는 것”이 핵심입니다.

이 경우는 Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법의 캐시 무효화/재검증 전략을 함께 점검하세요.

권장 아키텍처: useTransition을 가장 안전하게 쓰는 패턴

정리하면, useTransition은 다음 상황에서 가장 빛납니다.

  • 입력에 의해 UI가 자주 바뀌고
  • 그에 따른 렌더 비용이 크며
  • “사용자 입력 반응성”을 확보하고 싶을 때

그리고 안정적으로 쓰려면 아래처럼 역할을 분리하는 것이 좋습니다.

  • 네트워크 로딩: loading 또는 데이터 패칭 라이브러리의 상태
  • UI 업데이트 우선순위: useTransition
  • URL 동기화: 정규화 및 동일성 비교로 루프 차단

예시: 검색 입력은 transition, fetch는 별도 로딩

'use client'

import { useEffect, useMemo, useState, useTransition } from 'react'

type Item = { id: string; title: string }

export default function SearchPattern() {
  const [input, setInput] = useState('')
  const [query, setQuery] = useState('')
  const [items, setItems] = useState<Item[]>([])
  const [loading, setLoading] = useState(false)
  const [isPending, startTransition] = useTransition()

  // 입력은 즉시 반영
  const onChange = (v: string) => {
    setInput(v)
    // 실제 검색 쿼리 반영은 transition으로 늦춰 UI 반응성을 확보
    startTransition(() => setQuery(v.trim()))
  }

  const enabled = useMemo(() => query.length > 0, [query])

  useEffect(() => {
    if (!enabled) {
      setItems([])
      return
    }

    const ac = new AbortController()
    setLoading(true)

    ;(async () => {
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: ac.signal,
        })
        const data = (await res.json()) as { items: Item[] }
        setItems(data.items)
      } finally {
        setLoading(false)
      }
    })()

    return () => ac.abort()
  }, [query, enabled])

  return (
    <section>
      <label>
        Search
        <input value={input} onChange={(e) => onChange(e.target.value)} />
      </label>

      {(isPending || loading) && <p>Loading...</p>}

      <ul>
        {items.map((it) => (
          <li key={it.id}>{it.title}</li>
        ))}
      </ul>
    </section>
  )
}

이 구조의 장점:

  • 입력은 즉시 반응하되, 무거운 렌더는 transition으로 밀림
  • 네트워크 로딩은 명시적으로 관리되어 “pending 무한” 착시가 줄어듦
  • 요청 취소로 중복 응답 레이스를 제거

마무리

React 19에서 useTransition 무한 로딩처럼 보이는 문제는 대체로 다음 중 하나로 귀결됩니다.

  • isPending을 네트워크 로딩으로 오해해서 생긴 UX 설계 문제
  • transition으로 발생한 상태 업데이트가 effect, 라우팅, 파생 상태와 맞물려 “끝나지 않는 업데이트 체인”이 된 경우
  • Next.js 라우팅 및 RSC 지연, 캐시로 인해 pending과 무관하게 로딩이 길어 보이는 경우

가장 효과적인 해결법은 단순합니다.

  • transition은 “UI 상태 업데이트”에만 좁게 적용
  • fetch 로딩은 별도로 추적
  • URL 동기화는 정규화와 동일성 비교로 루프를 끊기

이 3가지만 지켜도 useTransition은 체감 성능을 올리면서도 예측 가능한 도구가 됩니다.