501 lines
12 KiB
Markdown
501 lines
12 KiB
Markdown
# Loading & Error States
|
|
|
|
**CRITICAL**: Proper loading and error state handling prevents layout shift and provides better user experience.
|
|
|
|
---
|
|
|
|
## ⚠️ CRITICAL RULE: Never Use Early Returns
|
|
|
|
### The Problem
|
|
|
|
```typescript
|
|
// ❌ NEVER DO THIS - Early return with loading spinner
|
|
const Component = () => {
|
|
const { data, isLoading } = useQuery();
|
|
|
|
// WRONG: This causes layout shift and poor UX
|
|
if (isLoading) {
|
|
return <LoadingSpinner />;
|
|
}
|
|
|
|
return <Content data={data} />;
|
|
};
|
|
```
|
|
|
|
**Why this is bad:**
|
|
1. **Layout Shift**: Content position jumps when loading completes
|
|
2. **CLS (Cumulative Layout Shift)**: Poor Core Web Vital score
|
|
3. **Jarring UX**: Page structure changes suddenly
|
|
4. **Lost Scroll Position**: User loses place on page
|
|
|
|
### The Solutions
|
|
|
|
**Option 1: SuspenseLoader (PREFERRED for new components)**
|
|
|
|
```typescript
|
|
import { SuspenseLoader } from '~components/SuspenseLoader';
|
|
|
|
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
|
|
|
|
export const MyComponent: React.FC = () => {
|
|
return (
|
|
<SuspenseLoader>
|
|
<HeavyComponent />
|
|
</SuspenseLoader>
|
|
);
|
|
};
|
|
```
|
|
|
|
**Option 2: LoadingOverlay (for legacy useQuery patterns)**
|
|
|
|
```typescript
|
|
import { LoadingOverlay } from '~components/LoadingOverlay';
|
|
|
|
export const MyComponent: React.FC = () => {
|
|
const { data, isLoading } = useQuery({ ... });
|
|
|
|
return (
|
|
<LoadingOverlay loading={isLoading}>
|
|
<Content data={data} />
|
|
</LoadingOverlay>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## SuspenseLoader Component
|
|
|
|
### What It Does
|
|
|
|
- Shows loading indicator while lazy components load
|
|
- Smooth fade-in animation
|
|
- Prevents layout shift
|
|
- Consistent loading experience across app
|
|
|
|
### Import
|
|
|
|
```typescript
|
|
import { SuspenseLoader } from '~components/SuspenseLoader';
|
|
// Or
|
|
import { SuspenseLoader } from '@/components/SuspenseLoader';
|
|
```
|
|
|
|
### Basic Usage
|
|
|
|
```typescript
|
|
<SuspenseLoader>
|
|
<LazyLoadedComponent />
|
|
</SuspenseLoader>
|
|
```
|
|
|
|
### With useSuspenseQuery
|
|
|
|
```typescript
|
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
|
import { SuspenseLoader } from '~components/SuspenseLoader';
|
|
|
|
const Inner: React.FC = () => {
|
|
// No isLoading needed!
|
|
const { data } = useSuspenseQuery({
|
|
queryKey: ['data'],
|
|
queryFn: () => api.getData(),
|
|
});
|
|
|
|
return <Display data={data} />;
|
|
};
|
|
|
|
// Outer component wraps in Suspense
|
|
export const Outer: React.FC = () => {
|
|
return (
|
|
<SuspenseLoader>
|
|
<Inner />
|
|
</SuspenseLoader>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Multiple Suspense Boundaries
|
|
|
|
**Pattern**: Separate loading for independent sections
|
|
|
|
```typescript
|
|
export const Dashboard: React.FC = () => {
|
|
return (
|
|
<Box>
|
|
<SuspenseLoader>
|
|
<Header />
|
|
</SuspenseLoader>
|
|
|
|
<SuspenseLoader>
|
|
<MainContent />
|
|
</SuspenseLoader>
|
|
|
|
<SuspenseLoader>
|
|
<Sidebar />
|
|
</SuspenseLoader>
|
|
</Box>
|
|
);
|
|
};
|
|
```
|
|
|
|
**Benefits:**
|
|
- Each section loads independently
|
|
- User sees partial content sooner
|
|
- Better perceived performance
|
|
|
|
### Nested Suspense
|
|
|
|
```typescript
|
|
export const ParentComponent: React.FC = () => {
|
|
return (
|
|
<SuspenseLoader>
|
|
{/* Parent suspends while loading */}
|
|
<ParentContent>
|
|
<SuspenseLoader>
|
|
{/* Nested suspense for child */}
|
|
<ChildComponent />
|
|
</SuspenseLoader>
|
|
</ParentContent>
|
|
</SuspenseLoader>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## LoadingOverlay Component
|
|
|
|
### When to Use
|
|
|
|
- Legacy components with `useQuery` (not refactored to Suspense yet)
|
|
- Overlay loading state needed
|
|
- Can't use Suspense boundaries
|
|
|
|
### Usage
|
|
|
|
```typescript
|
|
import { LoadingOverlay } from '~components/LoadingOverlay';
|
|
|
|
export const MyComponent: React.FC = () => {
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: () => api.getData(),
|
|
});
|
|
|
|
return (
|
|
<LoadingOverlay loading={isLoading}>
|
|
<Box sx={{ p: 2 }}>
|
|
{data && <Content data={data} />}
|
|
</Box>
|
|
</LoadingOverlay>
|
|
);
|
|
};
|
|
```
|
|
|
|
**What it does:**
|
|
- Shows semi-transparent overlay with spinner
|
|
- Content area reserved (no layout shift)
|
|
- Prevents interaction while loading
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
### useMuiSnackbar Hook (REQUIRED)
|
|
|
|
**NEVER use react-toastify** - Project standard is MUI Snackbar
|
|
|
|
```typescript
|
|
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
|
|
|
export const MyComponent: React.FC = () => {
|
|
const { showSuccess, showError, showInfo, showWarning } = useMuiSnackbar();
|
|
|
|
const handleAction = async () => {
|
|
try {
|
|
await api.doSomething();
|
|
showSuccess('Operation completed successfully');
|
|
} catch (error) {
|
|
showError('Operation failed');
|
|
}
|
|
};
|
|
|
|
return <Button onClick={handleAction}>Do Action</Button>;
|
|
};
|
|
```
|
|
|
|
**Available Methods:**
|
|
- `showSuccess(message)` - Green success message
|
|
- `showError(message)` - Red error message
|
|
- `showWarning(message)` - Orange warning message
|
|
- `showInfo(message)` - Blue info message
|
|
|
|
### TanStack Query Error Callbacks
|
|
|
|
```typescript
|
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
|
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
|
|
|
export const MyComponent: React.FC = () => {
|
|
const { showError } = useMuiSnackbar();
|
|
|
|
const { data } = useSuspenseQuery({
|
|
queryKey: ['data'],
|
|
queryFn: () => api.getData(),
|
|
|
|
// Handle errors
|
|
onError: (error) => {
|
|
showError('Failed to load data');
|
|
console.error('Query error:', error);
|
|
},
|
|
});
|
|
|
|
return <Content data={data} />;
|
|
};
|
|
```
|
|
|
|
### Error Boundaries
|
|
|
|
```typescript
|
|
import { ErrorBoundary } from 'react-error-boundary';
|
|
|
|
function ErrorFallback({ error, resetErrorBoundary }) {
|
|
return (
|
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
|
<Typography variant='h5' color='error'>
|
|
Something went wrong
|
|
</Typography>
|
|
<Typography>{error.message}</Typography>
|
|
<Button onClick={resetErrorBoundary}>Try Again</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export const MyPage: React.FC = () => {
|
|
return (
|
|
<ErrorBoundary
|
|
FallbackComponent={ErrorFallback}
|
|
onError={(error) => console.error('Boundary caught:', error)}
|
|
>
|
|
<SuspenseLoader>
|
|
<ComponentThatMightError />
|
|
</SuspenseLoader>
|
|
</ErrorBoundary>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Complete Examples
|
|
|
|
### Example 1: Modern Component with Suspense
|
|
|
|
```typescript
|
|
import React from 'react';
|
|
import { Box, Paper } from '@mui/material';
|
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
|
import { SuspenseLoader } from '~components/SuspenseLoader';
|
|
import { myFeatureApi } from '../api/myFeatureApi';
|
|
|
|
// Inner component uses useSuspenseQuery
|
|
const InnerComponent: React.FC<{ id: number }> = ({ id }) => {
|
|
const { data } = useSuspenseQuery({
|
|
queryKey: ['entity', id],
|
|
queryFn: () => myFeatureApi.getEntity(id),
|
|
});
|
|
|
|
// data is always defined - no isLoading needed!
|
|
return (
|
|
<Paper sx={{ p: 2 }}>
|
|
<h2>{data.title}</h2>
|
|
<p>{data.description}</p>
|
|
</Paper>
|
|
);
|
|
};
|
|
|
|
// Outer component provides Suspense boundary
|
|
export const OuterComponent: React.FC<{ id: number }> = ({ id }) => {
|
|
return (
|
|
<Box>
|
|
<SuspenseLoader>
|
|
<InnerComponent id={id} />
|
|
</SuspenseLoader>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default OuterComponent;
|
|
```
|
|
|
|
### Example 2: Legacy Pattern with LoadingOverlay
|
|
|
|
```typescript
|
|
import React from 'react';
|
|
import { Box } from '@mui/material';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { LoadingOverlay } from '~components/LoadingOverlay';
|
|
import { myFeatureApi } from '../api/myFeatureApi';
|
|
|
|
export const LegacyComponent: React.FC<{ id: number }> = ({ id }) => {
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['entity', id],
|
|
queryFn: () => myFeatureApi.getEntity(id),
|
|
});
|
|
|
|
return (
|
|
<LoadingOverlay loading={isLoading}>
|
|
<Box sx={{ p: 2 }}>
|
|
{error && <ErrorDisplay error={error} />}
|
|
{data && <Content data={data} />}
|
|
</Box>
|
|
</LoadingOverlay>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Example 3: Error Handling with Snackbar
|
|
|
|
```typescript
|
|
import React from 'react';
|
|
import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Button } from '@mui/material';
|
|
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
|
import { myFeatureApi } from '../api/myFeatureApi';
|
|
|
|
export const EntityEditor: React.FC<{ id: number }> = ({ id }) => {
|
|
const queryClient = useQueryClient();
|
|
const { showSuccess, showError } = useMuiSnackbar();
|
|
|
|
const { data } = useSuspenseQuery({
|
|
queryKey: ['entity', id],
|
|
queryFn: () => myFeatureApi.getEntity(id),
|
|
onError: () => {
|
|
showError('Failed to load entity');
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: (updates) => myFeatureApi.update(id, updates),
|
|
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['entity', id] });
|
|
showSuccess('Entity updated successfully');
|
|
},
|
|
|
|
onError: () => {
|
|
showError('Failed to update entity');
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Button onClick={() => updateMutation.mutate({ name: 'New' })}>
|
|
Update
|
|
</Button>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Loading State Anti-Patterns
|
|
|
|
### ❌ What NOT to Do
|
|
|
|
```typescript
|
|
// ❌ NEVER - Early return
|
|
if (isLoading) {
|
|
return <CircularProgress />;
|
|
}
|
|
|
|
// ❌ NEVER - Conditional rendering
|
|
{isLoading ? <Spinner /> : <Content />}
|
|
|
|
// ❌ NEVER - Layout changes
|
|
if (isLoading) {
|
|
return (
|
|
<Box sx={{ height: 100 }}>
|
|
<Spinner />
|
|
</Box>
|
|
);
|
|
}
|
|
return (
|
|
<Box sx={{ height: 500 }}> // Different height!
|
|
<Content />
|
|
</Box>
|
|
);
|
|
```
|
|
|
|
### ✅ What TO Do
|
|
|
|
```typescript
|
|
// ✅ BEST - useSuspenseQuery + SuspenseLoader
|
|
<SuspenseLoader>
|
|
<ComponentWithSuspenseQuery />
|
|
</SuspenseLoader>
|
|
|
|
// ✅ ACCEPTABLE - LoadingOverlay
|
|
<LoadingOverlay loading={isLoading}>
|
|
<Content />
|
|
</LoadingOverlay>
|
|
|
|
// ✅ OK - Inline skeleton with same layout
|
|
<Box sx={{ height: 500 }}>
|
|
{isLoading ? <Skeleton variant='rectangular' height='100%' /> : <Content />}
|
|
</Box>
|
|
```
|
|
|
|
---
|
|
|
|
## Skeleton Loading (Alternative)
|
|
|
|
### MUI Skeleton Component
|
|
|
|
```typescript
|
|
import { Skeleton, Box } from '@mui/material';
|
|
|
|
export const MyComponent: React.FC = () => {
|
|
const { data, isLoading } = useQuery({ ... });
|
|
|
|
return (
|
|
<Box sx={{ p: 2 }}>
|
|
{isLoading ? (
|
|
<>
|
|
<Skeleton variant='text' width={200} height={40} />
|
|
<Skeleton variant='rectangular' width='100%' height={200} />
|
|
<Skeleton variant='text' width='100%' />
|
|
</>
|
|
) : (
|
|
<>
|
|
<Typography variant='h5'>{data.title}</Typography>
|
|
<img src={data.image} />
|
|
<Typography>{data.description}</Typography>
|
|
</>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
```
|
|
|
|
**Key**: Skeleton must have **same layout** as actual content (no shift)
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
**Loading States:**
|
|
- ✅ **PREFERRED**: SuspenseLoader + useSuspenseQuery (modern pattern)
|
|
- ✅ **ACCEPTABLE**: LoadingOverlay (legacy pattern)
|
|
- ✅ **OK**: Skeleton with same layout
|
|
- ❌ **NEVER**: Early returns or conditional layout
|
|
|
|
**Error Handling:**
|
|
- ✅ **ALWAYS**: useMuiSnackbar for user feedback
|
|
- ❌ **NEVER**: react-toastify
|
|
- ✅ Use onError callbacks in queries/mutations
|
|
- ✅ Error boundaries for component-level errors
|
|
|
|
**See Also:**
|
|
- [component-patterns.md](component-patterns.md) - Suspense integration
|
|
- [data-fetching.md](data-fetching.md) - useSuspenseQuery details |