검색창에 글자를 입력할 때마다 결과 목록이 무겁게 리렌더링된다면, 입력 자체가 끊기면서 타이핑이 느려질 수 있습니다. "입력은 바로, 결과는 나중에" — 이게 가능할까요?

React 18에서 도입된 Transition API는 "긴급한 업데이트"와 "그렇지 않은 업데이트"를 구분할 수 있게 합니다. 사용자 입력은 즉시 반영하면서, 그에 따른 무거운 렌더링은 나중에 처리하는 것이 핵심입니다.

문제: 모든 업데이트가 같은 우선순위

JSX
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>
  );
}

문제: setQuerysetResults가 같은 렌더링 사이클에서 처리되어, 10,000개 필터링이 끝날 때까지 입력창도 업데이트되지 않습니다.

startTransition — 기본 사용

JSX
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으로 로딩 표시

JSX
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 시스템으로 구현됩니다.

우선순위 레벨

PLAINTEXT
SyncLane           — 최고 우선순위 (flushSync)
InputContinuousLane — 사용자 입력 (타이핑, 클릭)
DefaultLane        — 일반 업데이트 (setState)
TransitionLane     — 전환 업데이트 (startTransition)
IdleLane           — 유휴 작업

동작 흐름

PLAINTEXT
사용자가 "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가 입력을 먼저 처리하고, 여유가 있을 때 전환 작업을 완료합니다.

실전 예제

탭 전환

JSX
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와의 조합

JSX
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는 값 자체를 지연시킵니다.

JSX
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
JSX
// useTransition — setState를 transition으로 감쌈
const [isPending, startTransition] = useTransition();
startTransition(() => setQuery(value));

// useDeferredValue — 값의 반영을 지연
const deferredValue = useDeferredValue(externalValue);

주의사항

transition 안에서는 동기 코드만

JSX
startTransition(() => {
  // 동기 코드만 가능
  setResults(filterItems(query));

  // 비동기 코드는 transition으로 처리되지 않음
  // fetch('/api').then(...) // 이건 transition이 아님
});

transition은 디바운싱이 아님

JSX
// 디바운싱: 마지막 입력 후 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으로 감싸는 것이 올바른 사용입니다.

댓글 로딩 중...