blog

React의 세 가지 비동기 UI 도구: Suspense, startTransition, useDeferredValue

Front-end / React#React#Suspense#useTransition#useDeferredValue#Concurrent

React 18의 Concurrent 기능인 Suspense, startTransition, useDeferredValue는 모두 비동기 작업 중에도 UI를 자연스럽게 유지하려는 도구지만, 각자 해결하는 문제가 다르다.

한눈에 보기

항목SuspensestartTransitionuseDeferredValue
하는 일준비 안 된 영역 대신 fallback 표시상태 업데이트를 비긴급으로 표시값의 반영을 늦춘 사본 제공
다루는 대상렌더링 결과상태 업데이트값
이미 보이는 내용일반 업데이트로 suspend되면 fallback으로 교체유지유지
진행 중 상태-isPending (useTransition)value !== deferredValue로 판단
주요 사용처초기 로딩, lazy, 스트리밍 SSR페이지·필터·정렬·탭 변경검색어, 슬라이더 등 빠르게 바뀌는 입력

1. Suspense: "준비될 때까지 대신 보여줘"

자식이 렌더링 중 데이터를 기다리며 suspend하면, 가장 가까운 Suspense 경계가 fallback을 보여주고 준비되면 원래 내용을 그린다.

const UserList = () => {
  const { data } = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers });

  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

const Page = () => (
  <Suspense fallback={<TableSkeleton />}>
    <UserList />
  </Suspense>
);

suspend를 직접 구현할 일은 거의 없다. React.lazy, React의 use(), TanStack Query의 useSuspenseQuery, Next.js App Router의 async Server Component처럼 Suspense를 지원하는 데이터 소스가 처리한다.

경계는 필요한 곳에

suspend는 가장 가까운 경계까지 올라간다. 경계가 페이지 전체를 감싸면 테이블 하나 때문에 페이지 전체가 fallback으로 바뀐다.

// 테이블 영역만 fallback
<Page>
  <SearchInput />
  <Suspense fallback={<TableSkeleton />}>
    <DataTable />
  </Suspense>
</Page>

2. startTransition: "급하지 않은 업데이트야"

const [isPending, startTransition] = useTransition();

const handlePageChange = (page: number) => {
  startTransition(() => {
    setCurrentPage(page);
  });
};

return (
  <>
    {isPending && <OverlaySpinner />}
    <Suspense fallback={<TableSkeleton />}>
      <DataTable page={currentPage} />
    </Suspense>
  </>
);

일반 업데이트로 setCurrentPage(2)를 하면 새 페이지 데이터가 없어 DataTable이 suspend되고, 이미 보이던 테이블이 skeleton으로 바뀐다.

transition으로 감싸면 React는 새 화면을 백그라운드에서 준비하고, suspend되더라도 기존 테이블을 그대로 둔다. 그동안 isPending이 true이므로 오버레이 같은 가벼운 표시를 할 수 있고, 데이터가 준비되면 한 번에 교체한다.

  • 적합: 페이지네이션, 필터·정렬 변경, 무거운 화면 전환
  • 탭 전환: 탭 헤더는 바로 바뀌는데 내용은 이전 탭이 남아 있으면 어색할 수 있다. 탭 헤더 상태는 즉시 업데이트하고 내용만 transition으로 바꾸는 식으로 나누거나, isPending으로 진행 중임을 보여준다.
  • 주의: 텍스트 입력의 value 상태를 transition으로 바꾸면 안 된다. 입력은 항상 즉시 반영되어야 한다.

3. useDeferredValue: "값은 바로, 그 값을 쓰는 UI는 천천히"

const SearchPage = () => {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <SearchInput value={query} onChange={(e) => setQuery(e.target.value)} />

      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <Suspense fallback={<SearchSkeleton />}>
          <SearchResults query={deferredQuery} />
        </Suspense>
      </div>
    </>
  );
};

query는 즉시 바뀌어 입력창이 바로 반응한다. React는 먼저 이전 deferredQuery로 화면을 그린 뒤, 새 값으로 중단 가능한 백그라운드 렌더를 시도한다.

Suspense와 함께 쓸 때

첫 마운트에는 보여줄 이전 결과가 없으므로 fallback이 보인다. 그 이후에는 백그라운드 렌더가 suspend되어도 fallback 대신 이전 결과를 유지한다. 입력할 때마다 화면이 깜빡이지 않는다.

네트워크 요청은 줄어들까?

useDeferredValue는 렌더링 시점을 조절할 뿐, 요청을 제어하는 API가 아니다.

  • 원본 query로 요청하면 입력마다 요청이 생긴다.
  • deferredQuery로 요청하면, React가 중간 값을 건너뛴 만큼 요청이 줄어들 수 있다. 다만 React는 상황에 따라 중간 값을 모두 렌더할 수도 있으므로 "마지막 값으로 한 번만"을 보장하지는 않는다.

요청 수를 확실히 줄여야 한다면 debounce를 함께 쓴다.

startTransition vs useDeferredValue

둘 다 "이전 화면을 유지한 채 새 화면을 백그라운드에서 준비한다"는 점은 같다. 차이는 어디에 적용하느냐다.

startTransitionuseDeferredValue
상태를 업데이트하는 코드를 감싼다이미 받은 값을 감싼다
setter 호출 위치를 제어할 수 있을 때값이 props로 내려오는 등 업데이트 코드를 제어할 수 없을 때
isPending 제공원본 값과 비교해 판단
업데이트 코드를 감쌀 수 있다 → startTransition (페이지·필터·정렬)
값만 받아서 쓴다 / 입력은 즉시 반영돼야 한다 → useDeferredValue (검색어·슬라이더)

함께 쓰는 패턴

const SearchableList = () => {
  const [isPending, startTransition] = useTransition();
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const [filters, setFilters] = useState<Filters>({});

  const handleFilterChange = (nextFilters: Filters) => {
    startTransition(() => setFilters(nextFilters));
  };

  return (
    <div className="relative">
      {isPending && <OverlaySpinner />}
      <SearchInput value={query} onChange={(e) => setQuery(e.target.value)} />
      <FilterBar onChange={handleFilterChange} />
      <Suspense fallback={<TableSkeleton />}>
        <DataTable searchWord={deferredQuery} filters={filters} />
      </Suspense>
    </div>
  );
};
  • 검색 입력 → useDeferredValue
  • 필터 변경 → startTransition
  • 데이터 대기 영역 → Suspense

정리

  • Suspense: 기다리는 동안 무엇을 보여줄지(fallback)를 정한다.
  • startTransition: 업데이트를 비긴급으로 표시해 이전 화면을 유지할지를 정한다.
  • useDeferredValue: 어떤 값으로 렌더링할지를 늦춰 입력 반응성을 지킨다.

세 가지는 서로 대체 관계가 아니라 각자 다른 층의 문제를 푼다.

참고