+1 (415) 943-4271

Image Caching in React Native, 2026 Edition: expo-image and When to Roll Your Own

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:

  1. The global expo CLI is gone; it is npx expo install now.
  2. expo-file-system replaced its callback-style functions with an object-oriented File/Directory API in SDK 54. The old functions only survive under expo-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 installnpx expo install. The global expo-cli was deprecated and removed in 2023.
  • Legacy FileSystem API → File/Directory. getInfoAsync, makeDirectoryAsync, and downloadAsync moved to expo-file-system/legacy in SDK 54; the default export is the new object API.
  • defaultProps is gone. React 19 (which React Native has shipped since 0.78) removed defaultProps on 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.