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:
'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:
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
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.
errorTitlemakes 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:
'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