junyeokk
Blog
React·2025. 08. 08

React 19 useTransition

React에서 상태를 업데이트하면 UI가 즉시 리렌더링된다. 대부분의 경우 이게 원하는 동작이지만, 모든 상태 업데이트가 같은 우선순위를 가져야 하는 건 아니다.

예를 들어 검색 입력 필드가 있다고 하자. 사용자가 타이핑할 때마다 입력값 업데이트와 검색 결과 필터링이 동시에 일어난다.

tsx
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)"으로 표시해서 우선순위를 낮추는 훅이다.


기본 사용법

tsx
const [isPending, startTransition] = useTransition();

useTransition은 두 가지를 반환한다.

  • isPending: 전환이 진행 중인지 나타내는 boolean. 로딩 인디케이터를 보여주는 데 사용한다.
  • startTransition: 상태 업데이트를 전환으로 감싸는 함수. 이 안에서 실행된 setState는 낮은 우선순위로 처리된다.

아까의 검색 예제를 수정하면:

tsx
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 RenderinguseTransition의 핵심이다. 기존 React는 렌더링을 시작하면 완료될 때까지 중단할 수 없었다(동기적 렌더링). Concurrent 모드에서는 렌더링을 여러 조각으로 나누어 처리하고, 더 긴급한 작업이 들어오면 진행 중인 렌더링을 중단할 수 있다.

startTransition으로 감싼 업데이트는 React 내부에서 lane 시스템을 통해 낮은 우선순위 lane에 배치된다. React의 스케줄러는 이 lane 우선순위를 보고 어떤 업데이트를 먼저 처리할지 결정한다.

text
높은 우선순위 (Sync Lane)     → 클릭, 타이핑, 포커스 등 사용자 입력
전환 우선순위 (Transition Lane) → startTransition으로 감싼 업데이트
낮은 우선순위 (Idle Lane)      → offscreen 렌더링 등

중요한 점은 startTransition 안의 setState 호출 자체가 지연되는 게 아니라는 것이다. 호출은 즉시 실행되고, React가 이 업데이트를 렌더링하는 시점을 늦추거나 중단할 수 있는 것이다.


React 19에서 달라진 점

React 18의 useTransition은 동기적인 상태 업데이트에만 사용할 수 있었다. React 19에서는 비동기 함수startTransition에 전달할 수 있게 되었다. 이것이 React 19의 핵심 변화 중 하나다.

비동기 전환 (Async Transitions)

tsx
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 상태를 별도로 관리해야 했다:

tsx
// 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"이다.

tsx
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에서 추가됐다.

tsx
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지만, 사용하는 상황이 다르다.

useTransitionuseDeferredValue
대상상태 업데이트 자체값의 반영 시점
제어startTransition으로 어떤 setState를 감쌀지 직접 선택값을 넘기면 React가 알아서 이전 값을 유지
사용 시점setState를 호출하는 쪽에서 제어 가능할 때props로 받은 값을 늦추고 싶을 때
tsx
// useTransition: 직접 setState를 감싼다
const [isPending, startTransition] = useTransition();
startTransition(() => setQuery(value));

// useDeferredValue: 값 자체를 지연시킨다
const deferredQuery = useDeferredValue(query);
// deferredQuery는 긴급 업데이트가 끝난 후에 갱신된다

useDeferredValue는 상태를 업데이트하는 코드에 접근할 수 없을 때 유용하다. 예를 들어 부모 컴포넌트에서 props로 받은 값의 반영을 늦추고 싶다면 useDeferredValue를 쓴다. 반면 상태를 직접 업데이트하는 곳에서 우선순위를 제어하고 싶다면 useTransition이 더 적합하다.


startTransition (독립 함수)

useTransition 훅 없이 react에서 startTransition을 직접 import할 수도 있다.

tsx
import { startTransition } from "react";

startTransition(() => {
  setSearchResults(filterData(query));
});

차이점은 isPending 상태를 제공하지 않는다는 것이다. 로딩 인디케이터가 필요 없고 단순히 우선순위만 낮추고 싶을 때 유용하다. 컴포넌트 바깥(이벤트 핸들러, 유틸리티 함수 등)에서도 사용할 수 있다.


주의사항

동기적으로 실행되는 코드는 효과 없음

startTransition 콜백 안에서 setState를 호출하는 것만 전환으로 표시된다. 콜백 안의 다른 동기 코드가 지연되는 게 아니다.

tsx
startTransition(() => {
  heavyComputation(); // 이건 즉시 실행됨
  setResult(value);   // 이것만 전환 우선순위
});

무거운 계산 자체를 늦추고 싶다면 useMemo나 Web Worker를 써야 한다.

Suspense와의 관계

startTransition으로 감싼 업데이트가 컴포넌트를 suspend시키면(예: 데이터 로딩), React는 fallback UI를 즉시 보여주는 대신 이전 UI를 유지한다. isPendingtrue가 되어 이전 화면 위에 로딩 인디케이터를 표시할 수 있다.

tsx
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 상태를 전환으로 감싸면 입력이 지연되어 사용자 경험이 나빠진다.

tsx
// ❌ 잘못된 사용
startTransition(() => {
  setInputValue(e.target.value);
});

// ✅ 올바른 사용
setInputValue(e.target.value); // 즉시 반영
startTransition(() => {
  setFilteredResults(filter(e.target.value)); // 필터링만 전환
});

정리

useTransition은 React의 Concurrent Rendering을 활용해서 상태 업데이트의 우선순위를 구분하는 도구다. React 19에서 비동기 함수 지원이 추가되면서, 서버 통신과 폼 제출 같은 실제 비동기 작업에서도 로딩 상태를 선언적으로 관리할 수 있게 되었다.

핵심은 간단하다: 사용자가 즉시 반응을 기대하는 업데이트잠시 늦어도 괜찮은 업데이트를 분리하면, 같은 코드로도 체감 성능이 크게 달라진다.


관련 문서