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.