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 UXfull-screen- Centered in viewportinline- Compact inline loadingcard- 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:
- Support the new standardized error display
- Provide automatic retry functionality
- Support custom fallback components
- 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:
- Handle both API errors and regular errors
- Support retry functionality
- Provide consistent UI across all API calls
7. Migration Strategy
Phase 1: Foundation (Week 1)
- Create new shared components in
components/shared/state/ - Create
useDataFetchinghook - Update TypeScript interfaces
- Add comprehensive tests
Phase 2: Core Pages (Week 2-3)
Update pages in priority order:
High Priority:
app/dashboard/page.tsxapp/leagues/[id]/LeagueDetailInteractive.tsxapp/races/[id]/RaceDetailInteractive.tsx
Medium Priority:
app/teams/[id]/TeamDetailInteractive.tsxapp/leagues/[id]/schedule/page.tsxapp/races/[id]/results/RaceResultsInteractive.tsxapp/races/[id]/stewarding/RaceStewardingInteractive.tsx
Lower Priority:
- Sponsor pages
- Profile pages
- Other interactive components
Phase 3: Rollout (Week 4)
- Update remaining pages
- Add migration codemods for automation
- Update documentation
- Team training
Phase 4: Deprecation (Week 5-6)
- Remove old components
- Clean up legacy code
- 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:
- Feature flags to toggle new components
- Gradual rollout with monitoring
- Quick revert capability
- 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.