Expo App Image Caching: Custom Component Solution

Creating an app that simply just works nowadays is not enough. As technology improves, users (including myself!) have a shorter attention span and patience for when it comes to loading times. Having a seamless and responsive user experience is paramount. One way that loading times can be drastically reduced in your app is by optimising image loading through the caching of images.
What is Image Caching and why implement it?
Image Caching is a crucial technique in both mobile and web app development for enhancing the performance of your app. It is essentially the process of storing pictures in temporary memory locally on the user’s device. This allows your app to display images quickly, even when the user has sub-par network conditions. Image Caching also eliminates the need for repeated downloads of the same image which not only can be time consuming and frustrating for the user but also reduces the amount of data transferred over the network. This can save you big time on bandwidth costs, making your app more cost-efficient.
The biggest pro, however, to using image caching is the overall improvement to the user experience, which will enhance satisfaction and therefore increase the retention of your users. When users see images promptly, they are more likely to engage with your app and return for more.
Implementing Image Caching in React Native
There are a few image caching components available on GitHub for React Native – some notable ones are:
- React Native Fast Image by DylanVann
- React Native Cached Image by Kfiroo
Both of these give great details of control over the caching in your react native app, however, I had some difficulties implementing these into my Expo App and I did not need the extra control so I decided to go down the simple custom solution route.
Custom Solution
The advantage of creating your own component for image caching is that you can customise it to exactly suit your own needs. If done correctly, this can remove unnecessary complications and increase efficiency. I also wanted to learn how image caching works instead of just installing a package; I feel like you learn so much more about the ‘nitty gritty’ when you do it yourself.
After some research, I found a package which gave a good barebones starting place for the custom component. The code can be found in this article: https://blog.boot.dev/javascript/how-to-cache-images-react-native-expo-managed/ by Lane (wagslane).
I have inserted my custom component below:
import React, { Component } from "react";
import {
ActivityIndicator,
Dimensions,
Image,
ImageLoadEventData,
ImageStyle,
NativeSyntheticEvent,
} from "react-native";
import * as FileSystem from "expo-file-system";
import * as Crypto from "expo-crypto";
export default class CachedImage extends Component<
{
source: { uri: string };
desiredHeight?: number;
style?: ImageStyle;
onLoad?: (event: NativeSyntheticEvent<ImageLoadEventData>) => void;
},
{
imgURI: string | undefined;
desiredWidth: number;
loading: boolean;
desiredHeight: number;
}
> {
constructor(props: any) {
super(props);
this.state = {
imgURI: undefined,
desiredWidth: 0,
loading: false,
desiredHeight: this.props.desiredHeight ?? 0,
};
}
async componentDidMount() {
this.setState({ loading: true });
const filesystemURI = await this.getImageFilesystemKey(
this.props.source.uri
);
await this.loadImage(filesystemURI, this.props.source.uri);
this.setState({ loading: false });
}
async componentDidUpdate() {
const filesystemURI = await this.getImageFilesystemKey(
this.props.source.uri
);
if (
this.props.source.uri === this.state.imgURI ||
filesystemURI === this.state.imgURI
) {
return null;
}
await this.loadImage(filesystemURI, this.props.source.uri);
}
async getImageFilesystemKey(remoteURI: string) {
const hashed = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
remoteURI
);
return `${FileSystem.cacheDirectory}${hashed}`;
}
async loadImage(filesystemURI: string, remoteURI: string) {
try {
// Use the cached image if it exists
const metadata = await FileSystem.getInfoAsync(filesystemURI);
if (metadata.exists) {
this.setState({
imgURI: filesystemURI,
});
if (!!this.props.desiredHeight) {
const screenWidth = Dimensions.get("screen").width;
Image.getSize(filesystemURI, (width, height) => {
// If the desired heigh would make image too wide for the screen
// Make the image 100% width instead
if (
!!this.props.desiredHeight &&
(this.props.desiredHeight / height) * width > screenWidth
) {
const newWidth = screenWidth;
const newHeight = (screenWidth / width) * height;
this.setState({
desiredWidth: newWidth,
desiredHeight: newHeight,
});
} else {
this.setState({
desiredWidth: this.props.desiredHeight
? (this.props.desiredHeight / height) * width
: 0,
});
}
});
}
return;
}
// otherwise download to cache
const imageObject = await FileSystem.downloadAsync(
remoteURI,
filesystemURI
);
this.setState({
imgURI: imageObject.uri,
});
if (!!this.props.desiredHeight) {
const screenWidth = Dimensions.get("screen").width;
Image.getSize(imageObject.uri, (width, height) => {
if (
!!this.props.desiredHeight &&
(this.props.desiredHeight / height) * width > screenWidth
) {
const newWidth = screenWidth;
const newHeight = (screenWidth / width) * height;
this.setState({
desiredWidth: newWidth,
desiredHeight: newHeight,
});
} else {
this.setState({
desiredWidth: this.props.desiredHeight
? (this.props.desiredHeight / height) * width
: 0,
});
}
});
}
} catch (err) {
console.log("Image loading error:", err);
this.setState({ imgURI: remoteURI });
}
}
render() {
return this.state.loading ? (
<ActivityIndicator
style={{
flex: 1,
width: !!this.props.style?.width
? this.props.style?.width
: this.state.desiredWidth,
height: !!this.props.style?.height
? this.props.style?.height
: this.state.desiredHeight,
justifyContent: "center",
alignItems: "center",
}}
/>
) : (
<Image
{...this.props}
style={[
this.props?.style,
{
width: !!this.props.style?.width
? this.props.style?.width
: this.state.desiredWidth,
height: !!this.props.style?.height
? this.props.style?.height
: this.state.desiredHeight,
},
]}
source={{ uri: this.state.imgURI ?? undefined }}
/>
);
}
}An example of how this component can then be used is shown below:
<CachedImage
source={{ uri: uri }}
style={{
width: size,
height: size,
resizeMode: "cover",
borderRadius: size / 2,
}}
/>Where ‘width’ and ‘height’ are integer values to control the size of the image. Being able to set the desired size of the image is something that I really wanted to implement in my custom component.
I also found that the Image.getSize function was making lots of requests to Firebase (where I am storing my images in my project), causing me to reach my daily quota limit very quickly. Therefore, in the component, I point the Image.getSize function to the cached image instead, drastically reducing the number of requests made to Firebase.
Results
Here is a little comparison in loading times using the CachedImage component vs not using it.
Without using the CachedImage component:




