Files
gridpilot.gg/apps/website/docs/STREAMLINED_STATE_HANDLING_DESIGN.md
2026-01-06 11:05:16 +01:00

22 KiB

Streamlined Error and Load State Handling Design

Overview

This document outlines a comprehensive design for standardizing error and loading state handling across all pages in the GridPilot website app using shared, user-friendly components.

Problem Statement

The current implementation has several inconsistencies:

  • Mixed state management approaches (hook-based vs manual useState)
  • Inconsistent naming (loading vs isLoading vs pending)
  • Different error UIs (full-screen, inline, modal, toast)
  • Various loading UIs (spinners, skeletons, text-only, full-screen)
  • No shared useDataFetching() hook
  • Error boundaries not used everywhere

Solution Architecture

1. Unified State Management Pattern

All pages will follow this consistent pattern:

// Standardized state interface
interface PageState<T> {
  data: T | null;
  isLoading: boolean;
  error: ApiError | null;
  retry: () => Promise<void>;
}

// Standardized hook signature
interface UseDataFetchingOptions<T> {
  queryKey: string[];
  queryFn: () => Promise<T>;
  enabled?: boolean;
  retryOnMount?: boolean;
}

// Standardized return type
interface UseDataFetchingResult<T> {
  data: T | null;
  isLoading: boolean;
  isFetching: boolean;
  error: ApiError | null;
  retry: () => Promise<void>;
  refetch: () => Promise<void>;
}

2. Component Architecture

apps/website/components/shared/
├── state/
│   ├── LoadingWrapper.tsx          # Main loading component with variants
│   ├── ErrorDisplay.tsx            # Standardized error display
│   ├── EmptyState.tsx              # Enhanced empty state
│   └── StateContainer.tsx          # Combined wrapper for all states
├── hooks/
│   └── useDataFetching.ts          # Unified data fetching hook
└── types/
    └── state.types.ts              # TypeScript interfaces

3. Shared Component Design

LoadingWrapper Component

Purpose: Provides consistent loading states with multiple variants

Variants:

  • spinner - Traditional loading spinner (default)
  • skeleton - Skeleton screens for better UX
  • full-screen - Centered in viewport
  • inline - Compact inline loading
  • card - Loading card placeholders

Props Interface:

interface LoadingWrapperProps {
  variant?: 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
  message?: string;
  className?: string;
  size?: 'sm' | 'md' | 'lg';
  /**
   * For skeleton variant - number of skeleton items to show
   */
  skeletonCount?: number;
  /**
   * For card variant - card layout configuration
   */
  cardConfig?: {
    height?: number;
    count?: number;
    className?: string;
  };
}

Usage Examples:

// Default spinner
<LoadingWrapper />

// Full-screen with custom message
<LoadingWrapper 
  variant="full-screen" 
  message="Loading race details..." 
/>

// Skeleton for list
<LoadingWrapper 
  variant="skeleton" 
  skeletonCount={5} 
/>

// Inline loading
<LoadingWrapper 
  variant="inline" 
  size="sm" 
  message="Saving..." 
/>

ErrorDisplay Component

Purpose: Standardized error display with consistent behavior

Props Interface:

interface ErrorDisplayProps {
  error: ApiError;
  onRetry?: () => void;
  variant?: 'full-screen' | 'inline' | 'card' | 'toast';
  /**
   * Show retry button (auto-detected from error.isRetryable())
   */
  showRetry?: boolean;
  /**
   * Show navigation buttons
   */
  showNavigation?: boolean;
  /**
   * Additional actions
   */
  actions?: Array<{
    label: string;
    onClick: () => void;
    variant?: 'primary' | 'secondary' | 'danger';
  }>;
  className?: string;
  /**
   * Hide technical details in development
   */
  hideTechnicalDetails?: boolean;
}

Features:

  • Auto-detects retryable errors
  • Shows user-friendly messages
  • Provides navigation options (back, home)
  • Displays technical details in development
  • Supports custom actions
  • Accessible (ARIA labels, keyboard navigation)

Usage Examples:

// Full-screen error
<ErrorDisplay 
  error={error} 
  onRetry={retry} 
  variant="full-screen" 
/>

// Inline error with custom actions
<ErrorDisplay 
  error={error}
  variant="inline"
  actions={[
    { label: 'Contact Support', onClick: () => router.push('/support') }
  ]}
/>

// Toast-style error
<ErrorDisplay 
  error={error}
  variant="toast"
  showNavigation={false}
/>

EmptyState Component

Purpose: Consistent empty/placeholder states

Props Interface:

interface EmptyStateProps {
  icon: LucideIcon;
  title: string;
  description?: string;
  action?: {
    label: string;
    onClick: () => void;
    icon?: LucideIcon;
  };
  variant?: 'default' | 'minimal' | 'full-page';
  className?: string;
  /**
   * Show illustration instead of icon
   */
  illustration?: 'racing' | 'league' | 'team' | 'sponsor';
}

Usage Examples:

// No races
<EmptyState
  icon={Calendar}
  title="No upcoming races"
  description="Join a league to see races here"
  action={{
    label: "Browse Leagues",
    onClick: () => router.push('/leagues')
  }}
/>

// No data with illustration
<EmptyState
  illustration="racing"
  title="No results found"
  description="Try adjusting your filters"
  variant="minimal"
/>

4. useDataFetching Hook

Purpose: Unified data fetching with built-in state management

Signature:

function useDataFetching<T>(
  options: UseDataFetchingOptions<T>
): UseDataFetchingResult<T>

Implementation Features:

  • Built-in retry logic
  • Error classification
  • Loading state management
  • Refetch capability
  • Integration with error boundaries
  • Automatic retry on mount for recoverable errors

Usage Example:

// Dashboard page
function DashboardPage() {
  const { 
    data, 
    isLoading, 
    error, 
    retry,
    refetch 
  } = useDataFetching({
    queryKey: ['dashboardOverview'],
    queryFn: () => dashboardService.getDashboardOverview(),
    retryOnMount: true,
  });

  if (isLoading) {
    return <LoadingWrapper variant="full-screen" />;
  }

  if (error) {
    return (
      <ErrorDisplay 
        error={error} 
        onRetry={retry}
        variant="full-screen"
      />
    );
  }

  if (!data) {
    return (
      <EmptyState
        icon={Activity}
        title="No dashboard data"
        description="Try refreshing the page"
        action={{ label: "Refresh", onClick: refetch }}
      />
    );
  }

  return <DashboardContent data={data} />;
}

5. StateContainer Component

Purpose: Combined wrapper that handles all states automatically

Props Interface:

interface StateContainerProps<T> {
  data: T | null;
  isLoading: boolean;
  error: ApiError | null;
  retry: () => Promise<void>;
  children: (data: T) => React.ReactNode;
  /**
   * Configuration for each state
   */
  config?: {
    loading?: {
      variant?: LoadingWrapperProps['variant'];
      message?: string;
    };
    error?: {
      variant?: ErrorDisplayProps['variant'];
      actions?: ErrorDisplayProps['actions'];
    };
    empty?: {
      icon: LucideIcon;
      title: string;
      description?: string;
      action?: EmptyStateProps['action'];
    };
  };
  className?: string;
}

Usage Example:

// Simplified page implementation
function LeagueDetailPage() {
  const { data, isLoading, error, retry } = useDataFetching({
    queryKey: ['league', leagueId],
    queryFn: () => leagueService.getLeague(leagueId),
  });

  return (
    <StateContainer
      data={data}
      isLoading={isLoading}
      error={error}
      retry={retry}
      config={{
        loading: { variant: 'skeleton', message: 'Loading league...' },
        error: { variant: 'full-screen' },
        empty: {
          icon: Trophy,
          title: 'League not found',
          description: 'The league may have been deleted',
          action: { label: 'Back to Leagues', onClick: () => router.push('/leagues') }
        }
      }}
    >
      {(leagueData) => <LeagueDetailContent league={leagueData} />}
    </StateContainer>
  );
}

6. Error Boundary Integration

EnhancedErrorBoundary Updates

The existing EnhancedErrorBoundary will be updated to:

  1. Support the new standardized error display
  2. Provide automatic retry functionality
  3. Support custom fallback components
  4. Integrate with the notification system
interface EnhancedErrorBoundaryProps {
  children: ReactNode;
  fallback?: ReactNode;
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
  onReset?: () => void;
  enableDevOverlay?: boolean;
  context?: Record<string, unknown>;
  /**
   * NEW: Auto-retry configuration
   */
  autoRetry?: {
    enabled: boolean;
    maxAttempts?: number;
    delay?: number;
  };
  /**
   * NEW: Error display variant
   */
  errorVariant?: ErrorDisplayProps['variant'];
}

ApiErrorBoundary Updates

The ApiErrorBoundary will be enhanced to:

  1. Handle both API errors and regular errors
  2. Support retry functionality
  3. Provide consistent UI across all API calls

7. Migration Strategy

Phase 1: Foundation (Week 1)

  1. Create new shared components in components/shared/state/
  2. Create useDataFetching hook
  3. Update TypeScript interfaces
  4. Add comprehensive tests

Phase 2: Core Pages (Week 2-3)

Update pages in priority order:

High Priority:

  • app/dashboard/page.tsx
  • app/leagues/[id]/LeagueDetailInteractive.tsx
  • app/races/[id]/RaceDetailInteractive.tsx

Medium Priority:

  • app/teams/[id]/TeamDetailInteractive.tsx
  • app/leagues/[id]/schedule/page.tsx
  • app/races/[id]/results/RaceResultsInteractive.tsx
  • app/races/[id]/stewarding/RaceStewardingInteractive.tsx

Lower Priority:

  • Sponsor pages
  • Profile pages
  • Other interactive components

Phase 3: Rollout (Week 4)

  1. Update remaining pages
  2. Add migration codemods for automation
  3. Update documentation
  4. Team training

Phase 4: Deprecation (Week 5-6)

  1. Remove old components
  2. Clean up legacy code
  3. Performance optimization

8. Migration Examples

Before (Current State)

// Dashboard page - inconsistent patterns
function DashboardPage() {
  const { data: dashboardData, isLoading, error } = useDashboardOverview();

  if (isLoading) {
    return (
      <main className="min-h-screen bg-deep-graphite flex items-center justify-center">
        <div className="text-white">Loading dashboard...</div>
      </main>
    );
  }

  if (error || !dashboardData) {
    return (
      <main className="min-h-screen bg-deep-graphite flex items-center justify-center">
        <div className="text-red-400">Failed to load dashboard</div>
      </main>
    );
  }

  // ... render content
}

After (Standardized)

// Dashboard page - unified pattern
function DashboardPage() {
  const { data, isLoading, error, retry } = useDataFetching({
    queryKey: ['dashboardOverview'],
    queryFn: () => dashboardService.getDashboardOverview(),
  });

  if (isLoading) {
    return <LoadingWrapper variant="full-screen" message="Loading dashboard..." />;
  }

  if (error) {
    return <ErrorDisplay error={error} onRetry={retry} variant="full-screen" />;
  }

  if (!data) {
    return (
      <EmptyState
        icon={Activity}
        title="No dashboard data"
        description="Try refreshing the page"
        action={{ label: "Refresh", onClick: retry }}
      />
    );
  }

  return <DashboardContent data={data} />;
}

Before (League Detail - Manual State)

function LeagueDetailInteractive() {
  const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const loadLeagueData = async () => {
    try {
      const viewModelData = await leagueService.getLeagueDetailPageData(leagueId);
      if (!viewModelData) {
        setError('League not found');
        setLoading(false);
        return;
      }
      setViewModel(viewModelData);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load league');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    loadLeagueData();
  }, [leagueId]);

  if (loading) {
    return <div className="text-center text-gray-400">Loading league...</div>;
  }

  if (error || !viewModel) {
    return <div className="text-center text-warning-amber">{error || 'League not found'}</div>;
  }

  // ... render content
}

After (League Detail - Standardized)

function LeagueDetailInteractive() {
  const params = useParams();
  const leagueId = params.id as string;

  const { data: viewModel, isLoading, error, retry } = useDataFetching({
    queryKey: ['leagueDetailPage', leagueId],
    queryFn: () => leagueService.getLeagueDetailPageData(leagueId),
  });

  return (
    <StateContainer
      data={viewModel}
      isLoading={isLoading}
      error={error}
      retry={retry}
      config={{
        loading: { variant: 'skeleton', message: 'Loading league...' },
        error: { variant: 'full-screen' },
        empty: {
          icon: Trophy,
          title: 'League not found',
          description: 'The league may have been deleted or you may not have access',
          action: { label: 'Back to Leagues', onClick: () => router.push('/leagues') }
        }
      }}
    >
      {(leagueData) => <LeagueDetailTemplate viewModel={leagueData} leagueId={leagueId} />}
    </StateContainer>
  );
}

9. Testing Strategy

Component Tests

// LoadingWrapper tests
describe('LoadingWrapper', () => {
  it('renders spinner by default', () => { /* ... */ });
  it('renders skeleton variant', () => { /* ... */ });
  it('shows custom message', () => { /* ... */ });
  it('is accessible', () => { /* ... */ });
});

// ErrorDisplay tests
describe('ErrorDisplay', () => {
  it('shows retry button for retryable errors', () => { /* ... */ });
  it('hides retry for non-retryable errors', () => { /* ... */ });
  it('provides navigation options', () => { /* ... */ });
  it('shows technical details in dev mode', () => { /* ... */ });
});

// useDataFetching tests
describe('useDataFetching', () => {
  it('manages loading state correctly', () => { /* ... */ });
  it('handles errors properly', () => { /* ... */ });
  it('provides retry functionality', () => { /* ... */ });
  it('integrates with error boundaries', () => { /* ... */ });
});

Integration Tests

describe('Page State Handling', () => {
  it('dashboard shows loading, then content', async () => { /* ... */ });
  it('league page handles not found', async () => { /* ... */ });
  it('race page handles network errors', async () => { /* ... */ });
});

10. Accessibility Considerations

All components will be fully accessible:

  • ARIA labels for loading states
  • Keyboard navigation support
  • Screen reader announcements
  • High contrast support
  • Focus management

11. Performance Considerations

  • Lazy loading for heavy components
  • Memoization where appropriate
  • Optimistic updates support
  • Debounced retry logic
  • Minimal re-renders

12. Documentation and Training

Developer Documentation

  • Component API documentation
  • Migration guides
  • Best practices
  • Troubleshooting guide

Team Training

  • Workshop on new patterns
  • Code review guidelines
  • Migration walkthroughs

13. TypeScript Interfaces Reference

All TypeScript interfaces are defined in components/shared/state/types.ts. Here's a comprehensive reference:

Core State Interfaces

// Basic state for any data fetching
interface PageState<T> {
  data: T | null;
  isLoading: boolean;
  error: ApiError | null;
  retry: () => Promise<void>;
}

// Extended state with metadata
interface PageStateWithMeta<T> extends PageState<T> {
  isFetching: boolean;
  refetch: () => Promise<void>;
  lastUpdated: Date | null;
  isStale: boolean;
}

Hook Interfaces

// useDataFetching options
interface UseDataFetchingOptions<T> {
  queryKey: string[];
  queryFn: () => Promise<T>;
  enabled?: boolean;
  retryOnMount?: boolean;
  cacheTime?: number;
  staleTime?: number;
  maxRetries?: number;
  retryDelay?: number;
  onSuccess?: (data: T) => void;
  onError?: (error: ApiError) => void;
}

// useDataFetching result
interface UseDataFetchingResult<T> {
  data: T | null;
  isLoading: boolean;
  isFetching: boolean;
  error: ApiError | null;
  retry: () => Promise<void>;
  refetch: () => Promise<void>;
  lastUpdated: Date | null;
  isStale: boolean;
}

Component Props

// LoadingWrapper
interface LoadingWrapperProps {
  variant?: 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
  message?: string;
  className?: string;
  size?: 'sm' | 'md' | 'lg';
  skeletonCount?: number;
  cardConfig?: {
    height?: number;
    count?: number;
    className?: string;
  };
  ariaLabel?: string;
}

// ErrorDisplay
interface ErrorDisplayProps {
  error: ApiError;
  onRetry?: () => void;
  variant?: 'full-screen' | 'inline' | 'card' | 'toast';
  showRetry?: boolean;
  showNavigation?: boolean;
  actions?: ErrorAction[];
  className?: string;
  hideTechnicalDetails?: boolean;
  ariaLabel?: string;
}

interface ErrorAction {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
  icon?: LucideIcon;
  disabled?: boolean;
}

// EmptyState
interface EmptyStateProps {
  icon: LucideIcon;
  title: string;
  description?: string;
  action?: {
    label: string;
    onClick: () => void;
    icon?: LucideIcon;
    variant?: 'primary' | 'secondary';
  };
  variant?: 'default' | 'minimal' | 'full-page';
  className?: string;
  illustration?: 'racing' | 'league' | 'team' | 'sponsor' | 'driver';
  ariaLabel?: string;
}

// StateContainer
interface StateContainerProps<T> {
  data: T | null;
  isLoading: boolean;
  error: ApiError | null;
  retry: () => Promise<void>;
  children: (data: T) => ReactNode;
  config?: StateContainerConfig<T>;
  className?: string;
  showEmpty?: boolean;
  isEmpty?: (data: T) => boolean;
}

interface StateContainerConfig<T> {
  loading?: {
    variant?: LoadingVariant;
    message?: string;
    size?: 'sm' | 'md' | 'lg';
  };
  error?: {
    variant?: ErrorVariant;
    actions?: ErrorAction[];
    showRetry?: boolean;
    showNavigation?: boolean;
  };
  empty?: {
    icon: LucideIcon;
    title: string;
    description?: string;
    action?: {
      label: string;
      onClick: () => void;
    };
  };
  customRender?: {
    loading?: () => ReactNode;
    error?: (error: ApiError) => ReactNode;
    empty?: () => ReactNode;
  };
}

Page Template Interfaces

// Generic page template
interface PageTemplateProps<T> {
  data: T | null;
  isLoading: boolean;
  error: ApiError | null;
  retry: () => Promise<void>;
  refetch: () => Promise<void>;
  title?: string;
  description?: string;
  children: (data: T) => ReactNode;
  config?: StateContainerConfig<T>;
}

// List page template
interface ListPageTemplateProps<T> extends PageTemplateProps<T[]> {
  emptyConfig?: {
    icon: LucideIcon;
    title: string;
    description?: string;
    action?: {
      label: string;
      onClick: () => void;
    };
  };
  showSkeleton?: boolean;
  skeletonCount?: number;
}

// Detail page template
interface DetailPageTemplateProps<T> extends PageTemplateProps<T> {
  onBack?: () => void;
  onRefresh?: () => void;
}

Configuration Interfaces

// Retry configuration
interface RetryConfig {
  maxAttempts?: number;
  baseDelay?: number;
  backoffMultiplier?: number;
  retryOnMount?: boolean;
}

// Notification configuration
interface NotificationConfig {
  showToastOnSuccess?: boolean;
  showToastOnError?: boolean;
  successMessage?: string;
  errorMessage?: string;
  autoDismissDelay?: number;
}

// Analytics configuration
interface StateAnalytics {
  onStateChange?: (from: string, to: string, data?: unknown) => void;
  onError?: (error: ApiError, context: string) => void;
  onRetry?: (attempt: number, maxAttempts: number) => void;
}

// Performance metrics
interface PerformanceMetrics {
  timeToFirstRender?: number;
  timeToDataLoad?: number;
  retryCount?: number;
  cacheHit?: boolean;
}

// Advanced configuration
interface AdvancedStateConfig<T> extends StateContainerConfig<T> {
  retry?: RetryConfig;
  notifications?: NotificationConfig;
  analytics?: StateAnalytics;
  performance?: PerformanceMetrics;
}

Type Aliases

type LoadingVariant = 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
type ErrorVariant = 'full-screen' | 'inline' | 'card' | 'toast';
type EmptyVariant = 'default' | 'minimal' | 'full-page';
type EmptyCheck<T> = (data: T | null) => boolean;

Default Configuration

const DEFAULT_CONFIG = {
  loading: {
    variant: 'spinner',
    message: 'Loading...',
    size: 'md',
  },
  error: {
    variant: 'full-screen',
    showRetry: true,
    showNavigation: true,
  },
  empty: {
    title: 'No data available',
    description: 'There is nothing to display here',
  },
  retry: {
    maxAttempts: 3,
    baseDelay: 1000,
    backoffMultiplier: 2,
    retryOnMount: true,
  },
  notifications: {
    showToastOnSuccess: false,
    showToastOnError: true,
    autoDismissDelay: 5000,
  },
} as const;

14. Success Metrics

  • 100% of pages use standardized components
  • Reduced error handling code by 60%
  • Consistent user experience across all pages
  • Improved error recovery rates
  • Faster development velocity

15. Rollback Plan

If issues arise:

  1. Feature flags to toggle new components
  2. Gradual rollout with monitoring
  3. Quick revert capability
  4. Comprehensive logging for debugging

This design provides a complete, user-friendly solution for error and load state handling that will improve both developer experience and user experience across the entire GridPilot website application.