Query Boundary

Error boundary and suspense wrapper for TanStack Query with automatic error recovery and loading states.

npx shadcn@latest add @shuip/tsq-query-boundary
pnpm dlx shadcn@latest add @shuip/tsq-query-boundary
bun x shadcn@latest add @shuip/tsq-query-boundary
'use client';

import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { AlertTriangle, RefreshCcw } from 'lucide-react';
import * as React from 'react';
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
import { Button } from '@/components/ui/button';

interface QueryBoundaryProps {
  children: React.ReactNode;
  queryKeys?: string[];
  loadingFallback?: React.ReactNode;
  errorFallback?: (props: FallbackProps) => React.ReactNode;
}

export function QueryBoundary({
  children,
  queryKeys = [],
  loadingFallback = 'Loading...',
  errorFallback,
}: QueryBoundaryProps) {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={(props) =>
            errorFallback ? errorFallback(props) : <DefaultErrorFallback {...props} queryKeys={queryKeys} />
          }
        >
          <React.Suspense fallback={loadingFallback}>{children}</React.Suspense>
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

export function DefaultErrorFallback({
  error,
  resetErrorBoundary,
  queryKeys = [],
}: FallbackProps & { queryKeys?: string[] }) {
  return (
    <div
      role='alert'
      className='p-6 rounded-lg border border-destructive/30 bg-destructive/5 flex flex-col items-center justify-center space-y-4 text-center'
    >
      <AlertTriangle className='text-destructive size-12' />
      <div>
        <h4 className='text-lg font-semibold mb-2'>Oops, something went wrong!</h4>
        <p className='text-muted-foreground'>{error instanceof Error ? error.message : 'Unexpected error'}</p>
        {queryKeys.length > 0 && (
          <p className='text-xs text-muted-foreground mt-2'>
            Concerned quer{queryKeys.length > 1 ? 'ies' : 'y'}: {queryKeys.join(', ')}
          </p>
        )}
      </div>
      <Button onClick={resetErrorBoundary} variant='outline' className='gap-2'>
        <RefreshCcw className='size-4' />
        Retry
      </Button>
    </div>
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

Loading...

QueryBoundary is a wrapper component that combines TanStack Query's error handling with React Suspense for comprehensive query state management. It automatically catches query errors, displays loading states, and provides retry functionality.

The component eliminates the need to manually wrap components with multiple boundary providers. It handles both synchronous errors and async query failures with a unified interface and consistent error recovery patterns.

Built-in features

  • Automatic error catching: Captures both sync and async query errors
  • Suspense integration: Built-in loading state management with React Suspense
  • Error recovery: One-click retry functionality with query reset
  • Customizable fallbacks: Override default loading and error UI components
  • Query key tracking: Optional debugging information for failed queries

Examples

Error Handling

Loading...
'use client';

import { QueryClient, QueryClientProvider, useSuspenseQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { QueryBoundary } from '@/components/ui/shuip/tanstack-query/query-boundary';

const PROFILE_QUERY_KEYS = ['profile', 'ada'];

const wait = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function fetchProfile(shouldFail: boolean) {
  await wait(800);
  if (shouldFail) throw new Error('The profile request failed.');
  return { name: 'Ada Lovelace', email: 'ada@example.com' };
}

export default function QueryBoundaryErrorHandlingExample() {
  const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }));
  const [requested, setRequested] = useState(false);
  const [shouldFail, setShouldFail] = useState(true);

  const toggleRequest = () => {
    if (requested) queryClient.removeQueries({ queryKey: PROFILE_QUERY_KEYS });
    setRequested(!requested);
  };

  return (
    <QueryClientProvider client={queryClient}>
      <div className='flex w-full max-w-sm flex-col items-center gap-6'>
        <div className='flex w-full items-center justify-between gap-4'>
          <div className='flex items-center gap-2'>
            <Checkbox
              id='fail-next-request'
              checked={shouldFail}
              onCheckedChange={(checked) => setShouldFail(checked === true)}
            />
            <Label htmlFor='fail-next-request'>Fail the next request</Label>
          </div>
          <Button variant={requested ? 'outline' : 'default'} onClick={toggleRequest}>
            {requested ? 'Reset' : 'Load profile'}
          </Button>
        </div>
        <div className='flex min-h-24 w-full items-center justify-center'>
          {requested ? (
            <QueryBoundary
              queryKeys={PROFILE_QUERY_KEYS}
              loadingFallback={
                <div role='status' className='flex items-center gap-2 text-muted-foreground'>
                  <LoaderCircle className='size-4 animate-spin' />
                  Loading profile...
                </div>
              }
            >
              <Profile shouldFail={shouldFail} />
            </QueryBoundary>
          ) : (
            <p className='text-sm text-muted-foreground'>Nothing requested yet.</p>
          )}
        </div>
      </div>
    </QueryClientProvider>
  );
}

function Profile({ shouldFail }: { shouldFail: boolean }) {
  const { data } = useSuspenseQuery({
    queryKey: PROFILE_QUERY_KEYS,
    queryFn: () => fetchProfile(shouldFail),
  });

  return (
    <div className='w-full rounded-lg border bg-muted/50 p-4'>
      <p className='font-semibold'>{data.name}</p>
      <p className='text-sm text-muted-foreground'>{data.email}</p>
    </div>
  );
}

Default

Loading...
'use client';

import { QueryClient, QueryClientProvider, useSuspenseQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { QueryBoundary } from '@/components/ui/shuip/tanstack-query/query-boundary';

const PROFILE_QUERY_KEY = ['profile'];

const wait = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function fetchProfile() {
  await wait(800);
  return { name: 'Ada Lovelace', email: 'ada@example.com' };
}

export default function QueryBoundaryExample() {
  const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }));
  const [requested, setRequested] = useState(false);

  const toggleRequest = () => {
    if (requested) queryClient.removeQueries({ queryKey: PROFILE_QUERY_KEY });
    setRequested(!requested);
  };

  return (
    <QueryClientProvider client={queryClient}>
      <div className='flex w-full max-w-sm flex-col items-center gap-6'>
        <Button variant={requested ? 'outline' : 'default'} onClick={toggleRequest}>
          {requested ? 'Reset' : 'Load profile'}
        </Button>
        <div className='flex min-h-24 w-full items-center justify-center'>
          {requested ? (
            <QueryBoundary
              queryKeys={PROFILE_QUERY_KEY}
              loadingFallback={
                <div role='status' className='flex items-center gap-2 text-muted-foreground'>
                  <LoaderCircle className='size-4 animate-spin' />
                  Loading profile...
                </div>
              }
            >
              <Profile />
            </QueryBoundary>
          ) : (
            <p className='text-sm text-muted-foreground'>Nothing requested yet.</p>
          )}
        </div>
      </div>
    </QueryClientProvider>
  );
}

function Profile() {
  const { data } = useSuspenseQuery({ queryKey: PROFILE_QUERY_KEY, queryFn: fetchProfile });

  return (
    <div className='w-full rounded-lg border bg-muted/50 p-4'>
      <p className='font-semibold'>{data.name}</p>
      <p className='text-sm text-muted-foreground'>{data.email}</p>
    </div>
  );
}

Basic usage

Wrap components that use TanStack Query hooks:

import { QueryBoundary } from '@/components/ui/shuip/tanstack-query/query-boundary';

export default function App() {
  return (
    <QueryBoundary 
      queryKeys={['users']}
      loadingFallback={<div>Loading users...</div>}
    >
      <UsersList />
    </QueryBoundary>
  );
}

function UsersList() {
  const { data } = useSuspenseQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
  });

  return <div>{data.map(user => user.name)}</div>;
}

Custom error handling

Override the default error fallback for custom error UI:

function CustomErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div className="text-center p-8">
      <h3 className="text-lg font-semibold mb-2">Something went wrong</h3>
      <p className="text-muted-foreground mb-4">{error.message}</p>
      <button onClick={resetErrorBoundary}>
        Try again
      </button>
    </div>
  );
}

<QueryBoundary 
  queryKeys={['users', 'posts']}
  errorFallback={CustomErrorFallback}
  loadingFallback={<div>Loading...</div>}
>
  <Dashboard />
</QueryBoundary>

Default error fallback

The built-in error fallback includes:

  • Error icon and message display
  • Query key debugging information
  • One-click retry functionality
  • Consistent styling with your design system

Props

Prop

Type

On this page