DEV Community

Dmitry Amelchenko
Dmitry Amelchenko

Posted on • Edited on • Originally published at echowaves.com

Implementing fast-image for react-native expo apps.

Original code for this post can be found here: https://github.com/echowaves/WiSaw/blob/master/src/components/CachedImage/index.js

The code is used in production on a What I Saw mobile app that renders tons of images very fast: iOS, Android

And the article is a repost from: https://www.echowaves.com/post/implementing-fast-image-for-react-native-expo-apps

Recently this component was extracted into separate npm module expo-cached-image


If you've ever written react-native apps which rely on react-native-fast-image npm, you are probably aware that, unfortunately, this wonderful component simply does not work in react-native apps developed with Expo, because it uses platform specific implementation.

The development community has made numerous requests to the Expo team to include support for fast-image, unfortunately this is not a priority at this time. This leaves us no options but to implement something ourselves.

Let's call our component CachedImage. We will be using the latest react version, which supports function hooks, as they are more efficient than Class based components. And the efficiency -- that's what we are after.

To make it work with Expo, we will be using expo's components that work in iOS and Android out of the box. For instance FileSystem from 'expo-file-system' npm.

We will invoke our component like this:

<CachedImage
          source={{ uri: `${item.getThumbUrl}` }}
          cacheKey={`${item.id}t`}
          style={styles.thumbnail}
        />
Enter fullscreen mode Exit fullscreen mode

Generally speaking, it works just like a native <Image/> with one exception -- it requires cacheKey prop.

Now, let's start working on our CachedImage component:

First we will declare filesystemURI, which derives from cacheKey prop and defines unique cache entry for our image.

 const filesystemURI = `${FileSystem.cacheDirectory}${cacheKey}`
Enter fullscreen mode Exit fullscreen mode

Then declaring imgURI -- the state const that we pass to the actual tag when we render our component in the return.

const [imgURI, setImgURI] = useState(filesystemURI)
Enter fullscreen mode Exit fullscreen mode

Note, if the image is not cached yet (on the first run), it will reference non existing file.

To prevent updating component that is unmounted, we will declare:

const componentIsMounted = useRef(true)
Enter fullscreen mode Exit fullscreen mode

Next, let's implement useEffect which kicks in only once, when the component is mounted:

useEffect(() => {
...
    loadImage({ fileURI: filesystemURI })

    return () => {
      componentIsMounted.current = false
    }
  }, [])// eslint-disable-line react-hooks/exhaustive-deps
Enter fullscreen mode Exit fullscreen mode

Now let's implement the loadImage method -- the meats of our solution. Here is how it looks:

const loadImage = async ({ fileURI }) => {
      try {
        // Use the cached image if it exists
        const metadata = await FileSystem.getInfoAsync(fileURI)
        if (!metadata.exists) {
          // download to cache
          if (componentIsMounted.current) {
            setImgURI(null)
            await FileSystem.downloadAsync(
              uri,
              fileURI
            )
          }
          if (componentIsMounted.current) {
            setImgURI(fileURI)
          }
        }
      } catch (err) {
        console.log() // eslint-disable-line no-console
        setImgURI(uri)
      }
    }
Enter fullscreen mode Exit fullscreen mode

Pretty self explanatory. First, check if the file with fileURI exists. If not, then

setImgURI(null)
Enter fullscreen mode Exit fullscreen mode

This will force the Image to render with null source -- perfectly fine, will render empty image.

After that, download image from the uri and put it in the cache:

 await FileSystem.downloadAsync(
              uri,
              fileURI
            )            
Enter fullscreen mode Exit fullscreen mode

And if the component is still mounted (after all that wait), update state via setImage, which will force our component to re-render again:

if (componentIsMounted.current) {
            setImgURI(fileURI)
          }
Enter fullscreen mode Exit fullscreen mode

Note, that if the file was previously cached, our Image will be already rendering with proper uri pointing at the file in cache, and this is what makes our solution so fast -- no unnecessary re-renders, no calculations, just render Image straight from cache. If not, we will await until the file downloads, prior to updating state with setImageURI to trigger the Image to re-render. Yes, it will have to re-render our component couple of times, but, since downloading images will be slow anyways, not a really big deal -- as long as we optimize rendering of the image when it's already cached.

And this is how we render our component:

  return (
    <Image
    // eslint-disable-next-line react/jsx-props-no-spreading
      {...props}
      source={{
        uri: imgURI,
      }}
    />
  )
Enter fullscreen mode Exit fullscreen mode

Can't get any simpler than that.

It took me some trial and error to find the most efficient combination. Initially, I was trying to avoid using cacheKey and calculate the key as crypto hash function -- I found it performing much slower than what I was hoping for. After all, crypto hash function relies on a heavy math calculations. As such, I view having to pass cacheKey prop as a minor inconvenience, but this approach gives us the best performance possible. All my images already have unique ids, so, why not to use it as cacheKey?

And the complete code for the CachedImage component is down below. Let me know if you can think of any other optimization improvements:

import React, { useEffect, useState, useRef } from 'react'

import { Image } from 'react-native'

import * as FileSystem from 'expo-file-system'

import PropTypes from 'prop-types'

const CachedImage = props => {
  const { source: { uri }, cacheKey } = props
  const filesystemURI = `${FileSystem.cacheDirectory}${cacheKey}`

  const [imgURI, setImgURI] = useState(filesystemURI)

  const componentIsMounted = useRef(true)

  useEffect(() => {
    const loadImage = async ({ fileURI }) => {
      try {
        // Use the cached image if it exists
        const metadata = await FileSystem.getInfoAsync(fileURI)
        if (!metadata.exists) {
          // download to cache
          if (componentIsMounted.current) {
            setImgURI(null)
            await FileSystem.downloadAsync(
              uri,
              fileURI
            )
          }
          if (componentIsMounted.current) {
            setImgURI(fileURI)
          }
        }
      } catch (err) {
        console.log() // eslint-disable-line no-console
        setImgURI(uri)
      }
    }

    loadImage({ fileURI: filesystemURI })

    return () => {
      componentIsMounted.current = false
    }
  }, [])// eslint-disable-line react-hooks/exhaustive-deps

  return (
    <Image
    // eslint-disable-next-line react/jsx-props-no-spreading
      {...props}
      source={{
        uri: imgURI,
      }}
    />
  )
}

CachedImage.propTypes = {
  source: PropTypes.object.isRequired,
  cacheKey: PropTypes.string.isRequired,
}

export default CachedImage
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
morenomdz profile image
MorenoMdz

Thank you! Hope the Expo team can take a look and add it natively.