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.

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 guarda | Quien lo guarda | Donde | Persiste al cerrar |
|---|---|---|---|
| La imagen en si | expo-image automatico | Disco del dispositivo | Si |
| Las URLs procesadas | Map global (Version 1) | Memoria RAM | No |
| Las URLs procesadas | AsyncStorage + Map (Version 2) | Disco + Memoria | Si |

Ingeniero en Informática, Investigador, me encanta crear cosas o arreglarlas y darles una nueva vida. Escritor y poeta. Más de 20 APPs publicadas y un libro en Amazon.