Skip to main content

Image Gallery Component Design and Implementation

Comprehensive low-level design for a scalable, accessible image gallery with lazy loading, responsive layouts, and advanced filtering capabilities.

This document presents a production-ready low-level design for an image gallery component that handles large media collections efficiently while maintaining excellent user experience and accessibility standards.

1. Problem Statement & Requirements

Clarifying questions

  1. What's the target browser support matrix? (e.g., IE11, mobile Safari, Chrome 80+)
  2. Do we need real-time image updates or is this a static gallery?
  3. Should the gallery support offline viewing of previously loaded images?
  4. What accessibility level is required? (e.g., WCAG 2.1 AA, full keyboard navigation)
  5. Do we need internationalization support for image captions or metadata?
  6. Are there any design system constraints (e.g., Material Design, Tailwind, custom tokens)?
  7. Should the gallery support drag-and-drop reordering or multi-select?

Functional requirements

  • Display a grid of images with responsive layout
  • Support image loading with placeholders and error states
  • Enable user to view full-size image in modal
  • Allow filtering by category or tags (if applicable)
  • Support keyboard navigation between images
  • Provide image metadata display (title, caption, upload date)
  • Enable image download functionality
  • Support infinite scroll or pagination for large collections

Non-functional requirements

  • Performance: LCP < 2.5s, CLS < 0.1, INP < 200ms
  • Accessibility: Full keyboard navigation, ARIA roles, screen reader support
  • Scalability: Support up to 10k images with virtualized rendering
  • Responsiveness: Grid layout adapts from 2 to 6 columns based on viewport width
  • Offline capability: Cache images for offline viewing (if supported)
  • Internationalization: Support RTL layouts and localized metadata

Constraints and assumptions

  • Images are served from a CDN with consistent URLs
  • Image metadata is provided via API (title, caption, tags)
  • No client-side image editing or upload capabilities
  • Images are assumed to be in common formats (JPEG, PNG, WebP)
  • User session is managed via JWT tokens for protected routes
  • Design system uses Tailwind CSS with custom theme

Out of scope

  • Image upload or editing functionality
  • Image compression or format conversion in browser
  • Advanced filtering (e.g., search by metadata content)
  • Social sharing or embedding features
  • Image annotation or tagging by users
  • Server-side image processing or transformations
  • Multi-language content beyond metadata display

2. Component Architecture

graph TD
    A[ImageGallery] --> B[ImageGrid]
    A --> C[ImageModal]
    A --> D[ImageToolbar]
    B --> E[ImageCard]
    E --> F[ImageThumbnail]
    E --> G[ImageTitle]
    E --> H[ImageDescription]
    D --> I[SearchInput]
    D --> J[FilterDropdown]
    D --> K[SortSelect]
    C --> L[ModalImage]
    C --> M[ModalNavigation]
    C --> N[ModalCloseButton]

Component Tree Breakdown

Smart Components (Container Components):

  • ImageGallery - Main container that orchestrates state, API calls, and renders the gallery
  • ImageModal - Handles modal view state and navigation between images

Dumb Components (Presentational Components):

  • ImageGrid - Renders the grid of image cards
  • ImageCard - Individual image card with thumbnail, title, and description
  • ImageThumbnail - Image preview with loading states
  • ImageToolbar - Search, filter, and sort controls
  • ModalImage - Full-size image in modal view
  • ModalNavigation - Navigation controls within modal
  • SearchInput, FilterDropdown, SortSelect - Form controls

TypeScript Props Contracts

// ImageGallery.tsx
interface Image {
  id: string;
  title: string;
  description: string;
  url: string;
  thumbnailUrl: string;
  uploadDate: string;
  tags: string[];
}

interface ImageGalleryProps {
  images?: Image[];
  loading?: boolean;
  error?: string | null;
  onImageSelect: (image: Image) => void;
  onSearch?: (query: string) => void;
  onFilter?: (filter: string) => void;
  onSort?: (sort: string) => void;
}

// ImageCard.tsx
interface ImageCardProps {
  image: Image;
  onClick: () => void;
  isSelected?: boolean;
}

// ImageModal.tsx
interface ImageModalProps {
  image: Image;
  isOpen: boolean;
  onClose: () => void;
  onNext: () => void;
  onPrevious: () => void;
}

UI State Machine

stateDiagram-v2
    [*] --> Idle
    Idle --> Loading : fetchImages()
    Loading --> Success : imagesLoaded()
    Loading --> Error : fetchError()
    Success --> ModalOpen : openModal()
    ModalOpen --> ModalClosed : closeModal()
    ModalOpen --> NextImage : nextImage()
    ModalOpen --> PrevImage : prevImage()
    Error --> Idle : retryFetch()
    Success --> Filtering : applyFilter()
    Success --> Searching : applySearch()
    Success --> Sorting : applySort()

Component Responsibilities

ImageGallery:

  • Main orchestrator component
  • Manages global state (images, loading, error)
  • Handles search/filter/sort operations
  • Renders image grid and modal

ImageGrid:

  • Renders collection of ImageCard components in a responsive grid
  • Handles layout and spacing

ImageCard:

  • Individual image presentation with thumbnail, title, description
  • Handles selection state and click events

ImageModal:

  • Full-screen modal view for individual image
  • Navigation between images in gallery
  • Close functionality

Reusability Decisions

The component architecture prioritizes:

  1. Highly reusable: ImageThumbnail, SearchInput, FilterDropdown - these can be used across different features
  2. Feature-specific: ImageCard, ImageGrid - designed specifically for gallery use case
  3. State management: ImageGallery contains all complex state logic to keep components pure

Component Lifecycle Considerations

  • ImageGallery mounts and fetches initial image data
  • ImageCard renders thumbnails with proper loading states
  • ImageModal handles focus trapping when opening/closing
  • All components implement proper cleanup for event listeners and timers
  • ImageThumbnail implements lazy loading with IntersectionObserver
// Example of ImageCard lifecycle management
const ImageCard = ({ image, onClick }: ImageCardProps) => {
  const [isLoaded, setIsLoaded] = useState(false);
  const [hasError, setHasError] = useState(false);
  
  useEffect(() => {
    // Cleanup for any side effects
    return () => {
      // Remove any event listeners or timers if needed
    };
  }, []);

  const handleImageLoad = () => {
    setIsLoaded(true);
  };

  const handleImageError = () => {
    setHasError(true);
  };

  return (
    <div className="image-card" onClick={onClick}>
      <ImageThumbnail 
        src={image.thumbnailUrl}
        alt={image.title}
        onLoad={handleImageLoad}
        onError={handleImageError}
        loaded={isLoaded}
        error={hasError}
      />
      <div className="image-info">
        <h3>{image.title}</h3>
        <p>{image.description}</p>
      </div>
    </div>
  );
};

3. State Management Design

Local state vs. global state decisions

For an Image Gallery component, local state management is appropriate for:

  • Individual image loading states (pending, loaded, error)
  • Current view mode (grid vs. list)
  • Selected image state for modal viewing
  • Pagination and infinite scroll tracking

Global state is appropriate for:

  • User preferences (view mode, image size)
  • Authenticated user's favorite images
  • Cross-feature image selection state (for sharing/copying)
  • Application-level loading indicators

State management approach

Zustand is selected as the primary state management solution because:

  1. Simplicity: Minimal boilerplate for complex state operations
  2. TypeScript native: Excellent type inference and support
  3. Flexible: Can easily integrate with React Query for server state
  4. Bundle size: ~1KB minified, negligible impact on bundle size
  5. Developer experience: Clear separation of concerns with easy debugging

State shape and normalization strategy

// Normalized state structure for image gallery
interface Image {
  id: string;
  url: string;
  thumbnailUrl: string;
  title: string;
  description: string;
  author: string;
  uploadDate: Date;
  width: number;
  height: number;
  tags: string[];
  likes: number;
  isFavorite: boolean;
}

interface GalleryState {
  images: Record<string, Image>;
  imageIds: string[];
  viewMode: 'grid' | 'list';
  selectedImageId: string | null;
  currentPage: number;
  totalPages: number;
  isLoading: boolean;
  error: string | null;
  favorites: Set<string>;
  searchTerm: string;
  filters: {
    tags: string[];
    dateRange: { start?: Date; end?: Date };
  };
}

Server state vs. client state separation

Server State:

  • Image metadata (fetched from API)
  • User favorites (fetched from backend)
  • Pagination information

Client State:

  • UI state (view mode, search term)
  • Selected image for modal
  • Local cache of loaded images

Cache invalidation strategy

  1. Time-based expiration: Images cached for 5 minutes
  2. User action invalidation: Favorites update immediately, trigger re-fetch of user's favorite list
  3. Search term invalidation: Clear cache when search changes
  4. Manual refresh: User-triggered refresh invalidates all local caches

Optimistic updates design

For favorite toggling:

  1. Immediately update UI state (toggle local isFavorite)
  2. Send API request to backend
  3. If successful, update server state and sync with local cache
  4. If failed, revert UI to previous state and show error

Critical-path hook implementation

// src/features/imageGallery/hooks/useImageGallery.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { Image, GalleryState } from '../types';

interface ImageGalleryStore extends GalleryState {
  actions: {
    // Image loading and caching
    loadImage: (imageId: string) => Promise<void>;
    invalidateCache: () => void;
    
    // UI state management
    setViewMode: (mode: 'grid' | 'list') => void;
    setSelectedImageId: (id: string | null) => void;
    
    // Pagination and fetching
    fetchImages: (page?: number, search?: string) => Promise<void>;
    loadMoreImages: () => Promise<void>;
    
    // Favorites
    toggleFavorite: (imageId: string) => Promise<void>;
    
    // Filters and search
    setSearchTerm: (term: string) => void;
    applyFilters: (filters: GalleryState['filters']) => void;
  };
}

const useImageGalleryStore = create<ImageGalleryStore>()(
  immer((set, get) => ({
    // Initial state
    images: {},
    imageIds: [],
    viewMode: 'grid',
    selectedImageId: null,
    currentPage: 1,
    totalPages: 1,
    isLoading: false,
    error: null,
    favorites: new Set(),
    searchTerm: '',
    filters: {
      tags: [],
      dateRange: {}
    },
    
    actions: {
      // Image loading with caching
      loadImage: async (imageId) => {
        const state = get();
        if (state.images[imageId]) return;
        
        set((draft) => {
          draft.isLoading = true;
          draft.error = null;
        });
        
        try {
          // Simulate API call with caching
          const image = await fetchImageFromAPI(imageId);
          
          set((draft) => {
            draft.images[imageId] = image;
            if (!draft.imageIds.includes(imageId)) {
              draft.imageIds.push(imageId);
            }
          });
        } catch (error) {
          set((draft) => {
            draft.error = `Failed to load image ${imageId}`;
          });
        } finally {
          set((draft) => {
            draft.isLoading = false;
          });
        }
      },
      
      // View mode management
      setViewMode: (mode) => {
        set((draft) => {
          draft.viewMode = mode;
        });
      },
      
      // Modal selection
      setSelectedImageId: (id) => {
        set((draft) => {
          draft.selectedImageId = id;
        });
      },
      
      // Image fetching with pagination
      fetchImages: async (page = 1, search = '') => {
        set((draft) => {
          draft.isLoading = true;
          draft.error = null;
        });
        
        try {
          const response = await fetchImagesFromAPI({
            page,
            search,
            filters: get().filters
          });
          
          set((draft) => {
            draft.imageIds = response.imageIds;
            draft.images = { ...draft.images, ...response.images };
            draft.currentPage = page;
            draft.totalPages = response.totalPages;
          });
        } catch (error) {
          set((draft) => {
            draft.error = 'Failed to fetch images';
          });
        } finally {
          set((draft) => {
            draft.isLoading = false;
          });
        }
      },
      
      // Infinite scroll loading
      loadMoreImages: async () => {
        const state = get();
        if (state.currentPage >= state.totalPages) return;
        
        set((draft) => {
          draft.isLoading = true;
        });
        
        try {
          const response = await fetchImagesFromAPI({
            page: state.currentPage + 1,
            search: state.searchTerm,
            filters: state.filters
          });
          
          set((draft) => {
            draft.imageIds = [...draft.imageIds, ...response.imageIds];
            draft.images = { ...draft.images, ...response.images };
            draft.currentPage = response.page;
          });
        } catch (error) {
          set((draft) => {
            draft.error = 'Failed to load more images';
          });
        } finally {
          set((draft) => {
            draft.isLoading = false;
          });
        }
      },
      
      // Favorite toggling with optimistic updates
      toggleFavorite: async (imageId) => {
        const state = get();
        const isCurrentlyFavorite = state.favorites.has(imageId);
        
        // Optimistic update
        set((draft) => {
          if (isCurrentlyFavorite) {
            draft.favorites.delete(imageId);
          } else {
            draft.favorites.add(imageId);
          }
          
          // Update image favorite status if already loaded
          if (draft.images[imageId]) {
            draft.images[imageId].isFavorite = !isCurrentlyFavorite;
          }
        });
        
        try {
          // API call to sync with backend
          await toggleFavoriteInAPI(imageId);
          
          // If successful, update server state (already handled by optimistic update)
        } catch (error) {
          // Revert optimistic update on failure
          set((draft) => {
            if (isCurrentlyFavorite) {
              draft.favorites.add(imageId);
            } else {
              draft.favorites.delete(imageId);
            }
            
            if (draft.images[imageId]) {
              draft.images[imageId].isFavorite = isCurrentlyFavorite;
            }
          });
          
          set((draft) => {
            draft.error = 'Failed to update favorite status';
          });
        }
      },
      
      // Search term management
      setSearchTerm: (term) => {
        set((draft) => {
          draft.searchTerm = term;
          // Clear current results and fetch new ones
          draft.imageIds = [];
          draft.images = {};
        });
        
        // Debounced fetch
        setTimeout(() => {
          get().actions.fetchImages(1, term);
        }, 300);
      },
      
      // Filter application
      applyFilters: (filters) => {
        set((draft) => {
          draft.filters = filters;
          // Reset and refetch with new filters
          draft.imageIds = [];
          draft.images = {};
        });
        
        get().actions.fetchImages(1);
      },
      
      // Cache invalidation
      invalidateCache: () => {
        set((draft) => {
          draft.images = {};
          draft.imageIds = [];
          draft.currentPage = 1;
          draft.totalPages = 1;
        });
      }
    }
  }))
);

// Custom hook for component usage
export const useImageGallery = () => {
  const store = useImageGalleryStore();
  
  return {
    // State
    images: store.images,
    imageIds: store.imageIds,
    viewMode: store.viewMode,
    selectedImageId: store.selectedImageId,
    currentPage: store.currentPage,
    totalPages: store.totalPages,
    isLoading: store.isLoading,
    error: store.error,
    favorites: store.favorites,
    searchTerm: store.searchTerm,
    
    // Actions
    actions: store.actions
  };
};

// Helper function for API interaction (simplified)
const fetchImageFromAPI = async (id: string): Promise<Image> => {
  // Simulated API call
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        id,
        url: `https://example.com/image/${id}.jpg`,
        thumbnailUrl: `https://example.com/image/${id}-thumb.jpg`,
        title: `Image ${id}`,
        description: `Description for image ${id}`,
        author: 'Author Name',
        uploadDate: new Date(),
        width: 800,
        height: 600,
        tags: ['nature', 'landscape'],
        likes: Math.floor(Math.random() * 100),
        isFavorite: false
      });
    }, 100);
  });
};

const fetchImagesFromAPI = async (params: {
  page: number;
  search?: string;
  filters: GalleryState['filters'];
}): Promise<{
  imageIds: string[];
  images: Record<string, Image>;
  page: number;
  totalPages: number;
}> => {
  // Simulated API response
  return new Promise((resolve) => {
    setTimeout(() => {
      const imageIds = Array.from({ length: 20 }, (_, i) => `img-${i + (params.page - 1) * 20}`);
      const images = imageIds.reduce((acc, id) => {
        acc[id] = fetchImageFromAPI(id);
        return acc;
      }, {} as Record<string, Image>);
      
      resolve({
        imageIds,
        images,
        page: params.page,
        totalPages: Math.ceil(1000 / 20)
      });
    }, 200);
  });
};

const toggleFavoriteInAPI = async (imageId: string): Promise<void> => {
  // Simulated API call
  return new Promise((resolve) => {
    setTimeout(() => resolve(), 150);
  });
};

4. Routing & Navigation

Route Structure and Hierarchy

The image gallery feature will be structured as a nested route under the /gallery path with the following hierarchy:

/gallery
  ├── /albums
  │   ├── /:albumId
  │   │   ├── /:imageId
  │   │   └── /view
  │   └── /all
  ├── /search
  └── /upload

Protected routes include:

  • /gallery/albums/:albumId - Album view with image details
  • /gallery/albums/:albumId/:imageId - Individual image modal view
  • /gallery/albums/:albumId/view - Fullscreen image viewer
  • /gallery/search - Search results view
  • /gallery/upload - Image upload form

Public routes:

  • /gallery/albums/all - All images view (read-only)

Code Splitting Strategy

Route-based code splitting with React.lazy and Suspense:

// routes/GalleryRoutes.tsx
const GalleryLayout = React.lazy(() => import('components/gallery/Layout'));
const AlbumsView = React.lazy(() => import('components/gallery/AlbumsView'));
const ImageViewer = React.lazy(() => import('components/gallery/ImageViewer'));
const SearchView = React.lazy(() => import('components/gallery/SearchView'));
const UploadForm = React.lazy(() => import('components/gallery/UploadForm'));

// Route configuration with lazy loading
const galleryRoutes = [
  {
    path: '/gallery',
    element: <GalleryLayout />,
    children: [
      { 
        index: true, 
        element: <Navigate to="/gallery/albums/all" replace /> 
      },
      {
        path: 'albums',
        element: <AlbumsView />,
        children: [
          { 
            path: 'all', 
            element: <AlbumsView /> 
          },
          { 
            path: ':albumId', 
            element: <AlbumsView /> 
          },
          { 
            path: ':albumId/:imageId', 
            element: <ImageViewer /> 
          },
          { 
            path: ':albumId/view', 
            element: <ImageViewer /> 
          }
        ]
      },
      {
        path: 'search',
        element: <SearchView />
      },
      {
        path: 'upload',
        element: <UploadForm />,
        handle: { requiresAuth: true }
      }
    ]
  }
];

Component-based code splitting for large subcomponents:

// components/gallery/ImageGrid.tsx
const ImageCard = React.lazy(() => import('./ImageCard'));
const InfiniteScrollLoader = React.lazy(() => import('./InfiniteScrollLoader'));

// components/gallery/ImageViewer.tsx
const ImageModal = React.lazy(() => import('./ImageModal'));
const ImageToolbar = React.lazy(() => import('./ImageToolbar'));

Authentication guard implementation:

// guards/AuthGuard.tsx
const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const { isAuthenticated, loading } = useAuth();
  
  if (loading) return <LoadingSpinner />;
  
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  
  return children;
};

// Route configuration with guard
const ProtectedRoute = ({ 
  element, 
  requiresAuth = true 
}: { 
  element: React.ReactNode; 
  requiresAuth?: boolean; 
}) => (
  <AuthGuard>
    {element}
  </AuthGuard>
);

// Usage in route config
{
  path: 'upload',
  element: <ProtectedRoute element={<UploadForm />} />,
  handle: { requiresAuth: true }
}

Deep Linking Support and URL Parameter Design

URL structure designed for SEO and deep linking:

// URL patterns:
// /gallery/albums/all?sort=recent&filter=tag:landscape
// /gallery/albums/123?view=grid&limit=20&page=2
// /gallery/albums/123/456?mode=fullscreen&autoplay=true
// /gallery/search?q=mountain&category=nature&page=1

// URL parameter parsing utility
const useGalleryParams = () => {
  const location = useLocation();
  const searchParams = new URLSearchParams(location.search);
  
  return {
    albumId: searchParams.get('album') || null,
    imageId: searchParams.get('image') || null,
    viewMode: searchParams.get('view') || 'grid',
    sort: searchParams.get('sort') || 'recent',
    filter: searchParams.get('filter') || null,
    page: parseInt(searchParams.get('page') || '1', 10),
    limit: parseInt(searchParams.get('limit') || '20', 10)
  };
};

Browser History Management and Scroll Restoration

// components/gallery/ScrollRestoration.tsx
const GalleryScrollRestoration: React.FC = () => {
  const location = useLocation();
  
  useEffect(() => {
    // Restore scroll position when navigating back
    const restoreScroll = () => {
      const scrollPosition = sessionStorage.getItem(`scrollPosition_${location.key}`);
      if (scrollPosition) {
        window.scrollTo(0, parseInt(scrollPosition, 10));
      }
    };
    
    // Save scroll position before navigation
    const handleBeforeUnload = () => {
      sessionStorage.setItem(`scrollPosition_${location.key}`, window.scrollY.toString());
    };
    
    window.addEventListener('beforeunload', handleBeforeUnload);
    restoreScroll();
    
    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
    };
  }, [location.key]);
  
  return null;
};

// In main App component
const App: React.FC = () => {
  return (
    <BrowserRouter>
      <GalleryScrollRestoration />
      <Routes>
        {galleryRoutes}
      </Routes>
    </BrowserRouter>
  );
};
// hooks/useGalleryNavigation.ts
export const useGalleryNavigation = () => {
  const navigate = useNavigate();
  const location = useLocation();
  
  const goToAlbum = (albumId: string) => {
    navigate(`/gallery/albums/${albumId}`);
  };
  
  const goToImage = (albumId: string, imageId: string) => {
    navigate(`/gallery/albums/${albumId}/${imageId}`);
  };
  
  const goToSearch = (query: string) => {
    navigate(`/gallery/search?q=${encodeURIComponent(query)}`);
  };
  
  const goToUpload = () => {
    navigate('/gallery/upload');
  };
  
  const goBack = () => {
    // Preserve scroll position when going back
    navigate(-1);
  };
  
  const goToImageFullscreen = (albumId: string, imageId: string) => {
    navigate(`/gallery/albums/${albumId}/view?image=${imageId}`);
  };
  
  return {
    goToAlbum,
    goToImage,
    goToSearch,
    goToUpload,
    goBack,
    goToImageFullscreen
  };
};
  1. Keyboard navigation support for all gallery views
  2. Back/forward button preservation of view state and scroll position
  3. Deep link validation to prevent invalid navigation states
  4. Progressive enhancement for users without JavaScript
  5. URL parameter normalization to prevent malformed URLs

The routing strategy prioritizes:

  • User experience: Smooth transitions and preserved state
  • Performance: Code splitting to minimize initial bundle size
  • Accessibility: Keyboard navigation and proper semantic structure
  • Maintainability: Clear component hierarchy and consistent URL patterns

5. API Integration Layer

API Client Design Pattern

We'll implement a centralized API client using Axios with request/response interceptors for consistent handling across the application. The client will be built as a singleton to ensure consistent configuration and error handling.

// api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';

class ApiClient {
  private client: AxiosInstance;

  constructor() {
    this.client = axios.create({
      baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.example.com',
      timeout: 10000,
      headers: {
        'Content-Type': 'application/json',
      },
    });

    this.setupInterceptors();
  }

  private setupInterceptors() {
    // Request interceptor for auth token injection
    this.client.interceptors.request.use(
      (config) => {
        const token = localStorage.getItem('authToken');
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );

    // Response interceptor for error handling and normalization
    this.client.interceptors.response.use(
      (response) => response,
      (error) => {
        if (error.response?.status === 401) {
          // Handle auth errors
          localStorage.removeItem('authToken');
          window.location.href = '/login';
        }
        return Promise.reject(error);
      }
    );
  }

  public get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    return this.client.get<T>(url, config);
  }

  public post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    return this.client.post<T>(url, data, config);
  }

  public put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    return this.client.put<T>(url, data, config);
  }

  public delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    return this.client.delete<T>(url, config);
  }
}

export const apiClient = new ApiClient();

Request/Response Interceptors

The interceptors handle:

  1. Authentication: Automatically injects auth tokens from localStorage
  2. Error Handling: Centralized error processing with specific handling for auth failures
  3. Response Normalization: Ensures consistent response structure across all API calls

Error Handling and Retry Strategy

We implement a retry mechanism with exponential backoff for transient network errors:

// api/utils.ts
export const retryWithBackoff = async <T>(
  fn: () => Promise<T>,
  retries = 3,
  delay = 1000
): Promise<T> => {
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === retries) throw error;
      
      // Exponential backoff with jitter
      const waitTime = delay * Math.pow(2, i) + Math.random() * 1000;
      await new Promise(resolve => setTimeout(resolve, waitTime));
    }
  }
  throw new Error('Retry limit exceeded');
};

Loading, Error, and Empty States Design

We implement a consistent state management approach using custom hooks that provide loading, error, and data states:

// api/hooks/useImageGallery.ts
import { useState, useEffect } from 'react';
import { apiClient } from '../client';

interface Image {
  id: string;
  title: string;
  url: string;
  thumbnailUrl: string;
  width: number;
  height: number;
  uploadedAt: string;
}

interface GalleryState {
  images: Image[];
  loading: boolean;
  error: string | null;
  hasMore: boolean;
  page: number;
}

export const useImageGallery = (page = 1, limit = 20) => {
  const [state, setState] = useState<GalleryState>({
    images: [],
    loading: false,
    error: null,
    hasMore: true,
    page,
  });

  const fetchImages = async () => {
    if (state.loading) return;

    setState(prev => ({ ...prev, loading: true, error: null }));

    try {
      const response = await apiClient.get<{ images: Image[]; hasMore: boolean }>(
        `/gallery?page=${page}&limit=${limit}`
      );

      setState(prev => ({
        ...prev,
        images: prev.page === 1 ? response.images : [...prev.images, ...response.images],
        hasMore: response.hasMore,
        page: page,
        loading: false,
      }));
    } catch (error) {
      setState(prev => ({
        ...prev,
        loading: false,
        error: 'Failed to load images. Please try again.',
      }));
    }
  };

  useEffect(() => {
    fetchImages();
  }, [page]);

  return {
    ...state,
    refetch: fetchImages,
  };
};

Data Fetching Strategy

For image gallery, we use Client-Side Rendering (CSR) with:

  • Initial Load: Fetch first page of images on component mount
  • Infinite Scroll: Load additional pages as user scrolls
  • Caching: Use React Query for intelligent caching and invalidation

API Response Transformation Layer

We implement a DTO-to-domain model mapping layer:

// api/models/Image.ts
export interface ImageDto {
  id: string;
  title: string;
  url: string;
  thumbnail_url: string;
  width: number;
  height: number;
  uploaded_at: string;
}

export interface Image {
  id: string;
  title: string;
  url: string;
  thumbnailUrl: string;
  width: number;
  height: number;
  uploadedAt: Date;
}

export const transformImageDto = (dto: ImageDto): Image => ({
  id: dto.id,
  title: dto.title,
  url: dto.url,
  thumbnailUrl: dto.thumbnail_url,
  width: dto.width,
  height: dto.height,
  uploadedAt: new Date(dto.uploaded_at),
});

Sequence Diagram for Primary Data Flow

sequenceDiagram
    participant User
    participant GalleryComponent
    participant ImageService
    participant ApiClient
    participant Server

    User->>GalleryComponent: Component mounts
    GalleryComponent->>ImageService: fetchImages(page=1)
    ImageService->>ApiClient: GET /gallery?page=1&limit=20
    ApiClient->>Server: Request
    Server-->>ApiClient: Response with images
    ApiClient-->>ImageService: Transformed image data
    ImageService-->>GalleryComponent: Updated state with images
    GalleryComponent->>User: Render gallery with images

The API integration layer provides a robust foundation for image gallery functionality with proper error handling, retry logic, and consistent data transformation patterns that support the overall component architecture and performance requirements.

6. Performance Design

Core Web Vitals Targets

  • Largest Contentful Paint (LCP): < 2.5s for all image gallery routes
  • Interaction to Next Paint (INP): < 200ms for user interactions (clicks, keyboard navigation)
  • Cumulative Layout Shift (CLS): < 0.1 for all gallery layouts and transitions

Bundle Size Budget per Route

  • Image Gallery Main Route: < 150KB (excluding image assets)
  • Image Detail Modal Route: < 80KB additional bundle size
  • Lazy-loaded Image Components: < 25KB total for all image loading utilities

Critical Rendering Path Optimization

  • Critical CSS: Inline minimal gallery layout styles (max 5KB)
  • CSS Loading Strategy:
    • Critical layout styles inlined in HTML head
    • Non-critical gallery styling loaded asynchronously via CSS-in-JS or dynamic imports
  • JavaScript Loading:
    • Core gallery logic (image loading, keyboard navigation) loaded eagerly
    • Non-critical features (e.g., image editing tools) loaded on demand

Image Optimization Strategy

// Image optimization configuration
const imageConfig = {
  formats: ['webp', 'jpeg'],
  sizes: [320, 480, 640, 768, 1024, 1280, 1600],
  quality: 75,
  blurUp: true,
  placeholder: 'blur'
};
  • Responsive Images:
    • Use srcSet with multiple image sizes
    • Implement sizes attribute for proper viewport targeting
  • Lazy Loading:
    • Default to loading="lazy" attribute on all image elements
    • Custom intersection observer for pre-loading images near viewport
  • Image Format Optimization:
    • Prefer WebP format where supported (90% smaller than JPEG)
    • Fallback to JPEG for older browsers

Font Loading Strategy

  • Critical Fonts:
    • Load minimal gallery font subset (e.g., 400, 500 weights) via font-display: swap
  • Non-Critical Fonts:
    • Load additional font weights asynchronously after initial render
  • Font Preloading:
    • Use preload for critical font files in HTML head

Memoization Strategy

// Example memoization patterns for performance
const ImageCard = React.memo(({ image, onClick }: ImageCardProps) => {
  const handleClick = useCallback(() => {
    onClick(image);
  }, [image, onClick]);

  return (
    <div 
      className="image-card"
      onClick={handleClick}
      role="button"
      tabIndex={0}
    >
      <img 
        src={image.thumbnailUrl} 
        alt={image.altText}
        loading="lazy"
        width={image.width}
        height={image.height}
      />
    </div>
  );
});

const GalleryGrid = React.memo(({ images, onImageClick }: GalleryProps) => {
  const gridItems = useMemo(() => 
    images.map(image => (
      <ImageCard 
        key={image.id} 
        image={image} 
        onClick={onImageClick}
      />
    )), [images, onImageClick]
  );

  return (
    <div className="gallery-grid">
      {gridItems}
    </div>
  );
});
  • React.memo: Apply to image cards and grid components with stable props
  • useCallback: Memoize event handlers that depend on image data
  • useMemo: Cache computed values like filtered images or layout calculations
  • Avoid over-memoization: Don't memoize props that change frequently

Virtualization for Large Lists

// Virtualized image gallery implementation
import { FixedSizeList as List } from 'react-window';

const VirtualizedImageGallery = ({ images, onImageClick }: VirtualizedGalleryProps) => {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style} className="virtualized-image-row">
      <ImageCard 
        image={images[index]} 
        onClick={onImageClick}
      />
    </div>
  );

  return (
    <List
      height={600}
      itemCount={images.length}
      itemSize={200}
      width="100%"
    >
      {Row}
    </List>
  );
};
  • react-window: Use for large image collections (>100 items)
  • Virtualized rendering: Only render visible items + buffer
  • Buffer size: 50% of viewport height to prevent flickering during scroll

Prefetching and Preloading Strategy

// Prefetching implementation for gallery navigation
const ImageGallery = ({ images, currentImageIndex }: GalleryProps) => {
  const [prefetchImages, setPrefetchImages] = useState<Image[]>([]);
  
  useEffect(() => {
    // Prefetch next 3 images
    const nextImages = images.slice(
      currentImageIndex + 1, 
      currentImageIndex + 4
    );
    
    setPrefetchImages(nextImages);
    
    // Preload next image for smoother transitions
    const preloadNext = new Image();
    preloadNext.src = nextImages[0]?.fullImageUrl || '';
    
  }, [currentImageIndex, images]);

  return (
    <div className="image-gallery">
      {prefetchImages.map(image => (
        <img 
          key={image.id} 
          src={image.thumbnailUrl} 
          style={{ display: 'none' }} 
          alt="" 
        />
      ))}
    </div>
  );
};
  • Prefetch Next Images: Load 3-5 images ahead of current view
  • Preload Full-Size Images: When user clicks thumbnail, preload full-size image
  • Preconnect to Image Domains: Use <link rel="preconnect"> for image CDN domains
  • Priority Preloading: Use preload for images in viewport or likely to enter viewport soon

7. Error Handling & Resilience

Error Boundary Placement Strategy

Error boundaries should be strategically placed to prevent cascading failures while maintaining graceful degradation. For an image gallery, we'll implement a hierarchical error boundary structure:

graph TD
    A[GalleryContainer] --> B[ImageGrid]
    B --> C[ImageCard]
    C --> D[ImageLoader]
    
    A --> E[GalleryErrorBoundary]
    B --> F[GridErrorBoundary]
    C --> G[CardErrorBoundary]

The GalleryErrorBoundary will catch any unhandled errors in the entire gallery, while GridErrorBoundary handles grid-level issues and CardErrorBoundary manages individual image card failures.

Global Error Handler Design

A centralized error handler will capture unhandled errors and provide appropriate user feedback:

// ErrorHandlerContext.tsx
import React, { createContext, useContext, useState, useCallback } from 'react';

interface ErrorHandlerContextType {
  handleError: (error: Error, errorInfo?: string) => void;
  clearError: () => void;
  hasError: boolean;
}

const ErrorHandlerContext = createContext<ErrorHandlerContextType | undefined>(undefined);

export const useErrorHandler = () => {
  const context = useContext(ErrorHandlerContext);
  if (!context) {
    throw new Error('useErrorHandler must be used within ErrorHandlerProvider');
  }
  return context;
};

export const ErrorHandlerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [error, setError] = useState<Error | null>(null);
  const [errorInfo, setErrorInfo] = useState<string | null>(null);

  const handleError = useCallback((error: Error, errorInfo?: string) => {
    console.error('Gallery Error:', error);
    setError(error);
    setErrorInfo(errorInfo || null);
    
    // Report to monitoring service
    if (window.Sentry) {
      window.Sentry.captureException(error, { contexts: { errorInfo } });
    }
  }, []);

  const clearError = useCallback(() => {
    setError(null);
    setErrorInfo(null);
  }, []);

  return (
    <ErrorHandlerContext.Provider value={{ handleError, clearError, hasError: !!error }}>
      {children}
    </ErrorHandlerContext.Provider>
  );
};

Offline / Network Failure Handling

The gallery must gracefully handle network failures:

// useNetworkStatus.ts
import { useState, useEffect } from 'react';

export const useNetworkStatus = () => {
  const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);

  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);
    };
  }, []);

  return isOnline;
};

// ImageCard.tsx
const ImageCard: React.FC<ImageCardProps> = ({ image, onImageLoad }) => {
  const [isLoaded, setIsLoaded] = useState(false);
  const [hasError, setHasError] = useState(false);
  const networkStatus = useNetworkStatus();
  
  const handleImageLoad = () => {
    setIsLoaded(true);
    onImageLoad?.();
  };

  const handleImageError = () => {
    setHasError(true);
  };

  if (hasError) {
    return (
      <div className="image-card-error">
        <span className="error-icon">⚠️</span>
        <p>Failed to load image</p>
      </div>
    );
  }

  if (!isLoaded && !networkStatus) {
    return (
      <div className="image-card-offline">
        <span className="offline-icon">📶</span>
        <p>Offline - retry when online</p>
      </div>
    );
  }

  return (
    <div className="image-card">
      <img 
        src={image.url} 
        alt={image.alt}
        onLoad={handleImageLoad}
        onError={handleImageError}
        className={isLoaded ? "loaded" : "loading"}
      />
    </div>
  );
};

Graceful Degradation Patterns

When images fail to load or network is unavailable, the gallery should degrade gracefully:

  1. Image Loading Failures: Show placeholder with error message
  2. Network Failures: Display offline indicator and retry mechanism
  3. Memory Issues: Implement image virtualization to prevent OOM errors
  4. Browser Compatibility: Fallback to simpler layouts for older browsers

User Feedback on Errors

// ToastManager.tsx
import React from 'react';
import { ToastContainer, toast } from 'react-toastify';

export const ToastManager: React.FC = () => {
  return (
    <ToastContainer 
      position="bottom-right"
      autoClose={5000}
      hideProgressBar={false}
      newestOnTop={false}
      closeOnClick
      rtl={false}
      pauseOnFocusLoss
      draggable
      pauseOnHover
    />
  );
};

// ErrorToast.tsx
export const showErrorToast = (message: string, error?: Error) => {
  toast.error(message, {
    toastId: 'gallery-error',
    autoClose: 8000,
    onClose: () => {
      // Optionally log error to monitoring service
      if (error && window.Sentry) {
        window.Sentry.captureException(error);
      }
    }
  });
};

// ImageCard.tsx - Error handling integration
const ImageCard: React.FC<ImageCardProps> = ({ image, onImageLoad }) => {
  const [isLoaded, setIsLoaded] = useState(false);
  const [hasError, setHasError] = useState(false);
  const { handleError } = useErrorHandler();
  
  const handleImageLoad = () => {
    setIsLoaded(true);
    onImageLoad?.();
  };

  const handleImageError = () => {
    setHasError(true);
    handleError(new Error(`Failed to load image: ${image.url}`), `Image URL: ${image.url}`);
    showErrorToast(`Failed to load image: ${image.alt || 'Untitled'}`);
  };

  // ... rest of component logic
};

Error Boundary Implementation

// GalleryErrorBoundary.tsx
class GalleryErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean; error?: Error }
> {
  constructor(props: { children: React.ReactNode }) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Gallery Error Boundary caught:', error, errorInfo);
    
    // Report to monitoring service
    if (window.Sentry) {
      window.Sentry.captureException(error, { contexts: { errorInfo } });
    }
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="gallery-error-container">
          <h2>Something went wrong</h2>
          <p>We're sorry, but we couldn't display the gallery at this time.</p>
          <button onClick={() => window.location.reload()}>
            Reload Page
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

Retry Mechanism Strategy

Implement exponential backoff retry for failed image loads:

// useImageRetry.ts
import { useState, useEffect } from 'react';

export const useImageRetry = (src: string, maxRetries = 3) => {
  const [retryCount, setRetryCount] = useState(0);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (retryCount > maxRetries) {
      setError(new Error(`Failed to load image after ${maxRetries} attempts`));
      setIsLoading(false);
      return;
    }

    const img = new Image();
    
    const handleLoad = () => {
      setIsLoading(false);
    };

    const handleError = () => {
      if (retryCount < maxRetries) {
        setTimeout(() => {
          setRetryCount(prev => prev + 1);
        }, Math.pow(2, retryCount) * 1000); // Exponential backoff
      } else {
        setError(new Error(`Failed to load image: ${src}`));
        setIsLoading(false);
      }
    };

    img.addEventListener('load', handleLoad);
    img.addEventListener('error', handleError);

    img.src = src;

    return () => {
      img.removeEventListener('load', handleLoad);
      img.removeEventListener('error', handleError);
    };
  }, [src, retryCount, maxRetries]);

  return { isLoading, error, retryCount };
};

8. Testing Strategy

Unit Testing Approach

Component Tests (70% of test coverage):

  • Test all visual states: loading, loaded, error, empty
  • Test prop combinations and edge cases (empty array, single image, 100 images)
  • Test keyboard navigation and focus behavior
  • Test responsive breakpoints and layout changes
  • Test image loading states with IntersectionObserver mock

Utility Tests (20% of test coverage):

  • Test image URL generation helpers
  • Test responsive image size calculation logic
  • Test accessibility attribute generation
  • Test error boundary error message formatting

Test Coverage Targets:

  • Components: 85% line coverage, 90% branch coverage
  • Utilities: 95% line coverage
  • Critical paths: 100% test coverage

Integration Testing Strategy

Image Gallery Integration Tests:

  • Test image loading sequence with mock API responses
  • Test infinite scroll behavior and loading states
  • Test responsive layout changes during image load
  • Test error recovery flow from network failure to retry

State Management Integration:

  • Test state transitions in image loading lifecycle
  • Test optimistic updates with mock server responses
  • Test cache invalidation and refetch behavior

E2E Testing Scope

Cypress Test Coverage:

  • User flows: image selection, modal view, download
  • Responsive behavior across device sizes (mobile, tablet, desktop)
  • Keyboard navigation accessibility testing
  • Performance regression tests (LCP, FCP targets)

Critical User Journeys:

  1. Gallery load → image click → modal view → close
  2. Infinite scroll to end → loading states → new images
  3. Network error recovery → retry functionality
  4. Keyboard navigation through gallery items

Visual Regression Testing

Tool: Percy (or Chromatic)

  • Test all major breakpoints (mobile, tablet, desktop)
  • Test loading states and error states
  • Test modal overlay behavior
  • Test responsive grid layouts

Accessibility Testing Automation

Axe-core Integration:

  • Run axe-core on all gallery states (loaded, loading, error)
  • Test keyboard navigation flow
  • Validate ARIA attributes and roles
  • Check color contrast compliance

Test Data Strategy

Fixtures:

// test/fixtures/imageGallery.ts
export const mockImages = [
  {
    id: '1',
    url: 'https://example.com/image1.jpg',
    alt: 'Beautiful landscape',
    width: 800,
    height: 600,
    thumbnailUrl: 'https://example.com/thumb1.jpg'
  },
  {
    id: '2',
    url: 'https://example.com/image2.jpg',
    alt: 'Mountain view',
    width: 1024,
    height: 768,
    thumbnailUrl: 'https://example.com/thumb2.jpg'
  }
];

export const mockErrorState = {
  error: 'Failed to load images',
  retryable: true
};

Mock API Setup:

  • Use MSW (Mock Service Worker) for realistic network mocking
  • Mock different response scenarios: success, timeout, 404, 500
  • Test loading states with realistic delay simulation

Test Environment Configuration:

// jest.config.ts
export default {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
  moduleNameMapper: {
    '\\.svg$': '<rootDir>/src/test/__mocks__/svgMock.ts'
  },
  coverageThreshold: {
    'src/components/ImageGallery': {
      lines: 85,
      branches: 80
    }
  }
};

9. Accessibility (A11y)

WCAG 2.1 AA Compliance Checklist

The Image Gallery component must meet all WCAG 2.1 AA success criteria:

  • 1.1.1 Non-text Content (Level A): All images must have meaningful alt text or be marked as decorative
  • 1.3.1 Info and Relationships (Level A): Semantic HTML structure must be preserved for image grid layout
  • 1.4.1 Use of Color (Level A): Color contrast ratios must meet 4.5:1 for text and 3:1 for interactive elements
  • 1.4.3 Contrast (Minimum) (Level AA): Text and images must have sufficient contrast ratios
  • 2.1.1 Keyboard (Level A): All functionality must be operable via keyboard navigation
  • 2.4.1 Bypass Blocks (Level A): Skip links must be available for keyboard users
  • 2.4.3 Focus Order (Level A): Visual focus order must match logical reading order
  • 2.4.6 Headings and Labels (Level AA): All interactive elements must have descriptive labels
  • 3.1.1 Language of Page (Level A): Language attribute must be set on the root element
  • 4.1.2 Name, Role, Value (Level AA): ARIA attributes must be valid and properly implemented

Keyboard Navigation Support

The gallery supports full keyboard navigation:

  • Tab Navigation: Moves through image thumbnails and interactive elements in logical order
  • Arrow Keys: Navigate between images in the gallery view (left/right arrows)
  • Enter/Space: Activate image preview modal or selection
  • Escape: Close modal view and return to gallery
  • Home/End: Jump to first/last image in the gallery

Focus Management Strategy

Focus Trapping: When modal is open, focus is trapped within the modal boundaries:

const handleModalFocus = (event: KeyboardEvent) => {
  if (event.key === 'Tab') {
    const focusableElements = modalRef.current?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    ) as NodeListOf<HTMLElement>;
    
    if (focusableElements.length === 0) return;
    
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    
    if (event.shiftKey && document.activeElement === firstElement) {
      lastElement.focus();
      event.preventDefault();
    } else if (!event.shiftKey && document.activeElement === lastElement) {
      firstElement.focus();
      event.preventDefault();
    }
  }
};

Focus Restoration: When modal closes, focus returns to the previously focused element:

const restoreFocus = () => {
  if (previousActiveElement) {
    previousActiveElement.focus();
  }
};

ARIA Roles and Attributes Plan

// Gallery container
<div 
  role="region" 
  aria-label="Image gallery"
  tabIndex={0}
>

// Image thumbnail
<li 
  role="listitem"
  aria-label={`Image ${index + 1} of ${images.length}`}
>
  <img 
    src={image.url}
    alt={image.altText || `Gallery image ${index + 1}`}
    role="button"
    tabIndex={0}
    aria-describedby={`image-description-${index}`}
    onClick={() => openModal(index)}
  />
</li>

// Modal
<div 
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  aria-describedby="modal-description"
>
  <h2 id="modal-title">Image Preview</h2>
  <img 
    src={currentImage.url}
    alt={currentImage.altText}
    role="img"
    aria-label={`Full size view of ${currentImage.altText}`}
  />
</div>

Screen Reader Compatibility

  • Image Descriptions: Each image includes descriptive alt text or aria-describedby references
  • Progress Indicators: Screen readers announce current position in gallery (e.g., "Image 3 of 12")
  • Keyboard Instructions: Clear announcements when keyboard navigation is active
  • Dynamic Content Updates: When images load or modal opens, screen readers are notified of content changes

Additional ARIA Considerations

  • Live Regions: For loading states and progress updates
<span 
  role="status" 
  aria-live="polite"
  className="sr-only"
>
  Loading image {currentIndex + 1} of {imageCount}
</span>
  • Landmark Roles: Proper semantic structure with main, section and nav elements
  • Skip Links: Hidden navigation links to bypass gallery content for keyboard users

Keyboard Navigation Flow

  1. User tabs through gallery thumbnails
  2. Press Enter or Space to open image preview modal
  3. Use arrow keys to navigate between images in modal
  4. Press Escape to close modal and return focus to gallery
  5. Use Home/End keys to jump to first/last image in gallery

Focus Order Validation

The component ensures that focus order follows logical reading flow:

  1. Gallery navigation controls (if present)
  2. Image thumbnails in sequence
  3. Modal close button
  4. Navigation buttons within modal (previous/next)
  5. Image description elements

Dynamic Content Updates

When images are loaded or modal transitions occur, the component uses ARIA live regions to announce changes:

const updateScreenReaderStatus = (message: string) => {
  setLiveRegionMessage(message);
  // Clear after short delay to prevent repeated announcements
  setTimeout(() => setLiveRegionMessage(''), 1000);
};

This ensures screen reader users are always informed of current state changes without disrupting their navigation flow.

10. Security Considerations

XSS Prevention Strategy

The image gallery must prevent cross-site scripting attacks through comprehensive input sanitization and output encoding:

// Secure image URL validation and sanitization
const validateImageUrl = (url: string): boolean => {
  try {
    const parsedUrl = new URL(url);
    // Allow only HTTPS images from trusted domains
    const allowedDomains = ['images.unsplash.com', 'picsum.photos', 'source.unsplash.com'];
    return parsedUrl.protocol === 'https:' && allowedDomains.includes(parsedUrl.hostname);
  } catch (e) {
    return false;
  }
};

// Sanitize image metadata to prevent script injection
const sanitizeImageMetadata = (metadata: ImageMetadata): ImageMetadata => {
  return {
    ...metadata,
    title: sanitizeHtml(metadata.title),
    description: sanitizeHtml(metadata.description),
    altText: sanitizeHtml(metadata.altText)
  };
};

CSRF Protection

For any image upload or metadata modification functionality, implement CSRF token validation:

// CSRF token management in gallery context
const useCSRFToken = () => {
  const [csrfToken, setCsrfToken] = useState<string | null>(null);
  
  useEffect(() => {
    // Fetch CSRF token from server on component mount
    fetch('/api/csrf-token')
      .then(res => res.json())
      .then(data => setCsrfToken(data.token));
  }, []);

  return csrfToken;
};

Content Security Policy (CSP) Headers

Implement CSP headers to restrict image sources and prevent inline script execution:

Content-Security-Policy: 
  default-src 'self'; 
  img-src 'self' https://images.unsplash.com https://picsum.photos data:; 
  script-src 'self' 'unsafe-inline'; 
  style-src 'self' 'unsafe-inline';

Sensitive Data Handling in Frontend State

Image metadata and user data should never be stored in insecure client-side storage:

// Secure state management for sensitive image data
interface SecureImageState {
  // Never store sensitive metadata in localStorage/sessionStorage
  images: ImageItem[]; // Only non-sensitive data
  currentUser: User | null;
}

// Secure image data handling
const handleImageUpload = async (file: File) => {
  // Validate file type and size server-side
  if (!file.type.startsWith('image/')) {
    throw new Error('Invalid file type');
  }
  
  // Upload to secure server endpoint with proper validation
  const formData = new FormData();
  formData.append('image', file);
  
  const response = await fetch('/api/upload-image', {
    method: 'POST',
    body: formData,
    headers: {
      // Include CSRF token if needed
      'X-CSRF-Token': getCSRFToken()
    }
  });
  
  return response.json();
};

Input Sanitization Approach

All user-provided image data must undergo strict sanitization:

// Comprehensive input sanitization for image gallery
const sanitizeInput = (input: string): string => {
  // Remove potentially dangerous characters
  return input
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
};

// Validate image metadata fields
const validateImageMetadata = (metadata: Partial<ImageMetadata>): boolean => {
  const { title, altText, description } = metadata;
  
  // Check for malicious content in text fields
  if (title && sanitizeInput(title) !== title) return false;
  if (altText && sanitizeInput(altText) !== altText) return false;
  if (description && sanitizeInput(description) !== description) return false;
  
  // Validate URL format
  if (metadata.url && !validateImageUrl(metadata.url)) return false;
  
  return true;
};

Security Headers Implementation

Implement security headers at the application level:

// Security headers configuration
const securityHeaders = {
  'X-Content-Type-Options': 'nosniff',
  'X-Frame-Options': 'DENY',
  'X-XSS-Protection': '1; mode=block',
  'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
  'Content-Security-Policy': "img-src 'self' https: data: blob:; default-src 'none'"
};

// Apply security headers in middleware or server config

Image Source Validation

Implement strict source validation to prevent loading images from untrusted sources:

// Image source validation middleware
const validateImageSource = (src: string): boolean => {
  // Reject any URLs that don't match expected patterns
  const trustedPatterns = [
    /^https:\/\/images\.unsplash\.com\/.*$/,
    /^https:\/\/picsum\.photos\/.*$/,
    /^data:image\/.*;base64,/
  ];
  
  return trustedPatterns.some(pattern => pattern.test(src));
};

// Secure image rendering component
const SecureImageRenderer = ({ src, alt }: { src: string; alt: string }) => {
  if (!validateImageSource(src)) {
    console.warn('Blocked potentially unsafe image source:', src);
    return <div className="error">Image source not allowed</div>;
  }
  
  return <img src={src} alt={alt} loading="lazy" />;
};

Authentication & Authorization

Ensure only authorized users can modify image metadata:

// Authenticated image operations
const updateImageMetadata = async (imageId: string, metadata: ImageMetadata) => {
  // Verify user has permission to modify this image
  const hasPermission = await checkUserPermission(imageId);
  
  if (!hasPermission) {
    throw new Error('Unauthorized to modify image');
  }
  
  // Validate and sanitize metadata before update
  const sanitizedMetadata = sanitizeImageMetadata(metadata);
  
  return fetch(`/api/images/${imageId}/metadata`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getAuthToken()}`
    },
    body: JSON.stringify(sanitizedMetadata)
  });
};

Secure File Upload Handling

Implement secure image upload with proper validation and sanitization:

// Secure file upload handler
const handleSecureImageUpload = async (file: File) => {
  // Validate file size (max 10MB)
  if (file.size > 10 * 1024 * 1024) {
    throw new Error('File too large');
  }
  
  // Validate file type
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
  if (!allowedTypes.includes(file.type)) {
    throw new Error('Unsupported file type');
  }
  
  // Sanitize filename
  const sanitizedFilename = file.name.replace(/[^a-zA-Z0-9._-]/g, '');
  
  // Upload to secure storage with proper validation
  const formData = new FormData();
  formData.append('file', file);
  formData.append('filename', sanitizedFilename);
  
  const response = await fetch('/api/secure-upload', {
    method: 'POST',
    body: formData,
    headers: {
      // Include security tokens and validation headers
      'X-Client-Version': getClientVersion(),
      'X-Request-ID': generateRequestId()
    }
  });
  
  return response.json();
};

11. Internationalization (i18n)

Translation Key Structure and Naming Convention

Following BEM-style naming with feature-scoped keys:

// Translation keys for image gallery feature
{
  "imageGallery": {
    "title": "Image Gallery",
    "loading": "Loading images...",
    "noImages": "No images found",
    "filterBy": "Filter by",
    "sortBy": "Sort by",
    "viewAll": "View All",
    "viewGrid": "Grid View",
    "viewList": "List View",
    "imageCount": "{{count}} images",
    "imageDetails": "Image details",
    "close": "Close",
    "download": "Download",
    "share": "Share"
  }
}

RTL Support Considerations

RTL support requires:

  • Layout direction adjustments (flexbox, grid, text alignment)
  • Icon rotation for directional indicators (chevrons, arrows)
  • Navigation flow reversal in multi-step interactions
  • Component alignment and spacing adjustments

Date, Number, Currency Formatting Strategy

Date formatting uses Intl.DateTimeFormat with locale-aware fallbacks:

// Example date formatting in i18n context
const formatDate = (date: Date, locale: string) => {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }).format(date);
};

Locale Detection and Switching Strategy

Automatic locale detection via:

  1. Browser language preference (navigator.language)
  2. User preference in settings (persisted in localStorage)
  3. URL parameter override (?lang=fr-FR)

Locale switching implementation:

// Simple locale context for gallery
interface LocaleContext {
  currentLocale: string;
  supportedLocales: string[];
  switchLocale: (newLocale: string) => void;
}

const useLocale = () => {
  const [currentLocale, setCurrentLocale] = useState<string>(
    detectUserLocale() || 'en-US'
  );
  
  const switchLocale = (newLocale: string) => {
    setCurrentLocale(newLocale);
    localStorage.setItem('preferredLocale', newLocale);
  };
  
  return { currentLocale, switchLocale };
};

Translation File Organization

Translation files organized by locale:

/src/locales/
  ├── en-US.json
  ├── fr-FR.json
  ├── ar-SA.json
  └── es-ES.json

i18n Integration with Component

Using react-i18next for seamless integration:

// Gallery component using i18n
const ImageGallery = () => {
  const { t, i18n } = useTranslation('imageGallery');
  
  return (
    <div className="gallery" dir={i18n.dir()}>
      <h2>{t('title')}</h2>
      <p>{t('imageCount', { count: imageCount })}</p>
    </div>
  );
};

Dynamic Text Direction Handling

Component-level direction handling:

// Direction-aware gallery container
const GalleryContainer = ({ children }: { children: ReactNode }) => {
  const { i18n } = useTranslation();
  
  return (
    <div 
      className="gallery-container"
      dir={i18n.dir()}
      style={{ 
        direction: i18n.dir(),
        textAlign: i18n.dir() === 'rtl' ? 'right' : 'left'
      }}
    >
      {children}
    </div>
  );
};

Testing i18n Implementation

Test coverage includes:

  • Translation key existence validation
  • RTL layout verification in component tests
  • Locale switching behavior testing
  • Date/time formatting consistency across locales

Performance Considerations

Translation bundles are loaded on-demand per locale:

  • Default bundle includes English (en-US)
  • Additional bundles fetched via dynamic imports
  • Fallback mechanism for missing translations

Accessibility and i18n Synergy

ARIA labels and screen reader support:

// Accessible translation keys with ARIA integration
const ImageCard = ({ image, locale }: { image: Image, locale: string }) => {
  const { t } = useTranslation('imageGallery');
  
  return (
    <div 
      role="article"
      aria-label={t('imageDetails')}
      lang={locale}
    >
      <img 
        src={image.url} 
        alt={image.altText}
        aria-describedby={`desc-${image.id}`}
      />
      <p id={`desc-${image.id}`}>
        {t('imageDetails')}
      </p>
    </div>
  );
};

12. Monitoring & Observability

Real User Monitoring (RUM) Metrics to Track

The image gallery component should track key performance indicators to understand user experience:

  • Largest Contentful Paint (LCP): Target <2.5s for image loading completion
  • First Input Delay (FID): Target <100ms for user interactions with gallery controls
  • Cumulative Layout Shift (CLS): Target <0.1 for layout stability during image loading
  • Time to First Byte (TTFB): Track average response times from image API calls
  • Image Load Success Rate: Percentage of images that load successfully vs. failed
  • Gallery Render Time: Time from component mount to full gallery display

Error Tracking Integration

Integrate with Sentry or similar error monitoring service:

// Error tracking integration example
import * as Sentry from '@sentry/react';

const handleError = (error: Error, context: string) => {
  Sentry.captureException(error, {
    contexts: {
      gallery: { context, timestamp: Date.now() }
    }
  });
};

Custom Events and User Journey Tracking

Implement custom event tracking for key user interactions:

// Event tracking implementation
const trackGalleryEvent = (event: string, properties?: Record<string, any>) => {
  if (typeof window !== 'undefined' && window.gtag) {
    window.gtag('event', event, {
      ...properties,
      timestamp: Date.now(),
      galleryId: 'image-gallery-123'
    });
  }
};

Events to track:

  • gallery_image_loaded: When individual image successfully loads
  • gallery_image_failed: When image fails to load with retry attempts
  • gallery_scroll_to_end: When user reaches end of gallery (for infinite scroll)
  • gallery_image_clicked: When user clicks on an image for detail view
  • gallery_image_zoomed: When user zooms into an image (if feature exists)

Performance Monitoring and Regression Detection

Set up performance baselines for key metrics:

// Performance monitoring setup
const PERFORMANCE_THRESHOLDS = {
  lcp: 2500, // 2.5 seconds
  fid: 100,  // 100ms
  cls: 0.1   // 0.1 layout shift
};

const monitorPerformance = () => {
  if (typeof window !== 'undefined') {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'largest-contentful-paint') {
          const lcpValue = entry.startTime;
          if (lcpValue > PERFORMANCE_THRESHOLDS.lcp) {
            // Log performance regression
            console.warn('LCP performance degradation detected', { lcpValue });
          }
        }
      }
    });
    
    observer.observe({ entryTypes: ['largest-contentful-paint'] });
  }
};

Health Checks and Alerting

Implement health checks for the gallery's core functionality:

  • Image API Response Time: Alert if average response time exceeds 3 seconds
  • Image Load Success Rate: Alert if <95% of images load successfully
  • Gallery Initialization Time: Alert if gallery takes >5 seconds to render
  • Memory Usage: Monitor for memory leaks in image loading components

Data Collection Strategy

All monitoring data should be collected with:

  • User session IDs for correlation
  • Gallery ID and configuration parameters
  • Device characteristics (screen size, browser type)
  • Network conditions (if available via browser APIs)

Dashboard and Alerting Configuration

Create dashboards in monitoring tools with:

  • Real-time performance metrics
  • Error rate trends over time
  • User engagement patterns
  • Image loading success/failure rates by region or device type

Privacy Considerations

Ensure all monitoring respects user privacy:

  • Anonymize user session IDs
  • Exclude sensitive data from error reports
  • Comply with GDPR and CCPA requirements for tracking
  • Provide opt-out mechanisms where required

Integration with Existing Monitoring Stack

The gallery monitoring should integrate with existing observability infrastructure:

  • Use existing logging frameworks (Winston, Bunyan)
  • Follow established error reporting patterns
  • Align with existing performance monitoring tools (New Relic, Datadog)
  • Support existing alerting systems and Slack integrations

Metrics Export Strategy

Implement metrics export for external monitoring:

// Sample metrics export function
const exportGalleryMetrics = () => {
  const metrics = {
    timestamp: Date.now(),
    imageCount: imageState.images.length,
    loadSuccessRate: imageState.successCount / imageState.totalCount,
    avgLoadTime: imageState.avgLoadTime,
    userInteractions: interactionTracker.getEvents()
  };
  
  // Export to monitoring service or console
  console.log('GalleryMetrics:', JSON.stringify(metrics));
};

13. File & Folder Structure

Proposed Directory Structure

src/
├── components/
│   ├── ImageGallery/
│   │   ├── index.ts
│   │   ├── ImageGallery.tsx
│   │   ├── ImageItem.tsx
│   │   ├── ImageGrid.tsx
│   │   ├── ImageModal.tsx
│   │   └── hooks/
│   │       └── useImageGallery.ts
├── features/
│   └── image-gallery/
│       ├── index.ts
│       ├── ImageGalleryPage.tsx
│       └── routes/
│           └── ImageGalleryRoute.tsx
├── hooks/
│   └── useImagePreload.ts
├── types/
│   └── image-gallery.ts
└── utils/
    └── image-utils.ts

Module Boundaries and Import Rules

  • Feature isolation: ImageGallery is self-contained within features/image-gallery with its own route and page component
  • Shared components: Reusable UI elements like ImageItem and ImageModal are in components/ImageGallery
  • No cross-feature imports: Components in ImageGallery don't import from other feature directories directly
  • Barrel exports: All components in components/ImageGallery export through a single index.ts for clean imports

Shared vs Feature-Specific Code Separation

Shared code:

  • ImageItem and ImageModal are generic enough to potentially be reused in other image-based features
  • Utility functions like image-utils.ts for common transformations
  • Custom hooks like useImagePreload.ts that can be reused across features

Feature-specific code:

  • ImageGalleryPage.tsx and route configuration are specific to this feature's user journey
  • useImageGallery.ts hook encapsulates gallery-specific business logic and state management

This structure follows a feature-based organization that scales well for larger teams while maintaining clear boundaries between features and shared components. The approach balances code reuse with feature encapsulation, making it easy to extract shared components later if needed.

14. Dependencies & Third-Party Libraries

Key Libraries Selected with Justification

1. React (v18+)

  • Justification: Core framework for building UI components with hooks, context, and concurrent rendering capabilities. Essential for modern React development patterns.
  • Bundle size impact: ~150KB (minified + gzip)
  • Alternatives considered: Preact - smaller bundle but less ecosystem support; rejected due to reduced developer experience and tooling compatibility.

2. React Query (v5+)

  • Justification: Production-ready data fetching and state management solution that handles caching, background updates, and optimistic UI patterns. Ideal for image gallery's data loading and pagination needs.
  • Bundle size impact: ~20KB (minified + gzip)
  • Alternatives considered: Redux Toolkit - more boilerplate for simple data fetching; Context API + custom hooks - requires significant implementation effort for caching and retry logic.

3. React Window (v1.8+)

  • Justification: Efficient virtualization library for rendering large image collections without performance degradation. Critical for handling thousands of images.
  • Bundle size impact: ~15KB (minified + gzip)
  • Alternatives considered: react-virtual - similar feature set but less mature; custom virtualization - high maintenance cost.

4. Axios (v1.6+)

  • Justification: Robust HTTP client with request/response interceptors, cancelation support, and better error handling than native fetch. Essential for API integration.
  • Bundle size impact: ~20KB (minified + gzip)
  • Alternatives considered: Native fetch - requires more boilerplate for interceptors and error handling; ky - better performance but less established.

5. Framer Motion (v10+)

  • Justification: Production-ready animation library that enables smooth image transitions, loading states, and modal interactions with minimal performance overhead.
  • Bundle size impact: ~25KB (minified + gzip)
  • Alternatives considered: CSS animations - less flexible and maintainable; react-spring - more complex for simple transitions.

6. React Icons (v4+)

  • Justification: Consistent, accessible icon system with tree-shaking support that reduces bundle size while maintaining design consistency.
  • Bundle size impact: ~5KB (minified + gzip)
  • Alternatives considered: Custom SVGs - more maintenance overhead; iconfont solutions - less accessibility and performance.

Alternative Libraries Considered and Rejected

1. Redux Toolkit

  • Rejected because: Overkill for image gallery's data fetching needs; requires significant boilerplate for simple caching and pagination patterns.

2. Zustand

  • Rejected because: While lightweight, it lacks built-in data fetching capabilities and would require additional libraries for cache invalidation.

3. Emotion / Styled Components

  • Rejected because: Not essential for core image gallery functionality; design system already provides styling approach.

4. React Virtualized

  • Rejected because: Outdated and less actively maintained; react-window provides same functionality with better performance characteristics.

Licensing and Bundle Size Impact Assessment

Bundle size breakdown (estimated):

  • Core React + React Query + React Window: ~200KB
  • Additional libraries (Axios, Framer Motion, React Icons): ~60KB
  • Total estimated bundle size: ~260KB for image gallery feature (excluding base React)

License considerations:

  • All selected libraries are MIT licensed with no commercial restrictions
  • React Query and Framer Motion have permissive licenses suitable for enterprise use
  • Bundle size is within acceptable limits for modern web applications (target <300KB for feature bundles)

Tree-shaking optimization:

  • All libraries support tree-shaking to minimize final bundle size
  • React Query and Framer Motion have granular imports that reduce overhead
  • React Icons provide component-specific imports to avoid loading entire icon sets

15. Open Questions, Risks & Trade-offs

Decisions that need product/engineering alignment

  • Image loading strategy: Should we prioritize lazy loading with placeholders or preloading all images for better perceived performance? This decision impacts both UX and bundle size.
  • Pagination vs. Infinite scroll: The choice between these two approaches requires alignment on data volume expectations, user behavior patterns, and server-side API capabilities.
  • Image optimization pipeline: Whether to use client-side image processing or rely on CDN/image service providers (Cloudinary, Imgix) requires cross-team coordination for infrastructure and costs.

Known trade-offs made in this design

  • Bundle size vs. feature richness: Prioritized minimal dependencies over advanced features like drag-and-drop or image editing to keep the bundle size under 50KB for core gallery functionality.
  • Client-side vs. server-side rendering: Chose CSR over SSR to enable real-time interactions and dynamic filtering but at the cost of initial page load performance for SEO.
  • State management complexity vs. maintainability: Used React Context API instead of Redux Toolkit for simplicity, but this introduces potential performance issues at scale with complex state interactions.

Risks and mitigations

  • Performance degradation with large image sets: Risk of UI jank when rendering hundreds of images. Mitigation includes implementing virtualization with react-window and progressive image loading.
  • Image loading failures: Risk of broken images or loading spinners persisting indefinitely. Mitigation includes robust error boundaries, fallback image handling, and retry mechanisms.
  • Accessibility compliance gaps: Risk of incomplete keyboard navigation or screen reader support. Mitigation includes comprehensive ARIA implementation and regular accessibility audits.

Technical debt acknowledged

  • Hardcoded breakpoints: Using fixed breakpoint values (e.g., 768px) instead of responsive design tokens creates maintenance overhead as design system evolves.
  • Limited image format support: Currently only supports common formats (JPEG, PNG) and doesn't handle WebP or AVIF without additional configuration.
  • No image compression: Client-side image optimization is not implemented, relying on CDN or backend services for image processing.
  • Basic error handling: Simple toast notifications instead of structured error logging or user-facing recovery paths for critical failures.

Was this article helpful?

Topics in this article

More from Frontend LLD

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.