Skip to Content

React Suspense, complete.

ErrorBoundary, Suspense, Delay 등 — 성공한 케이스에만 집중하세요.

<ErrorBoundary />

fallback, resetKeys, onError, shouldCatch를 갖춘 선언적 에러 처리. 특정 에러만 선택적으로 캐치.

<ErrorBoundary
  shouldCatch={NetworkError}
  fallback={({ error, reset }) => <ErrorUI error={error} onRetry={reset} />}
>
  <App />
</ErrorBoundary>
자세히 보기

<ErrorBoundaryGroup />

여러 ErrorBoundary를 한번에 리셋. prop drilling 불필요.

자세히 보기

<Suspense clientOnly />

SSR 안전한 Suspense 바운더리. Next.js에서 dynamic()이나 useEffect 가드 없이 하이드레이션 불일치 방지.

자세히 보기

<Delay ms={200} />

로딩 상태 깜빡임 방지. 실제로 시간이 걸릴 때만 스피너 표시. render props로 fade-in 지원.

자세히 보기

<DefaultPropsProvider />

Suspense와 Delay의 전역 기본 fallback 설정. 컴포넌트별로 오버라이드 가능.

자세히 보기

<SuspenseQuery />

JSX로 선언적 데이터 페칭. 훅도, 래퍼 컴포넌트도 불필요. TanStack Query와 함께 동작.

<SuspenseQuery {...userQueryOptions()}>
  {({ data: user }) => <UserProfile user={user} />}
</SuspenseQuery>
자세히 보기

<ClientOnly />

클라이언트 사이드에서만 컴포넌트 렌더링. 서버/클라이언트 렌더링 경계 제어.

자세히 보기

Suspense + ive — React Suspense를 둘러싼 모든 것, 하나의 패키지로.

코드로 확인하세요

로딩, 에러, 복구가 어떻게 동작하는지.

대표적인 라이브러리인 TanStack Query로 Suspense 없이 코드를 작성한다면 이렇게 작성합니다.

import { useQuery } from '@tanstack/react-query'
const Page = () => {
const userQuery = useQuery(userQueryOptions())
const postsQuery = useQuery({
...postsQueryOptions(),
select: (posts) => posts.filter(({ isPublic }) => isPublic),
})
const promotionsQuery = useQuery(promotionsQueryOptions())
if (
userQuery.isLoading ||
postsQuery.isLoading ||
promotionsQuery.isLoading
) {
return 'loading...'
}
if (userQuery.isError || postsQuery.isError || promotionsQuery.isError) {
return 'error'
}
return (
<Fragment>
<UserProfile {...userQuery.data} />
{postsQuery.data.map((post) => (
<PostListItem key={post.id} {...post} />
))}
{promotionsQuery.data.map((promotion) => (
<Promotion key={promotion.id} {...promotion} />
))}
</Fragment>
)
}

Suspense를 사용하면 타입적으로 간결해지지만 컴포넌트의 깊이는 깊어집니다.

import { ErrorBoundary, Suspense } from '@suspensive/react'
import { useSuspenseQuery } from '@tanstack/react-query'
const Page = () => (
<ErrorBoundary fallback="error">
<Suspense fallback="loading...">
<UserInfo userId={userId} />
<PostList userId={userId} />
<PromotionList userId={userId} />
</Suspense>
</ErrorBoundary>
)
const UserInfo = ({ userId }) => {
const { data: user } = useSuspenseQuery(userQueryOptions())
return <UserProfile {...user} />
}
const PostList = ({ userId }) => {
const { data: posts } = useSuspenseQuery({
...postsQueryOptions(),
select: (posts) => posts.filter(({ isPublic }) => isPublic),
})
return posts.map((post) => <PostListItem key={post.id} {...post} />)
}
const PromotionList = ({ userId }) => {
const { data: promotions } = useSuspenseQuery(promotionsQueryOptions())
return promotions.map((promotion) => (
<PromotionListItem key={promotion.id} {...promotion} />
))
}

Suspensive를 사용하면 모든 것이 같은 뎁스에 유지됩니다.

import { ErrorBoundary, Suspense } from '@suspensive/react'
import { SuspenseQuery } from '@suspensive/react-query'
const Page = () => (
<ErrorBoundary fallback="error">
<Suspense fallback="loading...">
<SuspenseQuery {...userQueryOptions()}>
{({ data: user }) => <UserProfile {...user} />}
</SuspenseQuery>
<SuspenseQuery
{...postsQueryOptions()}
select={(posts) => posts.filter(({ isPublic }) => isPublic)}
>
{({ data: posts }) =>
posts.map((post) => <PostListItem key={post.id} {...post} />)
}
</SuspenseQuery>
<SuspenseQuery
{...promotionsQueryOptions()}
select={(promotions) => promotions.filter(({ isPublic }) => isPublic)}
>
{({ data: promotions }) =>
promotions.map((promotion) => (
<PromotionListItem key={promotion.id} {...promotion} />
))
}
</SuspenseQuery>
</Suspense>
</ErrorBoundary>
)

이미 react-error-boundary를 사용 중이신가요?

좋은 선택입니다.

react-error-boundary는 React에서 선언적 에러 처리를 실용적으로 만든 훌륭한 라이브러리입니다. Suspensive의 ErrorBoundary는 프로덕션에서 필요했던 것들을 몇 가지 더했습니다 — shouldCatch로 특정 에러만 캐치하고, ErrorBoundaryGroup 으로 여러 바운더리를 한번에 리셋하고, fallback 에러를 안전하게 부모로 전파합니다. 로딩 상태, SSR 하이드레이션, 로딩 깜빡임 UX, 데이터 페칭도 함께 해결해야 했기에 — 이것들도 하나의 패키지에 담았습니다.

Suspensivereact-error-boundary@sentry/reactDIY (Class Component)
Error BoundaryshouldCatch
ErrorBoundaryGroup
useErrorBoundaryFallbackProps
Safe fallback error propagation✓ To parent✗ Recursive✗ Recursive
TypeScript error type inference✓ Via shouldCatch
useErrorBoundary hook
Fallback UI with error & reset
resetKeys
onReset callback
onError callback
HOC support✓ ErrorBoundary.with✓ withErrorBoundary✓ withErrorBoundary
Declarative API
Async RenderingSSR-safe Suspense (clientOnly)
Flash-of-loading prevention (Delay)
Global default fallbacks (DefaultPropsProvider)
Declarative data fetching (SuspenseQuery)
Client-only rendering (ClientOnly)

Playground

코드를 수정하고 Suspensive를 직접 체험해보세요.

import {
  ErrorBoundary,
  ErrorBoundaryGroup,
  Suspense,
  Delay,
} from '@suspensive/react'
import { SuspenseQuery } from '@suspensive/react-query'
import { QueryErrorResetBoundary, queryOptions } from '@tanstack/react-query'

// Define query options — same pattern as TanStack Query
const userQueryOptions = () =>
  queryOptions({
    queryKey: ['user'],
    queryFn: async () => {
      await new Promise((r) => setTimeout(r, 1200))
      // Randomly fail to demonstrate ErrorBoundary
      if (Math.random() > 0.2) return { name: 'Alex Chen', role: 'Maintainer' }
      throw new Error('Network timeout')
    },
    retry: false,
  })

const statsQueryOptions = () =>
  queryOptions({
    queryKey: ['stats'],
    queryFn: async () => {
      await new Promise((r) => setTimeout(r, 800))
      if (Math.random() > 0.15) return { downloads: '34,326', stars: '1.5k' }
      throw new Error('API rate limit')
    },
    retry: false,
  })

export const Example = () => (
  <div
    style={{
      background: '#0a0a0a',
      color: '#e5e5e5',
      fontFamily: 'system-ui, sans-serif',
      padding: 24,
    }}
  >
    {/* QueryErrorResetBoundary ensures queries re-fetch on ErrorBoundary reset */}
    <QueryErrorResetBoundary>
      {({ reset: resetQueries }) => (
        <ErrorBoundaryGroup>
          <div
            style={{
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'space-between',
              marginBottom: 24,
            }}
          >
            <h2 style={{ fontSize: 16, fontWeight: 600, opacity: 0.5 }}>
              Dashboard
            </h2>
            <ErrorBoundaryGroup.Consumer>
              {({ reset }) => (
                <button
                  onClick={() => {
                    resetQueries()
                    reset()
                  }}
                  style={buttonStyle}
                >
                  Retry All
                </button>
              )}
            </ErrorBoundaryGroup.Consumer>
          </div>

          <div
            style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}
          >
            <ErrorBoundary
              onReset={resetQueries}
              fallback={({ error, reset }) => (
                <Card>
                  <p
                    style={{ fontSize: 13, fontWeight: 600, color: '#ef4444' }}
                  >
                    Failed to load
                  </p>
                  <p style={{ fontSize: 12, opacity: 0.4, marginTop: 4 }}>
                    {error.message}
                  </p>
                  <button
                    onClick={reset}
                    style={{ ...smallButtonStyle, marginTop: 8 }}
                  >
                    Retry
                  </button>
                </Card>
              )}
            >
              <Suspense
                fallback={
                  <Card>
                    <Skeleton />
                  </Card>
                }
              >
                <SuspenseQuery {...userQueryOptions()}>
                  {({ data: user }) => (
                    <Card>
                      <p style={{ fontSize: 15, fontWeight: 600 }}>
                        {user.name}
                      </p>
                      <p style={{ fontSize: 12, opacity: 0.4, marginTop: 2 }}>
                        {user.role}
                      </p>
                    </Card>
                  )}
                </SuspenseQuery>
              </Suspense>
            </ErrorBoundary>

            <ErrorBoundary
              onReset={resetQueries}
              fallback={({ error, reset }) => (
                <Card>
                  <p
                    style={{ fontSize: 13, fontWeight: 600, color: '#ef4444' }}
                  >
                    Failed to load
                  </p>
                  <button
                    onClick={reset}
                    style={{ ...smallButtonStyle, marginTop: 8 }}
                  >
                    Retry
                  </button>
                </Card>
              )}
            >
              <Suspense
                fallback={
                  <Card>
                    <Skeleton />
                  </Card>
                }
              >
                <SuspenseQuery {...statsQueryOptions()}>
                  {({ data: stats }) => (
                    <Card>
                      <p
                        style={{
                          fontSize: 22,
                          fontWeight: 700,
                          letterSpacing: -1,
                        }}
                      >
                        {stats.downloads}
                      </p>
                      <p style={{ fontSize: 12, opacity: 0.4, marginTop: 2 }}>
                        monthly downloads
                      </p>
                    </Card>
                  )}
                </SuspenseQuery>
              </Suspense>
            </ErrorBoundary>
          </div>
        </ErrorBoundaryGroup>
      )}
    </QueryErrorResetBoundary>
  </div>
)

// --- UI Primitives ---
const Card = ({ children }: { children: React.ReactNode }) => (
  <div
    style={{
      background: '#111',
      borderRadius: 10,
      padding: 16,
      border: '1px solid rgba(255,255,255,0.06)',
      minHeight: 100,
    }}
  >
    {children}
  </div>
)

// Delay prevents flash-of-loading — skeleton fades in only after 200ms
const Skeleton = () => (
  <Delay ms={200}>
    {({ isDelayed }) => (
      <div style={{ opacity: isDelayed ? 1 : 0, transition: 'opacity 300ms' }}>
        <div
          style={{
            background: '#1a1a1a',
            borderRadius: 4,
            height: 14,
            width: '60%',
            marginBottom: 8,
          }}
        />
        <div
          style={{
            background: '#1a1a1a',
            borderRadius: 4,
            height: 14,
            width: '40%',
          }}
        />
      </div>
    )}
  </Delay>
)

const buttonStyle = {
  background: '#111',
  color: '#e5e5e5',
  border: '1px solid rgba(255,255,255,0.1)',
  borderRadius: 6,
  padding: '6px 14px',
  fontSize: 13,
  cursor: 'pointer',
} as const
const smallButtonStyle = {
  background: '#111',
  color: '#e5e5e5',
  border: '1px solid rgba(255,255,255,0.1)',
  borderRadius: 4,
  padding: '4px 10px',
  fontSize: 12,
  cursor: 'pointer',
} as const

에러는 의도된 것입니다 — Retry를 눌러 ErrorBoundary의 복구를 체험해보세요.

설정 없이 바로 시작, 점진적 도입 가능, Next.js 등 모든 React 앱에서 동작.

로딩과 에러는 Suspensive에 맡기고, 성공한 케이스에 집중하세요.

수정된 날짜: