This is a 2026 rewrite of our original 2020 image-caching tutorial. The original hand-rolled a cache with expo-file-system and expo-crypto; every one of its commands and APIs has since been replaced, and the ecosystem now solves this problem for you. Here is what to do today — and how to roll your own on the current APIs if you genuinely need to.
The 2026 answer: expo-image
Since 2023, expo-image ships built-in memory and disk caching. For almost every app, image caching is now five lines:
npx expo install expo-image
import { Image } from "expo-image";
export const Avatar = ({ url }) => (
<Image
source={{ uri: url }}
cachePolicy="memory-disk"
style={{ width: 48, height: 48 }}
/>
);
cachePolicy="memory-disk" caches decoded images in memory and the files on disk. When the same URL can serve different bytes over time, pair it with cacheKey so you control invalidation; use recyclingKey inside lists to prevent stale images flashing while cells recycle.
That is the whole tutorial for 95% of cases. Do not hand-roll this in 2026.
Rolling your own on the modern File API
If you have a real reason to own the cache — custom eviction, shared cache with non-image downloads, offline bundles — here is the original idea rebuilt on the current APIs. Two things changed since 2020:
- The global
expoCLI is gone; it isnpx expo installnow. expo-file-systemreplaced its callback-style functions with an object-orientedFile/DirectoryAPI in SDK 54. The old functions only survive underexpo-file-system/legacy.
npx expo install expo-file-system expo-crypto
import { useEffect, useState } from "react";
import * as Crypto from "expo-crypto";
import { Directory, File, Paths } from "expo-file-system";
const cacheDir = new Directory(Paths.cache, "images");
export const useCachedImage = (sourceUri) => {
const [uri, setUri] = useState(sourceUri);
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!cacheDir.exists) cacheDir.create();
const digest = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
sourceUri
);
const file = new File(cacheDir, digest);
if (!file.exists) {
await File.downloadFileAsync(sourceUri, file);
}
if (!cancelled) setUri(file.uri);
};
load().catch(() => {
// Fall back to the network URI on any cache failure.
});
return () => {
cancelled = true;
};
}, [sourceUri]);
return uri;
};
import { memo } from "react";
import { Image } from "react-native";
const FALLBACK = require("../../assets/images/icon.png");
export const CustomImage = memo(({ source = FALLBACK, skipCache, ...props }) => {
// Hooks must run unconditionally — the *result* decides which URI we use.
const cachedUri = useCachedImage(
typeof source === "object" && source?.uri ? source.uri : null
);
const resolved =
!skipCache && cachedUri ? { uri: cachedUri } : source;
return <Image {...props} source={resolved} />;
});
What changed since our 2020 version
expo install→npx expo install. The globalexpo-cliwas deprecated and removed in 2023.- Legacy FileSystem API →
File/Directory.getInfoAsync,makeDirectoryAsync, anddownloadAsyncmoved toexpo-file-system/legacyin SDK 54; the default export is the new object API. defaultPropsis gone. React 19 (which React Native has shipped since 0.78) removeddefaultPropson function components; use default parameter values instead.- No hooks inside conditionals. The original called its hook inside an
if— a Rules of Hooks violation today's default lint config rejects. Call the hook unconditionally and branch on its result.
If you are migrating existing file-system code, see our companion guide: Migrating expo-file-system to the SDK 54+ File API.