Offline image caching in React Native and Expo

Offline image caching in React Native and Expo

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.