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}
/>
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}`
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)
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)
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
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)
}
}
Pretty self explanatory. First, check if the file with fileURI exists. If not, then
setImgURI(null)
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
)
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)
}
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,
}}
/>
)
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
Top comments (1)
Thank you! Hope the Expo team can take a look and add it natively.