- Published on
React useTransition 무한 로딩·깜빡임 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 요청이나 무거운 렌더링을 useTransition으로 감쌌는데 isPending이 계속 true로 남거나, 로딩 스피너가 깜빡이고 리스트가 순간적으로 비는 현상을 겪는 경우가 많습니다. 특히 검색 자동완성, 필터/정렬, 페이지네이션 같은 UI에서 전환을 잘못 설계하면 “전환이 끝나지 않는 것처럼 보이는” 상태가 쉽게 만들어집니다.
이 글에서는 다음 두 가지를 분리해서 다룹니다.
- 무한 로딩처럼 보이는
isPending: 실제로 전환이 계속 새로 시작되거나, 전환이 끝나도 곧바로 다음 전환이 발생하는 루프 - 깜빡임(flicker): 전환 중에 기존 UI를 유지하지 못하고 빈 화면이나 fallback으로 튀는 현상
또한, 폼 제출/중복 요청과 함께 엮이면 체감상 “로딩이 끝나지 않는다”가 더 자주 발생합니다. 폼 계열은 useTransition만으로 해결하려 하지 말고 useActionState 같은 패턴도 고려하세요. 자세한 내용은 React 19 useActionState로 폼 지연·중복 제출 해결도 함께 참고하면 좋습니다.
useTransition이 실제로 보장하는 것
useTransition은 “비동기 요청을 관리하는 훅”이 아니라, 특정 state 업데이트를 낮은 우선순위로 처리해서 입력/스크롤 같은 상호작용을 덜 막도록 하는 도구입니다.
startTransition(fn)안에서 발생한 state 업데이트는 “전환 업데이트”로 분류됩니다.isPending은 “전환 업데이트가 아직 커밋되지 않았는지”를 나타냅니다.- 네트워크 요청의 완료 여부와
isPending은 1:1로 연결되지 않습니다.
따라서 다음과 같은 구조는 흔히 오해를 부릅니다.
const [isPending, startTransition] = useTransition();
const onSearch = async (q: string) => {
startTransition(async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
setItems(data);
});
};
위 코드는 얼핏 그럴듯하지만, startTransition은 async 함수의 완료를 기다려서 isPending을 관리하는 API가 아닙니다. 네트워크 요청은 전환의 “범위”와 별개로 진행되고, 전환은 결국 setItems가 발생하는 시점의 렌더링/커밋과 관련됩니다.
증상 1: isPending이 끝나지 않는(것처럼 보이는) 대표 원인
1) 전환 중에 또 전환을 발생시키는 상태 루프
가장 흔한 패턴은 “전환으로 바뀐 상태를 useEffect가 감지해서 다시 전환을 시작”하는 구조입니다.
잘못된 예: query 변경이 fetch를 부르고, fetch 결과가 query를 다시 바꿈
const [query, setQuery] = useState("");
const [items, setItems] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
useEffect(() => {
// query가 바뀌면 검색
startTransition(() => {
void (async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = (await res.json()) as string[];
setItems(data);
// 여기서 query를 정규화한다며 다시 setQuery를 하면 루프가 쉽게 생김
// setQuery(query.trim());
})();
});
}, [query]);
setQuery가 다시 실행되면 useEffect가 다시 실행되고, 전환이 연속으로 발생합니다. 이 경우 isPending이 “항상 true로 보이는” 현상이 생깁니다.
해결: 전환과 사이드이펙트를 분리하고, query 정규화는 입력 단계에서 끝내기
- 입력 핸들러에서
trim/toLowerCase등을 적용 useEffect에서는 query가 실제로 달라졌을 때만 fetch- fetch 결과로 query를 다시 바꾸지 않기
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value; // 필요하면 여기서 정규화
setQuery(next);
};
useEffect(() => {
let cancelled = false;
const run = async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = (await res.json()) as string[];
if (!cancelled) {
startTransition(() => {
setItems(data);
});
}
};
void run();
return () => {
cancelled = true;
};
}, [query, startTransition]);
핵심은 네트워크는 네트워크대로, 전환은 UI 업데이트에만 적용하는 것입니다.
2) 전환 범위를 너무 넓게 잡아서 매 렌더마다 무거운 작업이 반복
startTransition으로 상태를 바꿨는데, 그 상태를 기반으로 매 렌더마다 큰 계산을 다시 수행하면 전환이 연쇄적으로 길어지고 isPending이 “계속 true”처럼 보일 수 있습니다.
잘못된 예: 렌더링 중 대량 정렬/필터
const filtered = items
.filter((x) => x.includes(query))
.sort((a, b) => a.localeCompare(b));
해결: 메모이제이션, 혹은 서버로 이동
const filtered = useMemo(() => {
return items
.filter((x) => x.includes(query))
.sort((a, b) => a.localeCompare(b));
}, [items, query]);
또는 정말 비용이 큰 계산이라면, 클라이언트에서 억지로 처리하지 말고 서버 쿼리로 밀어내는 편이 낫습니다. 이때도 UI 반응성 문제를 체감적으로 확인하려면 INP/Long Task를 같이 봐야 합니다. 관련해서는 Chrome INP 점수 급락? Long Task 추적·해결 글이 도움이 됩니다.
3) Strict Mode에서 “두 번 실행”을 무한 로딩으로 착각
개발 모드에서 React Strict Mode는 일부 로직을 의도적으로 두 번 실행해 부작용을 찾습니다. 이때 fetch가 2번 나가고 로딩이 길어져서 isPending이 이상해 보일 수 있습니다.
- 개발 모드에서만 재현되는지 확인
useEffect내부 fetch에 취소 플래그/AbortController를 추가
useEffect(() => {
const ac = new AbortController();
const run = async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: ac.signal,
});
const data = (await res.json()) as string[];
startTransition(() => setItems(data));
};
void run();
return () => ac.abort();
}, [query, startTransition]);
증상 2: 로딩 스피너/리스트가 깜빡이는 대표 원인
1) 전환 중 기존 UI를 유지하지 않고 “초기화”해버림
검색할 때 흔히 다음을 합니다.
- query가 바뀌면
setItems([])로 비우고 - 요청이 끝나면 새 items를 채움
이러면 전환 중에 리스트가 비었다가 다시 채워져서 깜빡임이 발생합니다.
잘못된 예
const onQueryChange = (q: string) => {
setQuery(q);
startTransition(() => {
setItems([]); // 깜빡임 유발
});
};
해결: 이전 결과를 유지하고 “오버레이 로딩”만 표시
return (
<div style={{ position: "relative" }}>
{isPending && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(255,255,255,0.6)",
}}
>
Loading...
</div>
)}
<ul aria-busy={isPending}>
{items.map((x) => (
<li key={x}>{x}</li>
))}
</ul>
</div>
);
핵심은 데이터를 비우지 말고, 기존 화면을 유지한 채 “업데이트 중”임을 표현하는 것입니다.
2) Suspense fallback이 자주 나타나며 화면이 튐
App Router, RSC, Suspense를 같이 쓰면 전환 중에 fallback이 등장하며 깜빡임이 생길 수 있습니다. 특히 캐시 설정이나 데이터 패칭 위치에 따라 “항상 새로 로드”되는 것처럼 보이기도 합니다.
- Suspense 경계를 너무 바깥에 두면 작은 변경에도 전체가 fallback으로 떨어짐
- 전환과 함께 라우트 세그먼트 전체가 다시 로드되면 깜빡임이 커짐
이 경우 Next.js 캐시/재검증 설정이 원인인 경우가 많습니다. 관련해서는 Next.js 14 RSC 캐시로 데이터가 안 갱신될 때도 함께 확인해보세요.
해결 방향
- Suspense 경계를 더 안쪽으로 옮겨 부분만 fallback
- 전환 중에도 유지되어야 하는 영역은 클라이언트 상태로 유지
- 라우팅 전환은
loading.tsx에만 의존하지 말고, 화면 내 오버레이 로딩도 병행
실전 패턴: 검색 UI에서 깜빡임 없이 전환 적용하기
요구사항을 다음처럼 잡아보겠습니다.
- 입력은 즉시 반응해야 함
- 결과 렌더는 전환으로 처리해 타이핑이 끊기지 않게
- 전환 중에도 기존 결과는 유지
- 오래 걸린 요청은 취소하여 최신 query만 반영
import React, { useEffect, useMemo, useState, useTransition } from "react";
type Item = { id: string; title: string };
export function SearchBox() {
const [input, setInput] = useState("");
const [query, setQuery] = useState("");
const [items, setItems] = useState<Item[]>([]);
const [isPending, startTransition] = useTransition();
// 타이핑은 즉시, query 반영은 약간 늦춰도 되면 디바운스 가능
useEffect(() => {
const t = setTimeout(() => setQuery(input), 200);
return () => clearTimeout(t);
}, [input]);
useEffect(() => {
const ac = new AbortController();
const run = async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: ac.signal,
});
const data = (await res.json()) as Item[];
// 네트워크 완료 후 UI 업데이트만 transition
startTransition(() => {
setItems(data);
});
};
// 빈 query는 요청 스킵 (불필요한 pending 방지)
if (query.trim().length === 0) {
startTransition(() => setItems([]));
return () => ac.abort();
}
void run();
return () => ac.abort();
}, [query, startTransition]);
const countText = useMemo(() => {
return `${items.length} results`;
}, [items.length]);
return (
<section>
<label>
Search
<input
value={input}
onChange={(e) => setInput(e.target.value)}
aria-label="search"
/>
</label>
<div style={{ position: "relative", marginTop: 12 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span>{countText}</span>
{isPending && <span>Updating...</span>}
</div>
<ul aria-busy={isPending}>
{items.map((it) => (
<li key={it.id}>{it.title}</li>
))}
</ul>
</div>
</section>
);
}
이 패턴의 포인트는 다음입니다.
startTransition은 fetch를 감싸지 않고, 결과를setItems로 반영할 때만 사용- 전환 중에도 기존
items를 유지하므로 깜빡임이 줄어듦 AbortController로 “느린 이전 요청”이 최신 결과를 덮어쓰는 문제 방지- 빈 query 처리로 불필요한 전환/로딩 상태 최소화
체크리스트: 무한 로딩·깜빡임을 빠르게 줄이는 진단 순서
1) 전환이 “끊임없이 시작되는”지 확인
useEffect의존성 배열에 상태가 과하게 들어가 있지 않은가- 전환으로 바뀐 상태가 다시 전환을 트리거하지 않는가
- 개발 모드 Strict Mode에서만 재현되는가
필요하면 아래처럼 로깅해 전환 시작 빈도를 확인합니다.
const [isPending, startTransition] = useTransition();
const safeTransition = (label: string, fn: () => void) => {
console.log("transition start:", label);
startTransition(() => {
fn();
});
};
2) 전환 중 “데이터를 비우는 코드”가 있는지 찾기
setItems([])setData(null)setState(initialState)
이런 코드는 대부분 깜빡임의 직접 원인입니다. 전환 중에는 기존 UI를 유지하고, 로딩 표시는 오버레이/스켈레톤으로 처리하는 편이 안정적입니다.
3) Suspense 경계가 너무 큰지 확인
- 작은 변경에도 전체가 fallback으로 떨어지면 경계를 쪼개야 합니다.
- 라우트 전환과 데이터 전환이 섞이면 깜빡임이 커집니다.
4) 성능 병목이 있는지 확인
전환이 끝나지 않는 게 아니라, 렌더가 너무 무거워서 “끝나기까지 오래 걸리는” 경우도 많습니다.
- 큰 리스트 렌더링이면 가상화 고려
- 정렬/필터는
useMemo - 불필요한 재렌더를 줄이기 위해 컴포넌트 분리
마무리
useTransition 문제의 대부분은 훅 자체의 버그라기보다, 전환의 의미(낮은 우선순위 렌더)와 네트워크/사이드이펙트의 의미(비동기 작업)가 섞이면서 생깁니다.
startTransition은 UI state 업데이트에만 적용- fetch는
useEffect등에서 수행하고, 완료 후 상태 반영을 전환으로 처리 - 전환 중 데이터 초기화는 피하고, 기존 UI를 유지하며 로딩을 표시
- Suspense 경계는 작게, 성능 병목은 측정으로 확인
이 4가지만 지켜도 isPending 무한 로딩처럼 보이는 현상과 깜빡임은 대부분 빠르게 안정화됩니다.