Caché de imágenes en React Native con expo-image

Tiempo de lectura: 4 minutos

Cuando usas un FlatList o navegas entre pantallas, los componentes se desmontan y remontan. Aunque expo-image guarda las imágenes en disco, al remontar el componente hace una verificación que provoca un flash visual. Además, si una URL falla, el siguiente montaje lo vuelve a intentar innecesariamente.

Casa azul - Pexels

La solución es mantener un caché fuera del componente, en memoria global. Vamos a ver dos versiones: una sin persistencia entre sesiones y otra que sobrevive al cierre de la app.

Conceptos previos: qué guarda cada capa

Antes de entrar en código conviene entender qué hace cada parte del sistema:

  • expo-image con cachePolicy memory-disk: descarga la imagen y la guarda en el disco del dispositivo automáticamente. La próxima vez que se pide esa URL, la lee del disco sin hacer petición de red. Esto persiste entre sesiones.
  • Map global en JavaScript: guarda en memoria qué URLs ya se han procesado, para que el componente no tenga que esperar ni parpadear al remontar. Se pierde al cerrar la app.
  • AsyncStorage: guarda en disco el contenido del Map (solo texto, solo URLs) para que al abrir la app de nuevo el Map se rehidrate y no haya flash.

Resumiendo: expo-image guarda las imágenes. El Map y AsyncStorage guardan las URLs para saber qué ya estaba cargado.

Version 1: Caché en memoria sin persistencia

Esta version elimina el parpadeo durante la sesion. Si el usuario cierra la app y la reabre, expo-image sigue teniendo las imagenes en disco pero el Map se resetea.

Paso 1: Crea util/ImageCache.ts

const imageCache = new Map<string, string>();

export const getCachedUri = (uri: string): string => {
  return imageCache.get(uri) ?? uri;
};

export const setCachedUri = (uri: string, resolved: string): void => {
  imageCache.set(uri, resolved);
};

Paso 2: Usa el cache en tu componente

Solo cambian tres puntos en tu FastImageCustom: la inicializacion del estado, el handler de exito y el handler de error.

import { getCachedUri, setCachedUri } from '@/util/ImageCache';

const FastImageCustom = ({ uri, ...resto }) => {

  // Inicializa desde el cache global, no desde la prop directamente
  const [currentUri, setCurrentUri] = useState<string>(
    () => getCachedUri(uri)
  );

  useEffect(() => {
    setCurrentUri(getCachedUri(uri));
  }, [uri]);

  const handleLoadEnd = () => {
    setLoading(false);
    setCachedUri(uri, currentUri); // guarda exito
    onLoadEnd?.();
  };

  const handleError = () => {
    setLoading(false);
    const fallback = isAvatar ? avatarDefault() : imagenDefault();
    if (currentUri !== fallback) {
      setCurrentUri(fallback);
      setCachedUri(uri, fallback); // guarda fallback para no reintentar
    }
    onError?.();
  };

  return (
    <Image
      source={{ uri: currentUri }}
      cachePolicy="memory-disk"
      recyclingKey={currentUri}
      // resto de props
    />
  );
};

Con esto, durante la sesion las imagenes no vuelven a parpadear aunque el componente se desmonte y remonte cien veces.

Version 2: Persistencia entre sesiones con AsyncStorage

Anadimos AsyncStorage para que el Map se rehidrate al arrancar la app. Solo guardamos texto (las URLs), no las imagenes. expo-image ya gestiona las imagenes en disco por su cuenta.

Necesitas tener instalado @react-native-async-storage/async-storage.

Paso 1: Actualiza util/ImageCache.ts

import AsyncStorage from '@react-native-async-storage/async-storage';

const memoryCache = new Map<string, string>();
const CACHE_KEY = 'image_url_cache';
const MAX_ENTRIES = 500;
const MAX_AGE_DAYS = 7;

interface CacheEntry {
  resolved: string;
  timestamp: number;
}

// Llama esto una vez al arrancar la app
export const loadImageCache = async () => {
  try {
    const stored = await AsyncStorage.getItem(CACHE_KEY);
    if (!stored) return;

    const parsed: Record<string, CacheEntry> = JSON.parse(stored);
    const now = Date.now();
    const maxAge = MAX_AGE_DAYS * 24 * 60 * 60 * 1000;

    const valid = Object.entries(parsed).filter(
      ([_, entry]) => now - entry.timestamp < maxAge
    );

    // Rehidrata el Map en memoria
    valid.forEach(([k, entry]) => memoryCache.set(k, entry.resolved));

    // Si habia entradas expiradas, limpia el disco tambien
    if (valid.length !== Object.keys(parsed).length) {
      await persist(Object.fromEntries(valid.map(([k, v]) => [k, v])));
    }
  } catch (e) {}
};

export const getCachedUri = (uri: string): string => {
  return memoryCache.get(uri) ?? uri;
};

export const setCachedUri = async (uri: string, resolved: string) => {
  // Si ya esta en memoria con el mismo valor, no escribimos en disco
  if (memoryCache.get(uri) === resolved) return;

  memoryCache.set(uri, resolved);

  try {
    const stored = await AsyncStorage.getItem(CACHE_KEY);
    let parsed: Record<string, CacheEntry> = stored ? JSON.parse(stored) : {};

    parsed[uri] = { resolved, timestamp: Date.now() };

    // Si supera el limite, elimina los mas antiguos
    const entries = Object.entries(parsed);
    if (entries.length > MAX_ENTRIES) {
      const sorted = entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
      parsed = Object.fromEntries(sorted.slice(-MAX_ENTRIES));
    }

    await persist(parsed);
  } catch (e) {}
};

const persist = async (data: Record<string, CacheEntry>) => {
  await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(data));
};

Paso 2: Llama a loadImageCache al arrancar la app

En tu App.tsx anade el useEffect antes del return:

import { useEffect } from 'react';
import { loadImageCache } from '@/util/ImageCache';

export default function App() {

  useEffect(() => {
    loadImageCache();
  }, []);

  return (
    // tu JSX habitual
  );
}

El componente FastImageCustom no cambia respecto a la Version 1. El mismo codigo funciona en ambos casos porque getCachedUri y setCachedUri son las mismas funciones, solo que ahora por debajo tambien hablan con disco.

Cuanto ocupa AsyncStorage

Cada entrada es un par de texto: la URL original y la URL resuelta, mas un timestamp. Son aproximadamente 100 bytes por entrada. Con el limite de 500 entradas el cache ocupa unos 50 KB como maximo. AsyncStorage aguanta hasta varios megabytes, asi que hay margen de sobra.

Las entradas expiran a los 7 dias automaticamente. Puedes ajustar MAX_ENTRIES y MAX_AGE_DAYS segun las necesidades de tu app.

Que se guardaQuien lo guardaDondePersiste al cerrar
La imagen en siexpo-image automaticoDisco del dispositivoSi
Las URLs procesadasMap global (Version 1)Memoria RAMNo
Las URLs procesadasAsyncStorage + Map (Version 2)Disco + MemoriaSi

Deja un comentario