React 19 useTransition
React에서 상태를 업데이트하면 UI가 즉시 리렌더링된다. 대부분의 경우 이게 원하는 동작이지만, 모든 상태 업데이트가 같은 우선순위를 가져야 하는 건 아니다.
예를 들어 검색 입력 필드가 있다고 하자. 사용자가 타이핑할 때마다 입력값 업데이트와 검색 결과 필터링이 동시에 일어난다.
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value);
setResults(filterResults(value)); // 무거운 연산
}
return (
<>
<input value={query} onChange={handleChange} />
<ResultList results={results} />
</>
);
}
filterResults가 수천 개의 항목을 필터링하는 무거운 연산이라면, 타이핑할 때마다 입력 필드가 버벅거린다. 입력값 반영이라는 긴급한 업데이트와 결과 필터링이라는 덜 긴급한 업데이트가 하나의 렌더링 사이클에 묶여 있기 때문이다.
useTransition은 이 문제를 해결한다. 특정 상태 업데이트를 "전환(transition)"으로 표시해서 우선순위를 낮추는 훅이다.
기본 사용법
const [isPending, startTransition] = useTransition();
useTransition은 두 가지를 반환한다.
isPending: 전환이 진행 중인지 나타내는 boolean. 로딩 인디케이터를 보여주는 데 사용한다.startTransition: 상태 업데이트를 전환으로 감싸는 함수. 이 안에서 실행된setState는 낮은 우선순위로 처리된다.
아까의 검색 예제를 수정하면:
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // 긴급: 즉시 반영
startTransition(() => {
setResults(filterResults(value)); // 비긴급: 전환으로 처리
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultList results={results} />
</>
);
}
setQuery는 즉시 처리되어 입력 필드가 바로 반응한다. setResults는 전환으로 표시되어 React가 더 긴급한 업데이트(타이핑)를 먼저 처리한 뒤에 결과를 업데이트한다. 타이핑이 연속으로 들어오면 React는 진행 중인 전환 렌더링을 중단(interrupt)하고 새 입력에 먼저 반응한다.
내부 동작 원리
React 18에서 도입된 Concurrent Rendering이 useTransition의 핵심이다. 기존 React는 렌더링을 시작하면 완료될 때까지 중단할 수 없었다(동기적 렌더링). Concurrent 모드에서는 렌더링을 여러 조각으로 나누어 처리하고, 더 긴급한 작업이 들어오면 진행 중인 렌더링을 중단할 수 있다.
startTransition으로 감싼 업데이트는 React 내부에서 lane 시스템을 통해 낮은 우선순위 lane에 배치된다. React의 스케줄러는 이 lane 우선순위를 보고 어떤 업데이트를 먼저 처리할지 결정한다.
높은 우선순위 (Sync Lane) → 클릭, 타이핑, 포커스 등 사용자 입력
전환 우선순위 (Transition Lane) → startTransition으로 감싼 업데이트
낮은 우선순위 (Idle Lane) → offscreen 렌더링 등
중요한 점은 startTransition 안의 setState 호출 자체가 지연되는 게 아니라는 것이다. 호출은 즉시 실행되고, React가 이 업데이트를 렌더링하는 시점을 늦추거나 중단할 수 있는 것이다.
React 19에서 달라진 점
React 18의 useTransition은 동기적인 상태 업데이트에만 사용할 수 있었다. React 19에서는 비동기 함수를 startTransition에 전달할 수 있게 되었다. 이것이 React 19의 핵심 변화 중 하나다.
비동기 전환 (Async Transitions)
function SubmitButton() {
const [isPending, startTransition] = useTransition();
async function handleSubmit() {
startTransition(async () => {
const result = await saveToServer(data);
// 서버 응답 후 상태 업데이트
setItems(result);
});
}
return (
<button onClick={handleSubmit} disabled={isPending}>
{isPending ? "저장 중..." : "저장"}
</button>
);
}
React 18에서는 이런 코드가 불가능했다. 비동기 작업을 하려면 isPending 상태를 별도로 관리해야 했다:
// React 18 방식 — 수동 상태 관리
function SubmitButton() {
const [isPending, setIsPending] = useState(false);
async function handleSubmit() {
setIsPending(true);
try {
const result = await saveToServer(data);
setItems(result);
} finally {
setIsPending(false);
}
}
return (
<button onClick={handleSubmit} disabled={isPending}>
{isPending ? "저장 중..." : "저장"}
</button>
);
}
React 19에서는 isPending이 비동기 함수가 완전히 완료될 때까지 true로 유지된다. 로딩 상태를 직접 관리할 필요가 없어졌다.
Actions
React 19에서 <form>의 action prop에 함수를 전달할 수 있게 되었다. 이 함수가 바로 "Action"이다.
function ContactForm() {
const [error, setError] = useState(null);
const [isPending, startTransition] = useTransition();
async function handleSubmit(formData: FormData) {
startTransition(async () => {
const result = await submitContact(formData);
if (result.error) {
setError(result.error);
}
});
}
return (
<form action={handleSubmit}>
<input name="email" type="email" />
<button type="submit" disabled={isPending}>
{isPending ? "전송 중..." : "전송"}
</button>
{error && <p>{error}</p>}
</form>
);
}
action에 전달된 함수는 내부적으로 자동으로 startTransition에 감싸진다. 즉 폼 제출 중에도 UI가 반응성을 유지한다.
useActionState
useTransition + action을 더 간단하게 쓸 수 있는 useActionState 훅도 React 19에서 추가됐다.
import { useActionState } from "react";
function ContactForm() {
const [state, formAction, isPending] = useActionState(
async (prevState, formData: FormData) => {
const result = await submitContact(formData);
if (result.error) {
return { error: result.error };
}
return { error: null, success: true };
},
{ error: null }
);
return (
<form action={formAction}>
<input name="email" type="email" />
<button type="submit" disabled={isPending}>
{isPending ? "전송 중..." : "전송"}
</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
useActionState는 action 함수의 반환값을 상태로 관리해준다. 이전 상태(prevState)를 받아서 다음 상태를 반환하는 리듀서와 비슷한 패턴이다. isPending도 자동으로 제공된다.
useTransition vs useDeferredValue
둘 다 우선순위를 낮추는 API지만, 사용하는 상황이 다르다.
| useTransition | useDeferredValue | |
|---|---|---|
| 대상 | 상태 업데이트 자체 | 값의 반영 시점 |
| 제어 | startTransition으로 어떤 setState를 감쌀지 직접 선택 | 값을 넘기면 React가 알아서 이전 값을 유지 |
| 사용 시점 | setState를 호출하는 쪽에서 제어 가능할 때 | props로 받은 값을 늦추고 싶을 때 |
// useTransition: 직접 setState를 감싼다
const [isPending, startTransition] = useTransition();
startTransition(() => setQuery(value));
// useDeferredValue: 값 자체를 지연시킨다
const deferredQuery = useDeferredValue(query);
// deferredQuery는 긴급 업데이트가 끝난 후에 갱신된다
useDeferredValue는 상태를 업데이트하는 코드에 접근할 수 없을 때 유용하다. 예를 들어 부모 컴포넌트에서 props로 받은 값의 반영을 늦추고 싶다면 useDeferredValue를 쓴다. 반면 상태를 직접 업데이트하는 곳에서 우선순위를 제어하고 싶다면 useTransition이 더 적합하다.
startTransition (독립 함수)
useTransition 훅 없이 react에서 startTransition을 직접 import할 수도 있다.
import { startTransition } from "react";
startTransition(() => {
setSearchResults(filterData(query));
});
차이점은 isPending 상태를 제공하지 않는다는 것이다. 로딩 인디케이터가 필요 없고 단순히 우선순위만 낮추고 싶을 때 유용하다. 컴포넌트 바깥(이벤트 핸들러, 유틸리티 함수 등)에서도 사용할 수 있다.
주의사항
동기적으로 실행되는 코드는 효과 없음
startTransition 콜백 안에서 setState를 호출하는 것만 전환으로 표시된다. 콜백 안의 다른 동기 코드가 지연되는 게 아니다.
startTransition(() => {
heavyComputation(); // 이건 즉시 실행됨
setResult(value); // 이것만 전환 우선순위
});
무거운 계산 자체를 늦추고 싶다면 useMemo나 Web Worker를 써야 한다.
Suspense와의 관계
startTransition으로 감싼 업데이트가 컴포넌트를 suspend시키면(예: 데이터 로딩), React는 fallback UI를 즉시 보여주는 대신 이전 UI를 유지한다. isPending이 true가 되어 이전 화면 위에 로딩 인디케이터를 표시할 수 있다.
function TabContainer() {
const [tab, setTab] = useState("home");
const [isPending, startTransition] = useTransition();
function selectTab(nextTab: string) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<TabButtons onSelect={selectTab} />
<Suspense fallback={<Skeleton />}>
<TabContent tab={tab} />
</Suspense>
</div>
);
}
탭을 전환할 때 새 탭 컨텐츠가 로딩되는 동안 이전 탭 컨텐츠가 반투명하게 유지된다. Suspense의 fallback skeleton이 매번 번쩍이는 것보다 훨씬 부드러운 전환을 제공한다.
텍스트 입력을 startTransition으로 감싸면 안 됨
startTransition은 비긴급 업데이트를 위한 것이다. 텍스트 입력의 value 상태를 전환으로 감싸면 입력이 지연되어 사용자 경험이 나빠진다.
// ❌ 잘못된 사용
startTransition(() => {
setInputValue(e.target.value);
});
// ✅ 올바른 사용
setInputValue(e.target.value); // 즉시 반영
startTransition(() => {
setFilteredResults(filter(e.target.value)); // 필터링만 전환
});
정리
useTransition은 React의 Concurrent Rendering을 활용해서 상태 업데이트의 우선순위를 구분하는 도구다. React 19에서 비동기 함수 지원이 추가되면서, 서버 통신과 폼 제출 같은 실제 비동기 작업에서도 로딩 상태를 선언적으로 관리할 수 있게 되었다.
핵심은 간단하다: 사용자가 즉시 반응을 기대하는 업데이트와 잠시 늦어도 괜찮은 업데이트를 분리하면, 같은 코드로도 체감 성능이 크게 달라진다.