C.W.K.
Stream
Lesson 02 of 07 · published

Suspense — 로딩 state 가 사는 자리

~13 min · suspense, boundary, fallback

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Suspense 는 suspension 잡는 경계. 로딩 UI 가 살 자리에 둬. 실제 페치하는 컴포넌트는 로딩에 대해 전혀 안 알아도 됨.

Mental model

컴포넌트 트리를 <Suspense fallback={...}> 경계의 계층으로 상상. 어떤 descendant 가 use(somePendingPromise) 호출하면 React 가 'throw' (suspend), 트리 올라가며 가장 가까운 Suspense 찾고, 그 경계의 fallback 렌더. Promise resolve 시 React 가 suspend 된 subtree 재렌더, fallback 이 진짜 컨텐츠로 교체.

경계 어디 둘지 결정. 페이지 상단 경계 = 페이지 전체 spinner; 단일 패널 주변 경계 = 그 패널만 spinner, 나머지는 정상 렌더.

Granularity

거친: <Suspense fallback={<FullPageSpinner />}><App /></Suspense>. 쉽지만 못생긴 UX.

고운: sidebar 주변 Suspense, 메인 패널 주변 또 하나, 각 리스트 row 주변. 페이지의 각 부분이 자기 페이스로 interactive 해지는 아름다운 스트리밍 UX.

현실적: 전략적 경계 두엇. 페이지 shell 즉시 렌더, 데이터 섹션 주변 Suspense 가 준비되면 스트림 in. 첫날부터 과도하게 granularize 시도 마.

중첩된 Suspense

경계가 자연스럽게 nest. 안쪽 경계가 자기 descendant 의 suspension 먼저 잡음; 안쪽 경계 없으면 바깥쪽이 인계. 의미 있는 가장 작은 영역으로 spinner 범위 잡으려고 nesting 사용.

Transition 트릭

사용자 액션 (검색, 필터 변경) 이 새 데이터 트리거하면 페이지가 보통 Suspense fallback 으로 flash. useTransition (Track 7) 이 React 한테 '이 state 변경은 비긴급; 새 UI 준비될 때까지 옛 UI 보여줘' 라고 말함. 결과: spinner flash 대신 부드러운 swap.

경계를 의미 있는 가장 작은 영역에 둬. 경계 좁을수록 데이터 로드 중 페이지 더 많이 interactive 유지. 과도-granular 는 noise 추가; 부족-granular 는 스트리밍 승리 낭비. 사용자가 UI 의 '섹션' 으로 인식하는 방식에 매치되는 경계 골라.

Code

전략적 Suspense 배치 가진 페이지·tsx
import { Suspense } from "react";
import { ConversationList } from "./ConversationList";
import { ConversationDetail } from "./ConversationDetail";

function ChatPage({ activeId }: { activeId: string }) {
  return (
    <div className="flex h-screen">
      {/* Sidebar 가 독립적으로 stream in */}
      <aside className="w-64 border-r">
        <Suspense fallback={<SidebarSkeleton />}>
          <ConversationList />
        </Suspense>
      </aside>

      {/* 메인 패널이 독립적으로 stream */}
      <main className="flex-1">
        <Suspense fallback={<DetailSkeleton />}>
          <ConversationDetail id={activeId} />
        </Suspense>
      </main>
    </div>
  );
}

// 페이지 shell, 헤더, 네비게이션 — 다 즉시 렌더.
// Sidebar 와 메인 패널이 데이터 resolve 시 채워짐.
중첩 Suspense — 안쪽이 먼저 잡음·tsx
function Profile({ userId }: { userId: string }) {
  return (
    <Suspense fallback={<p>Loading profile…</p>}>
      <UserHeader userId={userId} />
      {/* 중첩 경계가 더 느린 데이터를 자기 로딩 UI 로 범위 잡음 */}
      <Suspense fallback={<p>Loading posts…</p>}>
        <UserPosts userId={userId} />
      </Suspense>
    </Suspense>
  );
}

// UserHeader resolve → 렌더. UserPosts 는 아직 로딩 → 중첩 경계의 fallback 만
// 보이고 헤더는 보이는 채로 유지.

External links

Exercise

각 pane 이 다른 데이터 페치하는 2-pane 레이아웃 (sidebar + main) 빌드. Suspense 경계 배치해서 두 pane 이 독립적으로 로드. 그 다음 일부러 fetch 하나 느리게 (3초 후 resolve 하는 Promise) 만들고 다른 pane 이 안 기다리고 렌더하는지 확인. 메인 pane 안에 보조 widget 주변 중첩 Suspense 추가해서 계층 동작 봐.
Hint
느린 fetch 는 진짜 fetch 를 new Promise((r) => setTimeout(() => fetch(...).then(j => r(j)), 3000)) 으로 감싸. 빠른 pane 은 즉시 나타나야; 느린 pane 의 skeleton 은 timeout 까지 유지.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.