# React Image Optimization: Complete Guide

> Images in React: lazy loading, responsive srcset, WebP and AVIF delivery, CDN integration, and a reusable component for Vite or CRA projects.

Canonical page: https://www.imageguide.dev/guides/react-image-optimization/

Published: 2026-02-15. Updated: 2026-10-17.

Publisher: ImageGuide, published by Sirv. [Editorial method](https://www.imageguide.dev/about/#how-we-write).

## Quick answer

In React, native img and picture elements still need correct dimensions, responsive sources, and loading behavior. Use a build pipeline or image service to generate delivery variants, and keep the likely LCP image discoverable early. React rendering alone does not resize or compress the source file.

Reference for this answer: [MDN responsive image guidance](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Responsive_images).

## Why Image Optimization Matters in React

### SPA-Specific Challenges

Single Page Applications built with React introduce image problems that traditional server-rendered sites do not have:

| Challenge | Description | Impact |
|-----------|-------------|--------|
| Bundle bloat | Static imports add images to JS bundle | Larger initial download |
| Hydration delay | Heavy images block interactive readiness | Poor FID/INP scores |
| Client-side routing | Images not prefetched for new routes | Visible loading on navigation |
| Re-render re-fetches | Poor state management causes duplicate requests | Wasted bandwidth |
| No server-side resizing | Browser receives full-size images | Slow on mobile networks |
| Memory pressure | All route images may stay in memory | Crashes on low-end devices |

### The Cost of Unoptimized Images

```
Unoptimized:
  - Hero image: 2.4MB (4000x3000 JPEG)
  - 12 product thumbnails: 3.6MB total (300KB each)
  - Background patterns: 800KB
  - Total image weight: 6.8MB
  - LCP: 4.2s on 4G

Optimized:
  - Hero image: 180KB (1920x1080 WebP, lazy: false)
  - 12 product thumbnails: 360KB total (30KB each WebP)
  - Background patterns: 45KB (SVG or tiny WebP)
  - Total image weight: 585KB (91% reduction)
  - LCP: 1.4s on 4G
```

## Native HTML Image Optimization in React

Before reaching for libraries, use the native HTML attributes that React supports directly. These cost zero bundle size and work in every browser.

### Essential Image Attributes

```tsx
function HeroImage() {
  return (
    <img
      src="/images/hero-1920.webp"
      srcSet="
        /images/hero-480.webp 480w,
        /images/hero-768.webp 768w,
        /images/hero-1200.webp 1200w,
        /images/hero-1920.webp 1920w
      "
      sizes="100vw"
      alt="Mountain landscape at golden hour"
      width={1920}
      height={1080}
      loading="eager"
      fetchPriority="high"
      decoding="async"
    />
  );
}
```

### Attribute Reference

| Attribute | Purpose | Values | When to Use |
|-----------|---------|--------|-------------|
| `srcSet` | Responsive image sources | `url widthW, ...` | Every content image |
| `sizes` | Viewport-relative display width | `(media) size, ...` | With srcSet |
| `loading` | Loading strategy | `lazy`, `eager` | `lazy` for below-fold |
| `fetchPriority` | Network priority hint | `high`, `low`, `auto` | `high` for LCP image |
| `decoding` | Decode strategy | `async`, `sync`, `auto` | `async` for most images |
| `width` / `height` | Intrinsic dimensions | Pixels (number) | Always (prevents CLS) |

### The `sizes` Attribute

The `sizes` attribute tells the browser how wide the image will display before CSS is parsed:

```tsx
// Full-width hero
<img sizes="100vw" ... />

// Two-column layout on desktop, full-width on mobile
<img sizes="(min-width: 768px) 50vw, 100vw" ... />

// Three-column grid with padding
<img sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" ... />
```

### Picture Element for Format Fallbacks

```tsx
function OptimizedHero() {
  return (
    <picture>
      <source
        type="image/avif"
        srcSet="/images/hero-480.avif 480w, /images/hero-1200.avif 1200w, /images/hero-1920.avif 1920w"
        sizes="100vw"
      />
      <source
        type="image/webp"
        srcSet="/images/hero-480.webp 480w, /images/hero-1200.webp 1200w, /images/hero-1920.webp 1920w"
        sizes="100vw"
      />
      <img
        src="/images/hero-1920.jpg"
        srcSet="/images/hero-480.jpg 480w, /images/hero-1200.jpg 1200w, /images/hero-1920.jpg 1920w"
        sizes="100vw"
        alt="Mountain landscape at golden hour"
        width={1920}
        height={1080}
        loading="eager"
        fetchPriority="high"
        decoding="async"
      />
    </picture>
  );
}
```

## Building a Reusable Image Component

### TypeScript Image Component with Blur Placeholder

```tsx
// components/Image.tsx
import { useState, useRef, useEffect, ImgHTMLAttributes } from 'react';

interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'placeholder'> {
  src: string;
  alt: string;
  width: number;
  height: number;
  priority?: boolean;
  placeholder?: 'blur' | 'empty';
  blurDataURL?: string;
  onLoadingComplete?: () => void;
}

export function Image({
  src,
  alt,
  width,
  height,
  srcSet,
  sizes,
  priority = false,
  placeholder = 'empty',
  blurDataURL,
  onLoadingComplete,
  className = '',
  ...rest
}: ImageProps) {
  const [isLoaded, setIsLoaded] = useState(false);
  const [hasError, setHasError] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);

  useEffect(() => {
    if (imgRef.current?.complete) setIsLoaded(true);
  }, []);

  if (hasError) {
    return (
      <div
        style={{ width, height, background: '#f5f5f5', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
        role="img"
        aria-label={alt}
      >
        <span>Failed to load image</span>
      </div>
    );
  }

  const showBlur = placeholder === 'blur' && blurDataURL && !isLoaded;

  return (
    <div style={{ position: 'relative', maxWidth: width, aspectRatio: `${width}/${height}`, width: '100%' }}>
      {showBlur && (
        <img
          src={blurDataURL}
          alt=""
          aria-hidden="true"
          style={{
            position: 'absolute', inset: 0, width: '100%', height: '100%',
            objectFit: 'cover', filter: 'blur(20px)', transform: 'scale(1.1)',
            transition: 'opacity 0.3s', opacity: isLoaded ? 0 : 1,
          }}
        />
      )}
      <img
        ref={imgRef}
        src={src}
        srcSet={srcSet}
        sizes={sizes}
        alt={alt}
        width={width}
        height={height}
        loading={priority ? 'eager' : 'lazy'}
        fetchPriority={priority ? 'high' : undefined}
        decoding="async"
        onLoad={() => { setIsLoaded(true); onLoadingComplete?.(); }}
        onError={() => setHasError(true)}
        className={className}
        style={{
          transition: 'opacity 0.3s', maxWidth: '100%', height: 'auto',
          opacity: showBlur && !isLoaded ? 0 : 1,
        }}
        {...rest}
      />
    </div>
  );
}
```

### Usage Examples

```tsx
// Hero image - loads immediately with blur placeholder
<Image
  src="/images/hero.webp"
  alt="Company headquarters exterior"
  width={1920}
  height={1080}
  sizes="100vw"
  priority
  placeholder="blur"
  blurDataURL="data:image/webp;base64,UklGRkYAAABXRUJQ..."
/>

// Product thumbnail - lazy loaded
<Image
  src="/images/product-sneaker.webp"
  alt="Running sneaker in midnight blue"
  width={400}
  height={400}
  sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw"
/>
```

## Responsive Images in React

### Art Direction with Picture Element

```tsx
function HeroBanner() {
  return (
    <picture>
      <source media="(max-width: 639px)" srcSet="/images/hero-mobile.webp" width={640} height={800} />
      <source media="(max-width: 1023px)" srcSet="/images/hero-tablet.webp" width={1024} height={600} />
      <img
        src="/images/hero-desktop.webp"
        alt="Team collaboration in modern office"
        width={1920}
        height={800}
        loading="eager"
        fetchPriority="high"
        decoding="async"
      />
    </picture>
  );
}
```

### Dynamic Responsive Image Helper

```tsx
// utils/imageUtils.ts
interface ResponsiveImageConfig {
  basePath: string;
  widths: number[];
  formats: ('avif' | 'webp' | 'jpg')[];
}

export function buildResponsiveImage(config: ResponsiveImageConfig) {
  const { basePath, widths, formats } = config;
  const ext = basePath.split('.').pop();
  const base = basePath.replace(`.${ext}`, '');

  const sources = formats
    .filter(f => f !== 'jpg')
    .map(format => ({
      type: `image/${format}`,
      srcSet: widths.map(w => `${base}-${w}.${format} ${w}w`).join(', '),
    }));

  const fallback = formats.includes('jpg') ? 'jpg' : formats[formats.length - 1];

  return {
    src: `${base}-${widths[widths.length - 1]}.${fallback}`,
    srcSet: widths.map(w => `${base}-${w}.${fallback} ${w}w`).join(', '),
    sources,
  };
}

// Usage
function FeatureImage() {
  const img = buildResponsiveImage({
    basePath: '/images/feature.jpg',
    widths: [400, 800, 1200, 1600],
    formats: ['avif', 'webp', 'jpg'],
  });

  return (
    <picture>
      {img.sources.map(source => (
        <source key={source.type} type={source.type} srcSet={source.srcSet} sizes="(min-width: 768px) 50vw, 100vw" />
      ))}
      <img src={img.src} srcSet={img.srcSet} sizes="(min-width: 768px) 50vw, 100vw"
        alt="Feature showcase" width={1600} height={900} loading="lazy" decoding="async" />
    </picture>
  );
}
```

## Image Format Strategy

### Format Comparison

| Format | Compression | Browser Support | Transparency | Best For |
|--------|------------|----------------|--------------|----------|
| JPEG | Lossy | Universal | No | Photographs |
| PNG | Lossless | Universal | Yes | Graphics, screenshots |
| WebP | Lossy + Lossless | 97%+ | Yes | General purpose |
| AVIF | Lossy + Lossless | 92%+ | Yes | Maximum compression |
| SVG | N/A (vector) | Universal | Yes | Icons, logos |

For most React projects, using the `<picture>` element with `<source>` tags is preferable to JavaScript-based format detection because it does not require JavaScript execution and works with native browser caching.

## Build-Time Image Optimization

### Vite Configuration

```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';

export default defineConfig({
  plugins: [
    react(),
    ViteImageOptimizer({
      jpg: { quality: 80, progressive: true },
      png: { quality: 80, compressionLevel: 9 },
      webp: { quality: 80, lossless: false },
      avif: { quality: 65, lossless: false },
      svg: {
        multipass: true,
        plugins: [
          { name: 'removeViewBox', active: false },
          { name: 'removeDimensions', active: true },
        ],
      },
    }),
  ],
});
```

### Webpack Configuration (CRA / Custom)

```javascript
// webpack.config.js (or craco.config.js for CRA)
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [
      new ImageMinimizerPlugin({
        minimizer: {
          implementation: ImageMinimizerPlugin.sharpMinify,
          options: {
            encodeOptions: {
              jpeg: { quality: 80, progressive: true },
              webp: { quality: 80 },
              avif: { quality: 65 },
              png: { quality: 80, compressionLevel: 9 },
            },
          },
        },
      }),
    ],
  },
};
```

### Build Script with Sharp

For maximum control, use a build script to generate all image variants:

```typescript
// scripts/optimize-images.ts
import sharp from 'sharp';
import { globSync } from 'glob';
import path from 'path';
import fs from 'fs';

const config = {
  widths: [320, 480, 640, 768, 1024, 1280, 1920],
  formats: ['avif', 'webp', 'jpg'] as const,
  quality: { avif: 65, webp: 80, jpg: 82 },
};

async function optimizeImages() {
  const images = globSync('src/assets/images/**/*.{jpg,jpeg,png}');
  fs.mkdirSync('public/images', { recursive: true });

  for (const imagePath of images) {
    const basename = path.basename(imagePath, path.extname(imagePath));
    const metadata = await sharp(imagePath).metadata();
    const originalWidth = metadata.width || 1920;

    for (const width of config.widths) {
      if (width > originalWidth) continue;
      for (const format of config.formats) {
        const outputPath = `public/images/${basename}-${width}.${format}`;
        await sharp(imagePath)
          .resize(width, undefined, { withoutEnlargement: true })
          .toFormat(format, { quality: config.quality[format] })
          .toFile(outputPath);
      }
    }

    // Generate blur placeholder
    const blurBuffer = await sharp(imagePath)
      .resize(20, undefined, { withoutEnlargement: true })
      .webp({ quality: 20 })
      .toBuffer();
    fs.writeFileSync(
      `public/images/${basename}-blur.txt`,
      `data:image/webp;base64,${blurBuffer.toString('base64')}`
    );
  }
}

optimizeImages().then(() => console.log('Done'));
```

Add to `package.json`:

```json
{
  "scripts": {
    "images": "tsx scripts/optimize-images.ts",
    "prebuild": "npm run images"
  }
}
```

## Dynamic Image Loading

### Lazy Component Loading

For heavy image-based components (galleries, carousels), use React.lazy:

```tsx
import { lazy, Suspense } from 'react';

const ImageGallery = lazy(() => import('./components/ImageGallery'));

function ProductPage({ product }: { product: Product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <img src={product.mainImage} alt={product.name}
        width={800} height={600} loading="eager" fetchPriority="high" />

      <Suspense fallback={<GallerySkeleton count={6} />}>
        <ImageGallery images={product.images} />
      </Suspense>
    </div>
  );
}

function GallerySkeleton({ count }: { count: number }) {
  return (
    <div className="gallery-grid">
      {Array.from({ length: count }, (_, i) => (
        <div key={i} style={{ aspectRatio: '1', background: '#f0f0f0' }} />
      ))}
    </div>
  );
}
```

### IntersectionObserver Hook

```tsx
// hooks/useIntersectionObserver.ts
import { useState, useEffect, useRef, RefObject } from 'react';

export function useIntersectionObserver<T extends HTMLElement>(
  options: { threshold?: number; rootMargin?: string; triggerOnce?: boolean } = {}
): [RefObject<T | null>, boolean] {
  const { threshold = 0, rootMargin = '200px', triggerOnce = true } = options;
  const ref = useRef<T | null>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) { setIsIntersecting(true); if (triggerOnce) observer.disconnect(); }
        else if (!triggerOnce) setIsIntersecting(false);
      },
      { threshold, rootMargin }
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [threshold, rootMargin, triggerOnce]);

  return [ref, isIntersecting];
}
```

### Progressive Image Loading

```tsx
import { useState, useEffect } from 'react';

interface ProgressiveImageProps {
  lowSrc: string;
  highSrc: string;
  alt: string;
  width: number;
  height: number;
}

export function ProgressiveImage({ lowSrc, highSrc, alt, width, height }: ProgressiveImageProps) {
  const [currentSrc, setCurrentSrc] = useState(lowSrc);
  const [isHighRes, setIsHighRes] = useState(false);

  useEffect(() => {
    const img = new window.Image();
    img.onload = () => { setCurrentSrc(highSrc); setIsHighRes(true); };
    img.src = highSrc;
  }, [highSrc]);

  return (
    <img src={currentSrc} alt={alt} width={width} height={height}
      style={{ filter: isHighRes ? 'none' : 'blur(15px)', transition: 'filter 0.4s', maxWidth: '100%', height: 'auto' }} />
  );
}
```

## CDN Integration

### URL-Based Transforms with Sirv

[Sirv](https://sirv.com/) provides URL-based image transformations. Instead of generating multiple variants at build time, request exactly the size and format you need via URL parameters:

```tsx
// utils/sirvImage.ts
const SIRV_BASE = 'https://youraccount.sirv.com';

interface SirvOptions {
  width?: number;
  height?: number;
  quality?: number;
  format?: 'webp' | 'avif' | 'jpg' | 'png' | 'optimal';
  crop?: 'face' | 'center' | 'smart';
}

export function sirvUrl(path: string, options: SirvOptions = {}): string {
  const params = new URLSearchParams();
  if (options.width) params.set('w', String(options.width));
  if (options.height) params.set('h', String(options.height));
  if (options.quality) params.set('q', String(options.quality));
  if (options.format) params.set('format', options.format);
  if (options.crop) params.set('crop.type', options.crop);
  const qs = params.toString();
  return `${SIRV_BASE}${path}${qs ? '?' + qs : ''}`;
}

export function sirvSrcSet(path: string, widths: number[], quality = 80): string {
  return widths
    .map(w => `${sirvUrl(path, { width: w, quality, format: 'optimal' })} ${w}w`)
    .join(', ');
}
```

```tsx
// Using Sirv in components
import { sirvUrl, sirvSrcSet } from '../utils/sirvImage';

function ProductCard({ product }: { product: Product }) {
  const imagePath = `/products/${product.slug}.jpg`;

  return (
    <div className="product-card">
      <img
        src={sirvUrl(imagePath, { width: 600, quality: 80, format: 'optimal' })}
        srcSet={sirvSrcSet(imagePath, [300, 400, 600, 800, 1200])}
        sizes="(min-width: 1280px) 25vw, (min-width: 768px) 33vw, 50vw"
        alt={product.name}
        width={600}
        height={600}
        loading="lazy"
        decoding="async"
      />
      <h3>{product.name}</h3>
    </div>
  );
}
```

### Sirv Transformation Parameters

| Parameter | Description | Example |
|-----------|-------------|---------|
| `w` | Resize width | `?w=800` |
| `h` | Resize height | `?h=600` |
| `q` | Quality (1-100) | `?q=80` |
| `format` | Output format | `?format=optimal` (auto WebP/AVIF) |
| `crop.type` | Crop strategy | `?crop.type=face` |
| `sharpen` | Apply sharpening | `?sharpen=1` |
| `profile` | Apply saved preset | `?profile=thumbnail` |
| `watermark` | Add watermark | `?watermark=/mark.png` |

Using `format=optimal` lets Sirv automatically serve AVIF or WebP based on browser support, eliminating the need for `<picture>` element format fallbacks entirely.

### CDN Image Hook

```tsx
// hooks/useCDNImage.ts
import { useMemo } from 'react';
import { sirvUrl, sirvSrcSet } from '../utils/sirvImage';

export function useCDNImage(path: string, options: {
  widths?: number[]; quality?: number; sizes?: string; displayWidth?: number;
} = {}) {
  const { widths = [320, 480, 640, 800, 1200, 1600], quality = 80, sizes = '100vw', displayWidth = 800 } = options;

  return useMemo(() => ({
    src: sirvUrl(path, { width: displayWidth, quality, format: 'optimal' }),
    srcSet: sirvSrcSet(path, widths, quality),
    sizes,
  }), [path, displayWidth, quality, widths, sizes]);
}
```

For batch processing large image libraries, [Sirv AI Studio](https://www.sirv.studio/) can remove backgrounds, apply consistent styling, and generate variants across hundreds of product images via API — useful when your React app pulls images from a CMS.

## State Management for Images

### Preventing Re-Renders from Causing Re-Fetches

One of the most common React image performance issues is unnecessary re-renders causing image elements to unmount and remount:

```tsx
// BAD: Generates a new URL object on every render
function ProductList({ products }: { products: Product[] }) {
  return (
    <div>
      {products.map(product => (
        <img
          key={product.id}
          src={new URL(`/products/${product.id}.webp`, CDN_BASE).toString()}
          alt={product.name}
        />
      ))}
    </div>
  );
}

// GOOD: Stable string reference
function ProductList({ products }: { products: Product[] }) {
  return (
    <div>
      {products.map(product => (
        <img
          key={product.id}
          src={`${CDN_BASE}/products/${product.id}.webp`}
          alt={product.name}
        />
      ))}
    </div>
  );
}
```

### Image Error Boundary

```tsx
import { Component, ReactNode } from 'react';

export class ImageErrorBoundary extends Component<
  { children: ReactNode; fallback?: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };
  static getDerivedStateFromError() { return { hasError: true }; }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="image-error-boundary">
          <p>Images could not be loaded.</p>
          <button onClick={() => this.setState({ hasError: false })}>Retry</button>
        </div>
      );
    }
    return this.props.children;
  }
}
```

## Performance Monitoring

### Using the web-vitals Library

```tsx
import { onLCP, onCLS } from 'web-vitals';

export function trackImagePerformance() {
  onLCP((metric) => {
    const lcpEntry = metric.entries[metric.entries.length - 1] as PerformanceEntry & { element?: Element };
    if (lcpEntry?.element?.tagName === 'IMG') {
      console.log('LCP image:', (lcpEntry.element as HTMLImageElement).src);
    }
  });

  onCLS((metric) => {
    if (metric.value > 0.1) {
      console.warn('CLS exceeds threshold:', metric.value);
    }
  });
}
```

### Lighthouse CI Integration

```yaml
# .lighthouserc.yml
ci:
  collect:
    numberOfRuns: 3
    url:
      - http://localhost:3000
  assert:
    assertions:
      uses-responsive-images: error
      offscreen-images: error
      uses-optimized-images: error
      unsized-images: error
      largest-contentful-paint: ["error", {"maxNumericValue": 2500}]
      cumulative-layout-shift: ["error", {"maxNumericValue": 0.1}]
```

## Common React Image Anti-Patterns

### 1. Importing Images into the JavaScript Bundle

```tsx
// BAD: Image becomes part of JS bundle (OK for < 10KB, bad for larger)
import heroImage from './images/hero.jpg';
function Hero() { return <img src={heroImage} alt="Hero" />; }

// GOOD: Image loaded separately via public directory or CDN
function Hero() {
  return <img src="/images/hero.webp" alt="Hero" width={1920} height={1080} loading="eager" fetchPriority="high" />;
}
```

### 2. Missing Width and Height

```tsx
// BAD: Causes layout shift (CLS)
<img src="/photo.webp" alt="Photo" />

// GOOD: Prevents CLS
<img src="/photo.webp" alt="Photo" width={800} height={600} />
```

### 3. Eager Loading Everything

```tsx
// BAD: All images load immediately
{products.map(p => <img src={p.image} alt={p.name} loading="eager" />)}

// GOOD: Only first row loads eagerly
{products.map((p, i) => (
  <img src={p.image} alt={p.name}
    loading={i < 4 ? 'eager' : 'lazy'}
    fetchPriority={i === 0 ? 'high' : undefined} />
))}
```

### 4. Not Using srcSet

```tsx
// BAD: Single 3840px image for all viewports
<img src="/images/hero-3840.jpg" alt="Hero" />

// GOOD: Browser picks the right size
<img
  src="/images/hero-1920.webp"
  srcSet="/images/hero-480.webp 480w, /images/hero-768.webp 768w, /images/hero-1200.webp 1200w, /images/hero-1920.webp 1920w"
  sizes="100vw"
  alt="Hero" width={1920} height={1080} />
```

### 5. Using JavaScript for What CSS Can Do

```tsx
// BAD: JS-driven responsive image switching
function ResponsiveHero() {
  const [src, setSrc] = useState('/images/hero-mobile.jpg');
  useEffect(() => {
    setSrc(window.innerWidth > 768 ? '/images/hero-desktop.jpg' : '/images/hero-mobile.jpg');
  }, []);
  return <img src={src} alt="Hero" />;
}

// GOOD: Let HTML handle it natively
function ResponsiveHero() {
  return (
    <picture>
      <source media="(min-width: 768px)" srcSet="/images/hero-desktop.webp" />
      <img src="/images/hero-mobile.webp" alt="Hero" width={1920} height={1080} />
    </picture>
  );
}
```

### 6. Not Handling Loading and Error States

```tsx
// BAD: No feedback, no error handling
<img src={userAvatar} alt={userName} />

// GOOD: Complete loading lifecycle
function Avatar({ src, name }: { src: string; name: string }) {
  const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading');

  return (
    <div style={{ width: 48, height: 48 }}>
      {state === 'loading' && <div className="avatar-skeleton" />}
      {state === 'error' && <div className="avatar-fallback">{name[0]}</div>}
      <img src={src} alt={name} width={48} height={48}
        onLoad={() => setState('loaded')}
        onError={() => setState('error')}
        style={{ display: state === 'loaded' ? 'block' : 'none' }} />
    </div>
  );
}
```

## Summary

### Quick Reference

| Scenario | Key Attributes/Pattern |
|----------|----------------------|
| Hero/LCP image | `loading="eager"`, `fetchPriority="high"`, `decoding="async"` |
| Content image | `loading="lazy"`, `decoding="async"`, `srcSet` + `sizes` |
| Product grid | `loading="lazy"`, memoized component, CDN srcSet |
| Avatar/thumbnail | Fixed `width`/`height`, small sizes value |
| Background | CSS `background-image`, avoid JS-based switching |
| Format fallback | `<picture>` with AVIF/WebP `<source>`, JPEG fallback |
| Blur placeholder | Tiny base64 image + transition on load |
| CDN integration | URL-based transforms via [Sirv](https://sirv.com/), `format=optimal` |

### Optimization Checklist

1. Always include `width` and `height` on `<img>` elements
2. Use `loading="lazy"` on all below-fold images
3. Set `fetchPriority="high"` on the LCP image only
4. Add `decoding="async"` to all images
5. Provide `srcSet` and `sizes` for responsive images
6. Use `<picture>` for AVIF/WebP with JPEG fallback
7. Memoize image components to prevent re-render re-fetches
8. Use stable string URLs, not computed objects
9. Keep images out of the JS bundle (use `/public` or CDN)
10. Implement blur or skeleton placeholders for perceived performance
11. Handle error states gracefully with fallback UI
12. Use a CDN like [Sirv](https://my.sirv.com/#/signup) for URL-based transforms
13. Run Lighthouse CI in your pipeline to catch regressions
14. Consider [Sirv AI Studio](https://www.sirv.studio/) for batch processing product images
15. Profile with React DevTools to find unnecessary image re-renders

React does not provide a built-in image component like Next.js, but that gives you full control. Use native HTML attributes first, build a reusable component for consistency, integrate a CDN for dynamic resizing, and monitor performance continuously.

## Sources

- [React documentation](https://react.dev/learn)
- [Responsive images, MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Responsive_images)
