Sensical Image Prefetching with React Native
Poorly written by hailey on April 14, 2024One of the biggest design issues whenever it comes to images in React Native is how to nicely load images and reduce the amount of time before they appear to the user. In many cases, the answer is to simply display a loading indicator and replace it once the image has loaded.
However, whenever you have a list of images - say in a feed of posts - we don't want to endlessly show spinners as we scroll the page (think of a FlatList here, which will not render the image until it's parent becomes visible on the screen). Instead, we want the images to already be loaded and shown to the user without flashes or spinners.
Expo Image
There are various libraries that handle images in React Native and which have prefetching ability (one example is react-native-fast-image, however it has been unmaintained for some time now). However, expo-image stands out in terms of continued maintenance and functionality - especially whenever it comes to prefetching and caching. That is what we will be using today.
I'll be assuming that you have already added expo-image to your project. If you have not, you can do so by following the expo-image documentation.
Where to Begin
Usually whenever we are fetching remote images (especially in large numbers as we are doing here) we are getting the remote URLs from an API. Initially, we might think it's a great idea to just get a list of images out of the data and fetch them all as soon as we perform our query. Perhaps something like this:
const dataQueryFunc = () => {
const data = getSomeData();
const images = [];
for(const item of data) {
images.push(item.imageUri);
}
void Image.prefetch(images);
}
If we are receiving a small number of results (or at least we know there won't be too many images), this is a fine way to do this. In fact, there are a lot of situations where this is all you need to do. However, in this case we are talking about a feed of posts. We probably don't know which posts will include images, and there might be multiple images in each post that we need to fetch...if we get 50 results from the API and every result has a user avatar and an image, that's 100 images that we are going to immediately fetch after the request!
To be sure, you could absolutely still prefetch in the query. However, we should also be weary about data usage. If a user opens the feed, scrolls down 10 posts, then opens the 10th post, the 40 other posts (and 80 images!) that were loaded for no reason. It's possible (on a slower connection specifically) that the images would still be loading after the user navigated away from the post.
Instead...what if we can load the images as we scroll through the feed, section by section?
onViewableItemsChanged
Since we are talking about a feed, we are going to assume that we are using FlatList (if you're using FlashList, the same concept and code will work there as well). We will be loading 50 posts from an API (in this case, we will be getting posts from Bluesky) and as we scroll, we will load sets of 5 posts at a time (on average, with the given data we have, this would load around 7-10 images per section).
A few things
There's a few things we should know about the onViewableItemsChanged
callback before we begin. If you want a really good rundown of what's going on, there's a great article over at suelan.github.io on the topic.
First, the callback only gives us the currently visible items in the list. For each of those items, we get both the data as well as the item's index in our main array of data. In this case, we will be interested in the item's index.
Second, onViewableItemsChanged
cannot change after the FlatList has been rendered. Usually, we would use useCallback()
to memoize our function and prevent unnecessary re-renders. However, this won't work because the function would change each time that we update the array of data (say, as we scroll and fetch more when we reach the end of the page).
In some cases, we could just place the function outside the rendered component and be done with it, say if we only needed to get the index of the first item. However, in this case we need to have access to the data array, which we won't have outside the component. The solution then is to use useRef()
.
The caveat here, though, is that since we are using useRef()
, we will run into a closure issue where we do not have access to the updated data array. The solution here, then, is to create an additional ref that contains a copy of the data.
Here's where we might start:
const PrefetchedList = () => {
const [data, setData] = useState([]);
const page = useRef(1);
const dataRef = useRef([]);
const loadData = useCallback(() => {
const res = getSomeData(page.current);
setData([...data, ...res.data]);
dataRef.current = [...data, ...res.data];
page.current = page.current + 1;
});
const onViewableItemsChanged = useRef(() => {
// Now we have access to the data here
console.log(dataRef.current);
}).current;
}
As soon as we load the data, we update the state and update the ref holding the copy of our data.
When should we fetch?
So we know that we don't want to fetch all the images at once. So when do we want to then? Let's set a few "ground rules" for when images should be fetched in our list.
- The images that are immediately visible in the feed do not need to be prefetched, since they will be fetched anyway upon render. We want to skip these.
- The images that are immediately coming up after the initially visible ones should be prefetched. Perhaps we want to load the next five.
- Since we would prefer that images be already loaded *even before they are about to come into the view *we don't want to wait until they are just about to come up. Instead, we should prefetch well before they appear. Therefore, let's say that we want to prefetch an image five images before it comes into the view.
With these rules in mind, let's think of how our data looks:
1. Visible
2. Visible
3. Visible
4. Not Visible
5. Not Visible
6. Not Visible
7. Not Visible
8. Not Visible
9. Not Visible
10. Not Visible
11. Not Visible
12. Not Visible
13. Not Visible
14. Not Visible
15. Not Visible
16. Not Visible
17. Not Visible
18. Not Visible
19. Not Visible
20. Not Visible
21. Not Visible
...
We know that we don't want to fetch the first three images, since those are already going to be visible. We also know that we want the images immediately next in the list to be available. And lastly, we want the next batch of five images to be immediately visible. So, we want to fetch images 4-14.
In any case after the initial load, we only want to load the images - in batches of five - 5 posts ahead of time. So, that means whenever the last viewable image is image number 10, we will then want to load images 15-20. Great, seems easy enough! Let's see how to implement.
Setup some refs
There's a few things that we want to keep track of. You can either implement this directly in a component, or create a custom hook that can be reused (or, if you're like me, just create a custom hook because the logic is going to be over 100 lines and you don't like junk 😄). Here we will make a hook.
We also will create three refs:
dataItems (number)
- For storing the copy of our datahighestPrefetchedIndex (number)
- However you want to name this, but will store the highest index that we have already prefetched so we know if/when to skip prefetching.previousLength (number)
- This is going to be useful in a few particular edge cases which we will cover.
And finally, three more refs for the three functions we want:
setItems()
- For updating the ref (yea, you could just dodataItems.current = [];
but let's make this look nice 😄)resetPrefetch()
- We want to use this to resethighestPrefetchedIndex
whenever we refresh our FlatListonViewableItemsChanged()
- Our callback function
At this point, our code looks like this:
const usePrefetchListImages() => {
const dataItems = useRef([]);
const highestPrefetchedIndex = useRef(0);
const previousLength = useRef(0);
const setItems = useRef((items: any[]) => {
dataItems.current = items;
}).current;
const resetPrefetch = useRef(() => {
highestPrefetchedIndex = 0;
previousLength = 0;
}).current;
const onViewableItemsChanged = useRef({viewableItems}: {viewableItems: ViewToken[]}) => {
}).current;
return {
setItems,
highestPrefetchedIndex,
onViewableItemsChanged,
};
}
Wonderful! Now on to the fetching logic.
onViewableItemsChanged (again...)
Let's focus on the onViewableItemsChanged()
logic. I'll comment the code as we go along so there's no need to go in and out of context.
const onViewableitemsChanged = useRef({viewableItems}: {viewableItems: ViewToken[]}) => {
// First we want to get the last index of the viewable items. This is easy.
const lastIndex = viewableItems?.[viewableItems.length - 1]?.index;
// Also, we want to make sure that index *does* exists...
if (lastIndex == null) return;
// We also want to see if there are actually any new items in the list.
// more on that in a second, but for now just create a variable that
// checks this
const hasNewItems = dataItems.current.length > previousLength.current;
}).current;
Let's jump out real quick and explain why we need to check if there are new items.
Remember, we are going to be fetching in batches of five. That means we will only run this function in full whenever the last image index is a multiple of five. Because of this, there is the possibility of us ending our list on a non-multiple, say 47. We also have to assume it is possible (hopefully we have set our list up so that things will load beforehand, but just in case of a super fast scroll...) the user will reach the end of the list before we have time to get the result from the onEndReached
query.
To take this into account, we will run the entirety of the function both when the last viewable index is a multiple of fiveand**when the length of the array as changed. Back to the code:
const onViewableitemsChanged = useRef({viewableItems}: {viewableItems: ViewToken[]}) => {
// First we want to get the last index of the viewable items. This is easy.
const lastIndex = viewableItems?.[viewableItems.length - 1]?.index;
// Also, we want to make sure that index *does* exists...
if (lastIndex == null) return;
// We also want to see if there are actually any new items in the list.
// more on that in a second, but for now just create a variable that
// checks this
const hasNewItems = dataItems.current.length > previousLength.current;
// Check if the last index is a multiple of five or if the lenfth has
// changed
if (lastIndex % 5 !== 0 && !hasNewItems) return;
// Update the previous length for the next run
previousLength.current = feedItems.current.length;
// Let's figure out where from and to we need to get data from the array
// Rembmer, if this is *the initial render*, we want to load *the first
// ten items*. Otherwise, we want to load the 5 *after* the next five
const sliceFrom = highestPrefetchedIndex.current === 0 || hasNewItems ? lastIndex : lastIndex + 5;
// And get the sliceTo
const sliceTo = lastIndex + 10;
// Get the next items to fetch. REMEMBER that .slice() *isn't* inclusive
// of the end index, so we want to add 1 to that number.
const nextItems = feedItems.current.slice(sliceFrom, sliceTo + 1);
// Create an array to hold our image URIs
const imagesToPrefetch: string[] = [];
// Maybe you want to create a few little functions here to make getting
// the urls out of your data easier...
const addImages = (item) => {
// Logic...
imagesToPrefetch.push(item.user.avatar);
}
// Run for each item
for (const item of nextItems) {
addImages(item);
}
// Update the highest prefetched index
highestPrefetchedIndex.current = sliceTo;
// Finally, run the prefetch
// Image is imported from expo-image
Image.prefetch(imagesToPrefetch);
}).current;
That's it! We have taken care of the few different cases that could come up and are now fetching our images before the user sees them 🥳🎉. Here's a small example of how you might integrate this.
function MyList(): React.JSX.Element {
const {setItems, resetPrefetch, onViewableItemsChanged = usePrefetchListImages();
const [data, setData] = useState([]);
const loadData = useCallback(() => {
const res = getSomeData();
setData(res);
setItems(res);
}, []);
const renderItem = useCallback(() => {}, []);
const keyExtractor = useCallback(() => {}, []);
// Probably have some logic for fetching spepeifc page numbers, do what you need or use React Query :)
return (
<View style={styles.container}>
<FlatList
renderItem={renderItem}
keyExtractor={keyExtractor}
data={data}
onViewableItemsChanged={onViewableItemsChanged}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
}
});