Transition API — startTransition과 useTransition 이해하기
검색창에 글자를 입력할 때마다 결과 목록이 무겁게 리렌더링된다면, 입력 자체가 끊기면서 타이핑이 느려질 수 있습니다. "입력은 바로, 결과는 나중에" — 이게 가능할까요?
React 18에서 도입된 Transition API는 "긴급한 업데이트"와 "그렇지 않은 업데이트"를 구분할 수 있게 합니다. 사용자 입력은 즉시 반영하면서, 그에 따른 무거운 렌더링은 나중에 처리하는 것이 핵심입니다.
문제: 모든 업데이트가 같은 우선순위
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // 입력창 업데이트 (긴급)
setResults(filterItems(value)); // 10,000개 항목 필터링 (무거움)
};
return (
<div>
<input value={query} onChange={handleChange} />
<ResultList results={results} />
</div>
);
}
문제: setQuery와 setResults가 같은 렌더링 사이클에서 처리되어, 10,000개 필터링이 끝날 때까지 입력창도 업데이트되지 않습니다.
startTransition — 기본 사용
import { startTransition } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleChange = (e) => {
const value = e.target.value;
// 긴급 업데이트 — 즉시 반영
setQuery(value);
// 전환(Transition) 업데이트 — 나중에 반영 가능
startTransition(() => {
setResults(filterItems(value));
});
};
return (
<div>
<input value={query} onChange={handleChange} />
<ResultList results={results} />
</div>
);
}
startTransition으로 감싼 setResults는 "긴급하지 않다"고 표시됩니다. React는 입력창 업데이트를 먼저 처리하고, 여유가 있을 때 결과 목록을 업데이트합니다.
useTransition — isPending으로 로딩 표시
import { useTransition } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
startTransition(() => {
setResults(filterItems(value));
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{/* 전환 진행 중이면 시각적 피드백 */}
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<ResultList results={results} />
</div>
</div>
);
}
isPending은 transition 업데이트가 아직 반영되지 않았음을 나타냅니다. 이를 활용하여 스피너나 opacity 변경으로 "업데이트 중"임을 사용자에게 알릴 수 있습니다.
내부 동작: Fiber Lanes
React 18의 동시성은 Fiber의 Lanes 시스템으로 구현됩니다.
우선순위 레벨
SyncLane — 최고 우선순위 (flushSync)
InputContinuousLane — 사용자 입력 (타이핑, 클릭)
DefaultLane — 일반 업데이트 (setState)
TransitionLane — 전환 업데이트 (startTransition)
IdleLane — 유휴 작업
동작 흐름
사용자가 "abc" 입력:
1. "a" 입력 → setQuery("a") [InputContinuousLane] + setResults [TransitionLane]
2. React: InputContinuousLane 먼저 처리 → 입력창에 "a" 반영
3. "b" 입력 → setQuery("ab") [InputContinuousLane] + setResults [TransitionLane]
4. React: TransitionLane 작업 중단! InputContinuousLane 먼저 처리 → "ab" 반영
5. "c" 입력 → setQuery("abc") [InputContinuousLane] + setResults [TransitionLane]
6. React: InputContinuousLane 처리 → "abc" 반영
7. 입력이 멈추면 TransitionLane 처리 → 결과 목록 업데이트
핵심: TransitionLane의 작업은 더 높은 우선순위 작업이 들어오면 중단(interrupt)됩니다. React가 입력을 먼저 처리하고, 여유가 있을 때 전환 작업을 완료합니다.
실전 예제
탭 전환
function TabSection() {
const [activeTab, setActiveTab] = useState('home');
const [isPending, startTransition] = useTransition();
const handleTabChange = (tab) => {
startTransition(() => {
setActiveTab(tab);
});
};
return (
<div>
<nav>
{['home', 'posts', 'comments'].map((tab) => (
<button
key={tab}
onClick={() => handleTabChange(tab)}
className={activeTab === tab ? 'active' : ''}
>
{tab}
</button>
))}
</nav>
<div style={{ opacity: isPending ? 0.6 : 1, transition: 'opacity 0.2s' }}>
{activeTab === 'home' && <Home />}
{activeTab === 'posts' && <Posts />} {/* 무거운 컴포넌트 */}
{activeTab === 'comments' && <Comments />} {/* 무거운 컴포넌트 */}
</div>
</div>
);
}
탭을 클릭하면 즉시 클릭 피드백이 있고, 무거운 탭 콘텐츠는 transition으로 렌더링됩니다.
Suspense와의 조합
function ProfilePage({ userId }) {
const [isPending, startTransition] = useTransition();
const handleNavigate = (newUserId) => {
startTransition(() => {
navigate(`/profile/${newUserId}`);
});
};
return (
<div>
<UserSelector onSelect={handleNavigate} />
{isPending && <LoadingOverlay />}
<Suspense fallback={<ProfileSkeleton />}>
<Profile userId={userId} />
</Suspense>
</div>
);
}
startTransition으로 라우트를 변경하면, 새 페이지의 데이터가 로드되는 동안 이전 페이지를 계속 보여줍니다. Suspense fallback이 즉시 나타나는 대신, isPending으로 로딩 오버레이를 표시할 수 있습니다.
useDeferredValue
useTransition은 setState를 감싸지만, useDeferredValue는 값 자체를 지연시킵니다.
import { useDeferredValue } from 'react';
function SearchResults({ query }) {
// query의 업데이트를 지연
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
// deferredQuery로 무거운 렌더링
const results = useMemo(() => filterItems(deferredQuery), [deferredQuery]);
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ResultList results={results} />
</div>
);
}
useTransition vs useDeferredValue
| 상황 | 추천 |
|---|---|
| setState를 직접 제어할 수 있을 때 | useTransition |
| props로 받은 값을 지연시킬 때 | useDeferredValue |
| 서드파티 라이브러리의 상태일 때 | useDeferredValue |
// useTransition — setState를 transition으로 감쌈
const [isPending, startTransition] = useTransition();
startTransition(() => setQuery(value));
// useDeferredValue — 값의 반영을 지연
const deferredValue = useDeferredValue(externalValue);
주의사항
transition 안에서는 동기 코드만
startTransition(() => {
// 동기 코드만 가능
setResults(filterItems(query));
// 비동기 코드는 transition으로 처리되지 않음
// fetch('/api').then(...) // 이건 transition이 아님
});
transition은 디바운싱이 아님
// 디바운싱: 마지막 입력 후 300ms 대기
// Transition: 즉시 시작하되, 더 긴급한 작업에 양보
// 디바운싱이 필요한 경우 (API 호출)
const debouncedSearch = useMemo(
() => debounce((q) => fetchResults(q), 300),
[]
);
// Transition이 필요한 경우 (무거운 클라이언트 렌더링)
startTransition(() => {
setFilteredResults(heavyFiltering(query));
});
- API 호출이 필요하면 ** 디바운싱**
- 클라이언트 렌더링이 무거우면 Transition
- 둘 다 필요하면 함께 사용
정리
Transition API는 "긴급한 것과 그렇지 않은 것을 구분"하여 사용자 경험을 개선합니다.
- startTransition: 상태 업데이트를 낮은 우선순위(TransitionLane)로 표시합니다
- useTransition:
isPending으로 전환 진행 상태를 알 수 있습니다 - Fiber Lanes: 우선순위별로 작업을 관리하며, 높은 우선순위 작업이 낮은 우선순위를 중단할 수 있습니다
- useDeferredValue: props나 외부 값의 반영을 지연시킵니다
- 입력은 즉시, 무거운 렌더링은 나중에 — 이것이 Transition의 핵심 가치입니다
주의할 점
모든 setState를 startTransition으로 감싸면 안 됨
입력 필드의 onChange처럼 즉각적 피드백이 필요한 업데이트를 transition으로 감싸면 ** 입력이 지연 **됩니다. 사용자 입력은 일반 업데이트로, 그에 따른 무거운 부수 렌더링(검색 결과, 필터링 등)만 transition으로 감싸야 합니다.
transition 안에서 fetch를 직접 호출
startTransition은 ** 동기적 state 업데이트 **를 지연시키는 것이지, 비동기 작업 자체를 관리하지 않습니다. 데이터 페칭 지연은 Suspense + TanStack Query 등의 도구가 담당합니다.
"모든 setState에 startTransition을 감싸야 하나요?"의 답은 "아니요"입니다. 입력은 즉시, 무거운 렌더링만 transition으로 감싸는 것이 올바른 사용입니다.