Chapter : Error Handling and Loading States
Error Handling and Loading States
Proper error handling and loading states are essential for creating professional user experiences. This section covers comprehensive patterns for managing these states.
Overview
Learn to implement:
- Sophisticated loading indicators
- Comprehensive error handling
- Retry mechanisms
- Skeleton screens
- Error boundaries
Loading States
Basic Loading Indicator
import { useState, useEffect } from 'react'
function DataDisplay() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/data')
.then(r => r.json())
.then(setData)
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="loading-container">
<div className="spinner" />
<p>Loading...</p>
</div>
)
}
return <div>{/* render data */}</div>
}
Skeleton Screens
Provide better perceived performance with skeleton loading:
function SkeletonCard() {
return (
<div className="skeleton-card">
<div className="skeleton skeleton-image" />
<div className="skeleton skeleton-title" />
<div className="skeleton skeleton-text" />
<div className="skeleton skeleton-text" />
</div>
)
}
function ProductList() {
const { data: products, loading } = useFetch<Product[]>('/api/products')
if (loading) {
return (
<div className="product-grid">
{Array.from({ length: 6 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
)
}
return (
<div className="product-grid">
{products?.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
Progressive Loading
Show partial data while loading more:
import { useState, useEffect } from 'react'
interface Comment {
id: number
text: string
author: string
}
function CommentThread({ postId }: { postId: number }) {
const [comments, setComments] = useState<Comment[]>([])
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
useEffect(() => {
const controller = new AbortController()
async function loadComments() {
setLoadingMore(true)
try {
const response = await fetch(
`/api/posts/${postId}/comments?page=${page}`,
{ signal: controller.signal }
)
const data = await response.json()
setComments(prev => [...prev, ...data.comments])
setHasMore(data.hasMore)
} catch (err) {
console.error('Failed to load comments:', err)
} finally {
setLoadingMore(false)
}
}
loadComments()
return () => controller.abort()
}, [postId, page])
return (
<div>
{comments.map(comment => (
<div key={comment.id}>
<strong>{comment.author}:</strong> {comment.text}
</div>
))}
{loadingMore && <div>Loading more comments...</div>}
{hasMore && !loadingMore && (
<button onClick={() => setPage(p => p + 1)}>
Load More
</button>
)}
</div>
)
}
Error Handling
Comprehensive Error States
interface ErrorDisplayProps {
error: Error
onRetry?: () => void
}
function ErrorDisplay({ error, onRetry }: ErrorDisplayProps) {
const isNetworkError = error.message.includes('Failed to fetch')
const is404 = error.message.includes('404')
const is500 = error.message.includes('500')
return (
<div className="error-container">
<h3>
{isNetworkError && 'π Network Error'}
{is404 && 'π Not Found'}
{is500 && 'β οΈ Server Error'}
{!isNetworkError && !is404 && !is500 && 'β Error'}
</h3>
<p>{error.message}</p>
{onRetry && (
<button onClick={onRetry}>
Try Again
</button>
)}
{isNetworkError && (
<p className="help-text">
Please check your internet connection
</p>
)}
</div>
)
}
// Usage
function DataComponent() {
const { data, loading, error, refetch } = useFetch('/api/data')
if (loading) return <div>Loading...</div>
if (error) return <ErrorDisplay error={error} onRetry={refetch} />
return <div>{/* render data */}</div>
}
Error Boundaries
Catch errors in component tree:
import { Component, ReactNode, ErrorInfo } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error boundary caught:', error, errorInfo)
// Log to error reporting service
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<details>
<summary>Error details</summary>
<pre>{this.state.error?.message}</pre>
</details>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
// Usage
function App() {
return (
<ErrorBoundary>
<DataFetchingComponent />
</ErrorBoundary>
)
}
Retry Logic
Implement automatic and manual retry:
import { useState, useEffect, useCallback } from 'react'
interface UseRetryFetchReturn<T> {
data: T | null
loading: boolean
error: Error | null
retryCount: number
retry: () => void
}
function useRetryFetch<T>(
url: string,
maxRetries: number = 3
): UseRetryFetchReturn<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [retryCount, setRetryCount] = useState(0)
const [shouldRetry, setShouldRetry] = useState(false)
useEffect(() => {
const controller = new AbortController()
async function fetchWithRetry() {
let attempt = 0
while (attempt <= maxRetries) {
try {
setLoading(true)
setError(null)
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const json = await response.json()
setData(json)
setRetryCount(attempt)
setLoading(false)
return
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return
}
attempt++
setRetryCount(attempt)
if (attempt > maxRetries) {
setError(err instanceof Error ? err : new Error('Fetch failed'))
setLoading(false)
return
}
// Exponential backoff
await new Promise(resolve =>
setTimeout(resolve, Math.min(1000 * Math.pow(2, attempt), 10000))
)
}
}
}
fetchWithRetry()
return () => controller.abort()
}, [url, maxRetries, shouldRetry])
const retry = useCallback(() => {
setShouldRetry(prev => !prev)
}, [])
return { data, loading, error, retryCount, retry }
}
// Usage
function ResilientDataFetch() {
const { data, loading, error, retryCount, retry } = useRetryFetch('/api/data')
if (loading) {
return (
<div>
Loading...
{retryCount > 0 && <p>Retry attempt {retryCount}</p>}
</div>
)
}
if (error) {
return (
<div>
<p>Failed after {retryCount} attempts</p>
<p>Error: {error.message}</p>
<button onClick={retry}>Retry Now</button>
</div>
)
}
return <div>{/* render data */}</div>
}
Toast Notifications
Show non-blocking error messages:
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
}
interface ToastContextType {
addToast: (message: string, type: Toast['type']) => void
}
const ToastContext = createContext<ToastContextType | undefined>(undefined)
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = useCallback((message: string, type: Toast['type']) => {
const id = Date.now()
setToasts(prev => [...prev, { id, message, type }])
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 5000)
}, [])
return (
<ToastContext.Provider value={{ addToast }}>
{children}
<div className="toast-container">
{toasts.map(toast => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
// Usage
function DataComponent() {
const { addToast } = useToast()
const { data, loading, error } = useFetch('/api/data')
useEffect(() => {
if (error) {
addToast(error.message, 'error')
}
}, [error, addToast])
if (loading) return <div>Loading...</div>
return <div>{/* render data */}</div>
}
Empty States
Handle cases when data is successfully loaded but empty:
interface EmptyStateProps {
title: string
message: string
action?: {
label: string
onClick: () => void
}
}
function EmptyState({ title, message, action }: EmptyStateProps) {
return (
<div className="empty-state">
<h3>{title}</h3>
<p>{message}</p>
{action && (
<button onClick={action.onClick}>
{action.label}
</button>
)}
</div>
)
}
// Usage
function UserList() {
const { data: users, loading, error } = useFetch<User[]>('/api/users')
if (loading) return <div>Loading users...</div>
if (error) return <ErrorDisplay error={error} />
if (!users || users.length === 0) {
return (
<EmptyState
title="No users found"
message="There are no users in the system yet."
action={{
label: 'Add User',
onClick: () => console.log('Navigate to add user')
}}
/>
)
}
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
Best Practices
- Always show loading states - Never leave users wondering
- Provide helpful error messages - Explain what went wrong and how to fix it
- Implement retry mechanisms - Allow users to recover from transient errors
- Use skeleton screens - Better perceived performance than spinners
- Handle empty states - Donβt just show nothing when data is empty
- Log errors appropriately - Send to monitoring services
- Use error boundaries - Catch unexpected errors
- Test error scenarios - Network failures, timeouts, malformed responses
Summary
Professional error handling and loading states significantly improve user experience. By implementing comprehensive patterns, you create applications that feel polished and reliable.
Next Steps:
- Explore Advanced Data Fetching Patterns for complex scenarios
- Learn about Modern Data Fetching Libraries for built-in solutions
Key Takeaways:
- Loading states should be informative and visually appropriate
- Skeleton screens improve perceived performance
- Error messages should be helpful and actionable
- Retry logic handles transient failures automatically
- Error boundaries catch unexpected errors
- Empty states guide users when no data exists