Offline image caching in React Native and Expo

Damien Mason
5 min readOct 26, 2018

--

I recently worked on an app which required content stored online, but available offline. This content (loaded from a json file) included links to images, so I needed an easy way to show the online version if available, or else have a fallback to a previously downloaded image.

I decided the best option was to download the image and display the local version regardless. This let me put all the image checking stuff outside the render.

First up was to set up the basic structure. I put this in an external component to make it easier to reference. The states were to allow for possible issues: loading while the image download is attempted, failed for if the url is no good, and then imageuri, and width and height for once the image is loaded.

import React, { Component } from 'react'
import { View, Image, ActivityIndicator, Dimensions, Platform } from 'react-native'
import { FileSystem } from 'expo'
class CachedImage extends Component {
state = {
loading: true,
failed: false,
imguri: '',
width: 300,
height: 300
}
render() {
{
if (this.state.loading) {
// while the image is being checked and downloading
return();
}
}
{
if (this.state.failed) {
// if the image url has an issue
return();
}
}
// otherwise display the image
return();
}
}
export default CachedImage;

Next I needed to set up the code to download the image in componentDidMount(). There were a few possible image extensions, but I also had to deal with the json file sending non-image references, so I checked the extensions first, and if it wasn’t one of the expected ones, set a state of failed to true.

async componentDidMount() {
const extension = this.props.source.slice((this.props.source.lastIndexOf(".") - 1 >>> 0) + 2)
if ((extension.toLowerCase() !== 'jpg') && (extension.toLowerCase() !== 'png') && (extension.toLowerCase() !== 'gif')) {
this.setState({ loading: false, failed: true })
}

Following this was the code to download the file, save it to the cacheDirectory and then load it with a function. this.props.source and this.props.title were fed into the CachedImage function. I pulled the title from the external image filename so I could track it properly as the json data was updated with new images and the like.

await FileSystem.downloadAsync(
this.props.source,
`${FileSystem.cacheDirectory + this.props.title}.${ extension }`
)
.then(({ uri }) => {
// load the local image
this.loadLocal(uri);
})
.catch(e => {
console.log('Image loading error:', e);
// if the online download fails, load the local version
this.loadLocal(`${FileSystem.cacheDirectory + this.props.title}.${ extension }`);
});

Next up is getting the image data and updating the state. I wanted to set the image width to the device width, and then have the relative height since I couldn’t be sure of the dimensions these images were coming in with, which meant waiting on the Image.getSize function.

loadLocal(uri) {  Image.getSize(uri, (width, height) => {
// once we have the original image dimensions, set the state to the relative ones
this.setState({ imguri: uri, loading: false, width: Dimensions.get('window').width, height: (height/width)*Dimensions.get('window').width });
}, (e) => {
// As always include an error fallback
console.log('getSize error:', e);
this.setState({ loading: false, failed: true })
})
}

Finally I just needed to update the render functions to reflect the states. I included a style prop so I could override the sizes, set the resizeMode etc. if needed.

  render() {
const { style } = this.props
{
if (this.state.loading) {
// while the image is being checked and downloading
return(
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator
color='#42C2F3'
size='large'
/>
</View>
);
}
}
{
if (this.state.failed) {
// if the image url has an issue
return( <View></View> );
}
}
// otherwise display the image
return(
<View style={{ width: this.state.width, height: this.state.height }}>
<Image
style={[{ width: this.state.width, height: this.state.height }, style ]}
source={{ uri: this.state.imguri }}
/>
</View>
);
}

Final Gotcha

For whatever reason Android attempts to show the local image before it’s finished downloading. To counter this I ended up just using the online version on that device. Loading it in later while offline is fine, it just triggers that then a little too soon on the initial load.

.then(({ uri }) => {
this.loadLocal(Platform.OS === 'ios'? uri : this.props.source);
})

This was definitely cobbling together the quickest solution, and I’m certain it could be cleaner (or sorted out properly) with time, but sometimes deadlines mean if it works, it’s fine.

Enough talk, show me the code!

Here’s the final code:

import React, { Component } from 'react'
import { View, Image, ActivityIndicator, Dimensions, Platform } from 'react-native'
import { FileSystem } from 'expo'
class CachedImage extends Component { state = {
loading: true,
failed: false,
imguri: '',
width: 300,
height: 300
}
async componentDidMount() {
const extension = this.props.source.slice((this.props.source.lastIndexOf(".") - 1 >>> 0) + 2)
if ((extension.toLowerCase() !== 'jpg') && (extension.toLowerCase() !== 'png') && (extension.toLowerCase() !== 'gif')) {
this.setState({ loading: false, failed: true })
}
await FileSystem.downloadAsync(
this.props.source,
`${FileSystem.cacheDirectory + this.props.title}.${ extension }`
)
.then(({ uri }) => {
this.loadLocal(Platform.OS === 'ios'? uri : this.props.source);
})
.catch(e => {
console.log('Image loading error:', e);
// if the online download fails, load the local version
this.loadLocal(`${FileSystem.cacheDirectory + this.props.title}.${ extension }`);
});
}
loadLocal(uri) {
Image.getSize(uri, (width, height) => {
// once we have the original image dimensions, set the state to the relative ones
this.setState({ imguri: uri, loading: false, width: Dimensions.get('window').width, height: (height/width)*Dimensions.get('window').width });
}, (e) => {
// As always include an error fallback
console.log('getSize error:', e);
this.setState({ loading: false, failed: true })
})
}
render() {
const { style } = this.props
{
if (this.state.loading) {
// while the image is being checked and downloading
return(
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator
color='#42C2F3'
size='large'
/>
</View>
);
}
}
{
if (this.state.failed) {
// if the image url has an issue
return( <View></View> );
}
}
// otherwise display the image
return(
<View style={{ width: this.state.width, height: this.state.height }}>
<Image
style={[{ width: this.state.width, height: this.state.height }, style ]}
source={{ uri: this.state.imguri }}
/>
</View>
);
}
}
export default CachedImage;

And to load the image in:

<CachedImage
source={ 'online image url' }
title={ 'title for the image' }
style={ whatever extra styling you want }
/>

Thanks for reading. I hope you find it helpful. Please drop me a line if so, or if you have any advice or suggestions for how I could improve this code.

--

--

Damien Mason

Front-end web and mobile developer learning React Native one app at a time. http://damienmason.com