Skip to Content

React Suspense, complete.

ErrorBoundary, Suspense, Delay, and more — so you can focus on the success case.

<ErrorBoundary />

Declarative error handling with fallback, resetKeys, onError, and shouldCatch for selective error catching.

<ErrorBoundary
  shouldCatch={NetworkError}
  fallback={({ error, reset }) => <ErrorUI error={error} onRetry={reset} />}
>
  <App />
</ErrorBoundary>
Learn more

<ErrorBoundaryGroup />

Reset multiple ErrorBoundaries at once. No prop drilling needed.

Learn more

<Suspense clientOnly />

SSR-safe Suspense boundary. Avoids hydration mismatches in Next.js without dynamic() or useEffect guards.

Learn more

<Delay ms={200} />

Prevent flash-of-loading-state. Show spinners only when loading actually takes time. Supports render props for fade-in.

Learn more

<DefaultPropsProvider />

Set global default fallbacks for Suspense and Delay. Override per-component when needed.

Learn more

<SuspenseQuery />

Declarative data fetching as JSX. No hooks, no wrapper components. Works with TanStack Query.

<SuspenseQuery {...userQueryOptions()}>
  {({ data: user }) => <UserProfile user={user} />}
</SuspenseQuery>
Learn more

<ClientOnly />

Render components only on the client side. Control server/client rendering boundaries.

Learn more

Already using react-error-boundary?

You’re on the right track.

react-error-boundary is a great library that made declarative error handling practical in React. Suspensive’s ErrorBoundary adds a few things we found useful in production — shouldCatch for catching only specific errors, ErrorBoundaryGroup for resetting multiple boundaries at once, and safe fallback error propagation. We also needed to solve loading states, SSR hydration, flash-of-loading UX, and data fetching — so we built those too, all in one package.

Suspensivereact-error-boundaryReact Class
Error BoundarySelective error catching (shouldCatch)
Group reset (ErrorBoundaryGroup)
useErrorBoundaryFallbackProps
Safe fallback error propagation
Declarative ErrorBoundary
resetKeys
Basic error catching
Async RenderingSSR-safe Suspense (clientOnly)
Flash-of-loading prevention (Delay)
Global default fallbacks (DefaultPropsProvider)
Declarative data fetching (SuspenseQuery)
Client-only rendering (ClientOnly)

See the full comparison →

See it in action

How loading, errors, and recovery actually work.

Without Suspense, a typical TanStack Query page looks like this.

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 makes it type-safe. But hooks force you to split components.

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} />
))
}

With Suspensive, everything stays at the same depth.

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>
)

Playground

Edit, break, and recover — all in the browser.

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.4) 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.3) 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 }}>
                        weekly 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

Focus on the success case — let Suspensive handle loading and errors.

Last updated on