Design a robust data fetching layer for a React application. How would you handle caching, error states, and optimistic updates?
9 minadvancedreactsystem-designrobustdatafetchinglayer
Quick Answer
A robust data fetching layer is crucial for modern React applications.
Detailed Answer
Design a robust data fetching layer for a React application. How would you handle caching, error states, and optimistic updates?
Answer:
A robust data fetching layer is crucial for modern React applications. Here's a comprehensive approach that handles caching, error states, optimistic updates, and more:
5.5.1. Core Data Fetching Architecture
Custom Hook for Data Fetching
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
interface FetchOptions {
enabled?: boolean;
retry?: number;
retryDelay?: number;
staleTime?: number;
cacheTime?: number;
onSuccess?: (data: any) => void;
onError?: (error: Error) => void;
}
const useFetch = <T>(
url: string,
options: FetchOptions = {}
): FetchState<T> => {
const {
enabled = true,
retry = 3,
retryDelay = 1000,
staleTime = 5 * 60 * 1000, // 5 minutes
cacheTime = 10 * 60 * 1000, // 10 minutes
onSuccess,
onError,
} = options;
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: false,
error: null,
refetch: () => Promise.resolve(),
});
const cache = useRef<Map<string, { data: T; timestamp: number }>>(new Map());
const fetchData = useCallback(async (retryCount = 0): Promise<void> => {
if (!enabled) return;
// Check cache first
const cached = cache.current.get(url);
if (cached && Date.now() - cached.timestamp < staleTime) {
setState(prev => ({ ...prev, data: cached.data, loading: false }));
onSuccess?.(cached.data);
return;
}
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Cache the data
cache.current.set(url, { data, timestamp: Date.now() });
setState(prev => ({ ...prev, data, loading: false }));
onSuccess?.(data);
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
if (retryCount < retry) {
// Retry with exponential backoff
setTimeout(() => {
fetchData(retryCount + 1);
}, retryDelay * Math.pow(2, retryCount));
} else {
setState(prev => ({ ...prev, error: err, loading: false }));
onError?.(err);
}
}
}, [url, enabled, retry, retryDelay, staleTime, onSuccess, onError]);
const refetch = useCallback(() => {
cache.current.delete(url);
return fetchData();
}, [fetchData, url]);
useEffect(() => {
fetchData();
}, [fetchData]);
// Cleanup old cache entries
useEffect(() => {
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, value] of cache.current.entries()) {
if (now - value.timestamp > cacheTime) {
cache.current.delete(key);
}
}
}, 60000); // Cleanup every minute
return () => clearInterval(cleanup);
}, [cacheTime]);
return { ...state, refetch };
};
5.5.2. Advanced Caching with React Query
React Query Setup
import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutations: {
retry: 1,
},
},
});
// Custom hooks for different data types
export const useUsers = () => {
return useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
select: (data) => data.users,
});
};
export const useUser = (id: string) => {
return useQuery({
queryKey: ['users', id],
queryFn: () => fetch(`/api/users/${id}`).then(res => res.json()),
enabled: !!id,
});
};
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userData: CreateUserData) =>
fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
}).then(res => res.json()),
onSuccess: (newUser) => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ['users'] });
// Optimistically update the cache
queryClient.setQueryData(['users', newUser.id], newUser);
},
onError: (error) => {
console.error('Failed to create user:', error);
},
});
};
5.5.3. Optimistic Updates
Optimistic Update Implementation
interface OptimisticUpdate<T> {
queryKey: string[];
updateFn: (oldData: T) => T;
rollbackFn?: (oldData: T) => T;
}
const useOptimisticMutation = <T, TVariables>(
mutationFn: (variables: TVariables) => Promise<T>,
optimisticUpdate: OptimisticUpdate<T>
) => {
const queryClient = useQueryClient();
const [isOptimistic, setIsOptimistic] = useState(false);
return useMutation({
mutationFn,
onMutate: async (variables) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: optimisticUpdate.queryKey });
// Snapshot previous value
const previousData = queryClient.getQueryData<T>(optimisticUpdate.queryKey);
// Optimistically update
if (previousData) {
queryClient.setQueryData(
optimisticUpdate.queryKey,
optimisticUpdate.updateFn(previousData)
);
setIsOptimistic(true);
}
return { previousData };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousData && optimisticUpdate.rollbackFn) {
queryClient.setQueryData(
optimisticUpdate.queryKey,
optimisticUpdate.rollbackFn(context.previousData)
);
}
setIsOptimistic(false);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: optimisticUpdate.queryKey });
setIsOptimistic(false);
},
});
};
// Usage example
const useToggleLike = () => {
return useOptimisticMutation(
(postId: string) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }).then(res => res.json()),
{
queryKey: ['posts'],
updateFn: (oldData) => ({
...oldData,
posts: oldData.posts.map(post =>
post.id === postId
? { ...post, liked: !post.liked, likes: post.liked ? post.likes - 1 : post.likes + 1 }
: post
),
}),
rollbackFn: (oldData) => ({
...oldData,
posts: oldData.posts.map(post =>
post.id === postId
? { ...post, liked: !post.liked, likes: post.liked ? post.likes + 1 : post.likes - 1 }
: post
),
}),
}
);
};
5.5.4. Error Handling and Retry Logic
Advanced Error Handling
interface ApiError extends Error {
status?: number;
code?: string;
details?: any;
}
class ApiErrorHandler {
static async handleResponse(response: Response): Promise<any> {
if (!response.ok) {
const error: ApiError = new Error(`HTTP ${response.status}: ${response.statusText}`);
error.status = response.status;
try {
const errorData = await response.json();
error.details = errorData;
error.message = errorData.message || error.message;
} catch {
// If response is not JSON, use status text
}
throw error;
}
return response.json();
}
static isRetryableError(error: ApiError): boolean {
if (!error.status) return true; // Network errors are retryable
// Retry on server errors and rate limiting
return error.status >= 500 || error.status === 429;
}
static getRetryDelay(attempt: number, baseDelay = 1000): number {
// Exponential backoff with jitter
const delay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 0.1 * delay;
return Math.min(delay + jitter, 30000); // Max 30 seconds
}
}
const useApiCall = <T>(
url: string,
options: RequestInit = {},
retryOptions = { maxRetries: 3, baseDelay: 1000 }
) => {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: ApiError | null;
}>({
data: null,
loading: false,
error: null,
});
const execute = useCallback(async (retryCount = 0): Promise<T> => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(url, options);
const data = await ApiErrorHandler.handleResponse(response);
setState({ data, loading: false, error: null });
return data;
} catch (error) {
const apiError = error as ApiError;
if (
retryCount < retryOptions.maxRetries &&
ApiErrorHandler.isRetryableError(apiError)
) {
const delay = ApiErrorHandler.getRetryDelay(retryCount, retryOptions.baseDelay);
await new Promise(resolve => setTimeout(resolve, delay));
return execute(retryCount + 1);
}
setState({ data: null, loading: false, error: apiError });
throw apiError;
}
}, [url, options, retryOptions]);
return { ...state, execute };
};
5.5.5. Real-time Data with WebSockets
WebSocket Integration
interface WebSocketMessage {
type: string;
payload: any;
timestamp: number;
}
const useWebSocket = (url: string, options: { reconnect?: boolean; reconnectInterval?: number } = {}) => {
const { reconnect = true, reconnectInterval = 3000 } = options;
const [socket, setSocket] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const [error, setError] = useState<Event | null>(null);
const connect = useCallback(() => {
const ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
setError(null);
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
setLastMessage(message);
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
ws.onclose = () => {
setIsConnected(false);
if (reconnect) {
setTimeout(connect, reconnectInterval);
}
};
ws.onerror = (event) => {
setError(event);
};
setSocket(ws);
}, [url, reconnect, reconnectInterval]);
const sendMessage = useCallback((message: any) => {
if (socket && isConnected) {
socket.send(JSON.stringify(message));
}
}, [socket, isConnected]);
const disconnect = useCallback(() => {
if (socket) {
socket.close();
setSocket(null);
}
}, [socket]);
useEffect(() => {
connect();
return disconnect;
}, [connect, disconnect]);
return {
isConnected,
lastMessage,
error,
sendMessage,
disconnect,
reconnect: connect,
};
};
// Usage with React Query for real-time updates
const useRealtimePosts = () => {
const queryClient = useQueryClient();
const { lastMessage } = useWebSocket('ws://localhost:8080/posts');
useEffect(() => {
if (lastMessage) {
switch (lastMessage.type) {
case 'POST_CREATED':
queryClient.invalidateQueries({ queryKey: ['posts'] });
break;
case 'POST_UPDATED':
queryClient.setQueryData(['posts', lastMessage.payload.id], lastMessage.payload);
break;
case 'POST_DELETED':
queryClient.setQueryData(['posts'], (old: any) =>
old?.filter((post: any) => post.id !== lastMessage.payload.id)
);
break;
}
}
}, [lastMessage, queryClient]);
return useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json()),
});
};
5.5.6. Offline Support and Background Sync
Service Worker Integration
// service-worker.js
const CACHE_NAME = 'api-cache-v1';
const OFFLINE_QUEUE = 'offline-queue';
// Cache API responses
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((response) => {
if (response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
}).catch(() => {
// Return cached version or queue for later
return caches.match(event.request);
});
})
);
}
});
// Background sync for offline actions
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
event.waitUntil(processOfflineQueue());
}
});
const processOfflineQueue = async () => {
const queue = await getOfflineQueue();
for (const request of queue) {
try {
await fetch(request.url, request.options);
await removeFromOfflineQueue(request.id);
} catch (error) {
console.error('Failed to sync request:', error);
}
}
};
Offline-Aware Data Fetching
const useOfflineAwareFetch = <T>(url: string, options: RequestInit = {}) => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [offlineQueue, setOfflineQueue] = useState<any[]>([]);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const executeRequest = useCallback(async (): Promise<T> => {
if (!isOnline && options.method !== 'GET') {
// Queue non-GET requests when offline
const queuedRequest = {
id: Date.now().toString(),
url,
options,
timestamp: Date.now(),
};
setOfflineQueue(prev => [...prev, queuedRequest]);
throw new Error('Request queued for offline sync');
}
const response = await fetch(url, options);
return ApiErrorHandler.handleResponse(response);
}, [url, options, isOnline]);
return { executeRequest, isOnline, offlineQueue };
};
5.5.7. Performance Optimization
Request Deduplication
class RequestDeduplicator {
private static pendingRequests = new Map<string, Promise<any>>();
static async deduplicate<T>(key: string, requestFn: () => Promise<T>): Promise<T> {
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key)!;
}
const promise = requestFn().finally(() => {
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, promise);
return promise;
}
}
const useDeduplicatedFetch = <T>(url: string) => {
return useQuery({
queryKey: [url],
queryFn: () => RequestDeduplicator.deduplicate(url, () =>
fetch(url).then(res => res.json())
),
});
};
This comprehensive data fetching layer provides robust caching, error handling, optimistic updates, real-time capabilities, offline support, and performance optimizations for modern React applications.