← Back to articles
March 20269 min read

How to Handle Loading, Empty, and Error States in Modern Dashboards

Skeleton loaders, empty states, error recovery, wallet disconnected states, and API failure handling. Practical React patterns for Web3 and analytics dashboards.

UXReactDashboardsWeb3

Why UI states matter in dashboards

A dashboard that shows a blank screen while loading feels broken. One that shows "No data" without context feels abandoned. One that shows a generic "Error" with no recovery path feels untrustworthy. Loading, empty, and error states are where users decide whether your product is professional or amateur.

In Web3 dashboards, these states are more critical because data fetching is slower and less reliable than traditional APIs.

Loading skeletons

Skeleton loaders match the dimensions of the final content. They prevent layout shift and communicate that data is coming.

// components/ui/Skeleton.tsx
export function Skeleton({ className }: { className?: string }) {
  return <div className={`animate-pulse rounded-lg bg-white/5 ${className}`} />;
}

export function BalanceCardSkeleton() {
  return (
    <div className="rounded-xl border border-white/10 p-6">
      <Skeleton className="h-4 w-24" />
      <Skeleton className="mt-3 h-8 w-40" />
      <Skeleton className="mt-2 h-4 w-20" />
    </div>
  );
}

export function TableSkeleton({ rows = 5 }: { rows?: number }) {
  return (
    <div className="space-y-3">
      {Array.from({ length: rows }).map((_, i) => (
        <Skeleton key={i} className="h-12 w-full" />
      ))}
    </div>
  );
}

Distinguish initial loading (full skeleton) from background refresh (subtle indicator):

export function DataPanel({ queryKey, fetcher, children }: DataPanelProps) {
  const { data, isLoading, isFetching, isError, refetch } = useQuery({
    queryKey,
    queryFn: fetcher,
  });

  if (isLoading) return <PanelSkeleton />;
  if (isError) return <ErrorState onRetry={refetch} />;
  if (!data?.length) return <EmptyState message="No transactions yet" />;

  return (
    <div className="relative">
      {isFetching && <RefreshDot className="absolute top-2 right-2" />}
      {children(data)}
    </div>
  );
}

Empty states

Empty states should explain why there is no data and what the user can do next.

// components/ui/EmptyState.tsx
interface EmptyStateProps {
  icon?: React.ReactNode;
  title: string;
  description: string;
  action?: { label: string; onClick: () => void };
}

export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
  return (
    <div className="flex flex-col items-center rounded-xl border border-dashed border-white/10 p-12 text-center">
      {icon && <div className="mb-4 text-gray-500">{icon}</div>}
      <h3 className="text-lg font-medium text-white">{title}</h3>
      <p className="mt-2 max-w-sm text-sm text-gray-400">{description}</p>
      {action && (
        <button onClick={action.onClick} className="mt-6 btn-primary">
          {action.label}
        </button>
      )}
    </div>
  );
}

Context-specific empty states for Web3:

- No wallet connected: "Connect your wallet to see your portfolio" - Empty portfolio: "No tokens found. Your wallet may be empty or on an unsupported chain." - No transactions: "No transactions yet. Activity will appear here after your first transfer." - No search results: "No tokens match your search. Try a different name or address."

Error messages and retry actions

Show specific error messages, not generic ones. Always offer a retry action.

// components/ui/ErrorState.tsx
export function ErrorState({
  message = 'Failed to load data',
  onRetry,
}: {
  message?: string;
  onRetry?: () => void;
}) {
  return (
    <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-8 text-center">
      <p className="text-red-300">{message}</p>
      {onRetry && (
        <button onClick={onRetry} className="mt-4 rounded-lg bg-white/10 px-4 py-2 text-sm text-white hover:bg-white/20">
          Try again
        </button>
      )}
    </div>
  );
}

Wallet disconnected states

When a wallet disconnects mid-session, panels should gracefully degrade — not crash or show stale data.

export function PortfolioPanel() {
  const { isConnected, address } = useAccount();

  if (!isConnected) {
    return (
      <EmptyState
        title="Wallet not connected"
        description="Connect your wallet to view your portfolio balances and positions."
        action={{ label: 'Connect Wallet', onClick: openConnectModal }}
      />
    );
  }

  return <ConnectedPortfolio address={address!} />;
}

API failure states

Handle rate limits, timeouts, and server errors differently:

function getErrorMessage(error: unknown): string {
  if (error instanceof ApiError) {
    if (error.status === 429) return 'Too many requests. Data will refresh shortly.';
    if (error.status >= 500) return 'Service temporarily unavailable. Please try again.';
  }
  return 'Failed to load data. Check your connection and try again.';
}

FAQ

**Should I show skeletons or spinners?** Skeletons for content areas (cards, tables, charts). Spinners only for discrete actions (button clicks, form submissions).

**How long before showing an error?** Set a reasonable timeout (10–15 seconds) before showing a timeout error. Show the skeleton until then.

**Should empty states have illustrations?** Simple icons yes, elaborate illustrations no. Keep it lightweight and fast-loading.

**How do I handle partial failures?** In a dashboard with multiple data panels, each panel handles its own error state independently. One failed API should not break the entire page.

Want to work together? I build Web3 dashboards and DeFi interfaces.