useTransition vs useDeferredValue 비교

useTransition과 useDeferredValue의 차이를 관점·사용 위치·로딩 표시 방법으로 비교하고, 각 훅의 적합한 사용 케이스와 함께 사용하는 패턴을 설명합니다.

· 5 min read · PALDYN Team

지난 글에서 useDeferredValue를 살펴봤다. 이번에는 useTransitionuseDeferredValue나란히 비교해 언제 어떤 훅을 선택해야 하는지 정리한다.

같은 목표, 다른 관점

두 훅은 모두 “무거운 렌더링을 긴급하지 않은 것으로 처리해 UI 응답성을 개선”한다는 공통 목표가 있다. 차이는 어디서 지연을 선언하는가다.

useTransition vs useDeferredValue 비교

  • useTransition: 업데이트를 시작하는 쪽 (이벤트 핸들러)
  • useDeferredValue: 값을 소비하는 쪽 (렌더링하는 컴포넌트)

코드로 비교

같은 기능(검색 입력 최적화)을 두 방식으로 구현하면:

useTransition 방식

function SearchWithTransition() {
  const [inputValue, setInputValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setInputValue(e.target.value); // 즉각

    startTransition(() => {
      setSearchQuery(e.target.value); // 전환
    });
  }

  return (
    <>
      <input value={inputValue} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultList query={searchQuery} />
    </>
  );
}

두 개의 state가 필요하다. isPending으로 명시적인 로딩 표시가 가능하다.

useDeferredValue 방식

const ResultList = memo(function ResultList({ query }: { query: string }) {
  const results = useMemo(() => search(query), [query]);
  return <ul>{results.map((r) => <li key={r.id}>{r.name}</li>)}</ul>;
});

function SearchWithDeferred() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  const isStale = query !== deferredQuery;

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.7 : 1 }}>
        <ResultList query={deferredQuery} />
      </div>
    </>
  );
}

state는 하나. memo가 필수. stale 감지는 두 값 비교로.

선택 가이드

선택 가이드

useTransition을 선택하는 경우

// 탭 전환 — setState 직접 제어
function Tabs() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  return (
    <nav>
      {tabs.map((t) => (
        <button
          key={t}
          onClick={() => startTransition(() => setTab(t))}
          style={{ opacity: isPending ? 0.6 : 1 }}
        >
          {t}
        </button>
      ))}
      <Suspense fallback={<Loading />}>
        <TabContent tab={tab} />
      </Suspense>
    </nav>
  );
}

탭 버튼을 클릭할 때 setTab을 직접 호출하므로 useTransition이 적합하다. Suspense와 함께 쓰면 새 탭 콘텐츠가 준비될 때까지 이전 탭을 유지할 수 있다.

useDeferredValue를 선택하는 경우

// 입력 기반 필터 — 외부에서 받은 prop 지연
function FilteredTable({ data }: { data: Item[] }) {
  const [filter, setFilter] = useState('');
  const deferredFilter = useDeferredValue(filter);

  const filtered = useMemo(
    () => data.filter((item) => item.name.includes(deferredFilter)),
    [data, deferredFilter]
  );

  return (
    <>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} />
      <Table rows={filtered} />
    </>
  );
}

filter state의 owner가 이 컴포넌트지만, data는 외부에서 온다. useDeferredValue로 필터링 연산을 후순위로 처리한다.

함께 사용하는 경우

드물지만 두 훅을 함께 쓸 수도 있다:

function Parent() {
  const [tab, setTab] = useState('a');
  const [isPending, startTransition] = useTransition();

  return (
    <Child
      tab={tab}
      onSwitch={() => startTransition(() => setTab('b'))}
    />
  );
}

function Child({ tab, onSwitch }: { tab: string; onSwitch: () => void }) {
  // 부모의 startTransition + 자식의 useDeferredValue 이중 지연
  const deferredTab = useDeferredValue(tab);

  return (
    <>
      <button onClick={onSwitch}>전환</button>
      <HeavyContent tab={deferredTab} />
    </>
  );
}

부모에서 useTransition으로 전환을 시작하고, 자식에서 useDeferredValue로 추가 지연을 적용한다. 매우 무거운 렌더링에서 단계적으로 지연을 걸 때 사용할 수 있다.

공통 함정

// ❌ memo 없이 useDeferredValue — 효과 없음
function SearchBad() {
  const [q, setQ] = useState('');
  const deferred = useDeferredValue(q);

  // memo 없는 컴포넌트: deferred가 바뀌지 않아도 부모 리렌더로 함께 렌더됨
  return <SlowListNoMemo query={deferred} />;
}

// ✓ memo + useDeferredValue
const SlowList = memo(({ query }: { query: string }) => { ... });
// ❌ transition 안에서 input value 업데이트
startTransition(() => {
  setInputValue(e.target.value); // input은 긴급 — 전환으로 감싸면 지연됨
});

// ✓ input은 밖에서, 검색 결과는 안에서
setInputValue(e.target.value);
startTransition(() => setSearchQuery(e.target.value));

요약

useTransitionuseDeferredValue
위치이벤트 핸들러컴포넌트 상단
필요 state2개 (input + query)1개
로딩 표시isPendingvalue !== deferred
memo 필요선택필수
Suspense 연동이전 UI 유지이전 값 유지
주요 use case탭 전환, 페이지 이동검색 필터, 정렬

지난 글: useDeferredValue로 후순위 업데이트 분리


읽어주셔서 감사합니다. 😊