Workbench

Loading and error boundaries

A QueryBoundary per section, so one failed request shows a retry button instead of blanking the page.

From: the resort website.

The problem

useSuspenseQuery doesn't return isLoading or isError. While it loads, it suspends to the nearest <Suspense>. When it fails, it throws to the nearest error boundary. Without boundaries close to the section, one failed request (say, testimonials) takes the whole page down to the route's error.tsx, or worse.

The resort's home page has many independent sections: about, highlights, rooms, gallery, testimonials and more. If one of them fails, the rest should still show.

QueryBoundary

One component that pairs Suspense with an error boundary wired to TanStack Query's reset:

components/shared/query-boundary.tsx
'use client';

import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { type ReactNode, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

import { SectionErrorFallback } from '@/components/shared/section-error-fallback';

interface QueryBoundaryProps {
  children: ReactNode;
  /** Loading placeholder shown while the query is pending. */
  fallback: ReactNode;
  /** Short label of the content, woven into the error message. */
  errorTitle?: string;
  /** Classes to size the error fallback to match the section. */
  errorClassName?: string;
}

export function QueryBoundary({ children, fallback, errorTitle, errorClassName }: QueryBoundaryProps) {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          fallbackRender={({ resetErrorBoundary }) => (
            <SectionErrorFallback
              className={errorClassName}
              onRetry={resetErrorBoundary}
              title={errorTitle}
            />
          )}
          onReset={reset}
        >
          <Suspense fallback={fallback}>{children}</Suspense>
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

Why QueryErrorResetBoundary

A failed query keeps its error in the cache. If "Try again" only reset the React error boundary, the component would re-render, read the same cached error, and throw again straight away. onReset={reset} clears the query's error too, so the retry actually refetches.

Using it

Each section splits into an outer component with the boundary and an inner one that reads the data:

about-section.tsx
export function AboutSection() {
  return (
    <QueryBoundary errorTitle="about us" fallback={<AboutSectionSkeleton />}>
      <AboutSectionContent />
    </QueryBoundary>
  );
}

function AboutSectionContent() {
  const { data } = useSuspenseQuery(homeContentQueryOptions());
  const learnAboutUs = data.data.learnAboutUs;

  if (!learnAboutUs) {
    return null;
  }
  // ...
}
  • The skeleton matches the section's shape, so nothing jumps when the content arrives.
  • The inner component has no loading or error branches. It only describes the content.
  • Empty is still handled. A CMS section with no content returns null.

I added the boundary to the public sections in one change: the home page sections (including the rooms preview), about us, contact, events, FAQs and blogs.

The fallback

components/shared/section-error-fallback.tsx (simplified)
export function SectionErrorFallback({ title, onRetry, className }: SectionErrorFallbackProps) {
  const heading = title ? `We couldn't load ${title}` : "We couldn't load this content";

  return (
    <div className={cn('flex min-h-80 flex-col items-center justify-center gap-4', className)} role="alert">
      <p>{heading}</p>
      <p>Something went wrong while loading this content. Please check your connection and try again.</p>
      {onRetry ? <Button onClick={onRetry}>Try again</Button> : null}
    </div>
  );
}
  • role="alert" so screen readers announce the failure.
  • The raw error isn't shown. It could leak API details, and a visitor can't act on it anyway.
  • errorTitle makes the message specific: "We couldn't load about us" tells the visitor what's missing.

The route-level net

The same fallback is also used by error.tsx for anything a section boundary doesn't catch:

app/[locale]/(public)/error.tsx
'use client';

export default function PublicError({ reset }: PublicErrorProps) {
  return <SectionErrorFallback className="min-h-[60vh] py-24" onRetry={reset} title="this page" />;
}

The dashboard route group has its own error.tsx with the same fallback.

Last updated on

On this page