Explorer
KNOW-PAT-114

Web UI/UX - IntersectionObserver Lazy Loading : thumbnails, rootMargin 200px, Map cache

Domaine
web-uiux
Type
pattern
Priorité
P2

Parent : [[INDEX-WEB-UIUX]]

Web UI/UX - IntersectionObserver Lazy Loading : thumbnails, rootMargin 200px, Map cache

Problème

Charger 50 images d'un coup crée un LCP catastrophique et une expérience laggy. loading="lazy" natif ne suffit pas pour des images générées dynamiquement (scrape CFX forums).

Solution

IntersectionObserver avec rootMargin: '200px' (chargement anticipé), combiné avec un Map cache global pour éviter les requêtes répétées.

const imgCache = new Map<string, string>()

function useLazyThumbnailWithRef(asset: AssetResult) {
  const ref = useRef<HTMLDivElement>(null)
  const [resolved, setResolved] = useState<string | undefined>(asset.thumbnailUrl)

  useEffect(() => {
    if (asset.thumbnailUrl) { setResolved(asset.thumbnailUrl); return }
    if (asset.source !== 'cfx') return

    const cached = imgCache.get(asset.url)
    if (cached) { setResolved(cached); return }

    const el = ref.current
    if (!el) return

    const observer = new IntersectionObserver(
      (entries) => {
        if (!entries[0].isIntersecting) return
        observer.disconnect()
        fetch(`/api/thumbnail?url=${encodeURIComponent(asset.url)}`)
          .then((r) => r.json())
          .then((data: { images?: string[] }) => {
            const first = data.images?.[0]
            if (first) { imgCache.set(asset.url, first); setResolved(first) }
          })
          .catch(() => {})
      },
      { rootMargin: '200px' } // Charge 200px AVANT d'entrer dans le viewport
    )
    observer.observe(el)
    return () => observer.disconnect()
  }, [asset.url, asset.thumbnailUrl, asset.source])

  return { ref, thumbnail: resolved }
}

Points clés

  1. rootMargin: '200px' : l'image commence à charger 200px avant d'être visible → pas de flash blanc
  2. Map global imgCache : évite de rescraper la même URL si l'utilisateur scrolle up/down
  3. observer.disconnect() après le premier trigger : évite les requêtes multiples
  4. Fallback icon si pas d'image : TYPE_ICON[asset.type] affiché dans un placeholder
  5. loading="lazy" + decoding="async" sur l'img natif : double protection

Pourquoi

  • LCP amélioré : seules les images visibles sont chargées
  • Pas de requêtes dupliquées grâce au cache Map
  • rootMargin créé une expérience fluide sans flash de chargement

Quand l'utiliser

  • Grids de cartes avec images (e-commerce, galleries, dashboards)
  • Images scrapées dynamiquement (forums, API externes)

Quand NE PAS l'utiliser

  • Hero image (charger immédiatement avec priority)
  • Moins de 10 images sur la page (overkill)

Références

  • ProjectAlpha/AssetCard.tsx L25-60