React-Native Infinite Scrolling with Lazy Loading: A Step-by-Step Guide

React-Native Infinite Scrolling with Lazy Loading: A Step-by-Step Guide
Social Media Scrolling

Infinite scrolling is a ubiquitous feature in modern apps, think Instagram, X, or Pinterest, where content loads seamlessly as users scroll, creating an engaging and uninterrupted experience. According to a study cited in this article, the average social media user scrolls through roughly 300 feet of newsfeed daily, equivalent to the height of the Statue of Liberty! While this statistic highlights social media usage, infinite scrolling is a staple across many app categories, from e-commerce to news aggregators. In this in-depth tutorial, I’ll guide you through implementing infinite scrolling with lazy loading in React Native, leveraging the power of React Query, FlashList, and Expo. We’ll also address common pitfalls, optimize performance, and even show how to implement this with Drizzle ORM. Let’s dive in!

Why Infinite Scrolling and Lazy Loading?

Before we start coding, let’s understand why infinite scrolling and lazy loading are essential. Infinite scrolling keeps users engaged by loading more content as they reach the bottom of a list, eliminating the need for pagination buttons. Lazy loading, on the other hand, ensures that resources (like images) are only fetched and rendered when needed, improving performance and reducing data usage, crucial for mobile apps where bandwidth and battery life are limited. Combining these techniques creates a smooth, efficient user experience, especially for image-heavy apps like the gallery we’ll build today.

React Query: Your Data-Fetching Superhero

React Query is one of my all-time favorite libraries for React and React Native. It’s a powerful tool for managing, caching, and synchronizing asynchronous data, simplifying the complexities of API calls. Here’s why I love it:

  • Simplified Fetching: Fetch data with minimal boilerplate, no more juggling useState and useEffect for every API call.
  • Automatic Caching: Cache responses to avoid redundant network requests, improving app speed.
  • Built-In States: Handle loading, error, and success states effortlessly.
  • Infinite Queries: Perfect for infinite scrolling, as we’ll see with useInfiniteQuery.

React Query eliminates the need for manual state management for API data, letting you focus on building a great UI. Let’s set it up.

Step 1: Install React Query

First, add React Query to your project. I’m using Yarn, but you can use npm if you prefer:

yarn add @tanstack/react-query

If you’re using TypeScript (which I recommend for better type safety), React Query is fully typed out of the box, making your code more robust.

Step 2: Set Up the React Query Provider

React Query requires a QueryClientProvider to manage its state. You’ll wrap your app with this provider to make query functionality available everywhere. The setup depends on your navigation library:

  • Expo Router: Add it in _layout.tsx.
  • React Navigation: Add it in App.tsx (or your root file).

Here’s how to do it:


import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaView } from 'react-native';

// Create a QueryClient instance
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Optional: Configure global query options
      staleTime: 1000 * 60 * 5, // Cache data for 5 minutes
      cacheTime: 1000 * 60 * 10, // Keep data in memory for 10 minutes
    },
  },
});

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
       // Rest of your application
    </QueryClientProvider>
  );
}

Explanation

  • QueryClient: Manages the query cache and configuration. I’ve added staleTime and cacheTime to optimize caching behavior — adjust these based on your app’s needs.
  • QueryClientProvider: Wraps your app, providing access to React Query hooks.

Step 3: Install FlashList for High-Performance Lists

For our infinite scroll list, we’ll use FlashList from Shopify instead of React Native’s FlatList. FlashList is a high-performance alternative that renders only visible items, making it ideal for long lists with lazy-loaded content like images.

Install it with Expo:

npx expo install @shopify/flash-list

Why FlashList Over FlatList?

  • Performance: FlashList recycles views more efficiently, reducing memory usage and improving scroll performance.
  • Built for Lazy Loading: It pairs well with lazy-loaded content, as it only renders items in the viewport.
  • Caveat: As we’ll see later, recycling can cause issues with image rendering, but we’ll fix that.

If FlashList isn’t suitable for your use case (e.g., complex layouts with dynamic heights), FlatList is still a solid choice, but for an image gallery, FlashList shines.

Step 4: Install Expo Image for Optimized Image Loading

Since we’re building an image gallery, we’ll use expo-image for efficient image rendering and caching. It’s faster than React Native’s Image component and supports lazy loading out of the box.

Install it:

npx expo install expo-image

Benefits of Expo Image

  • Caching: Supports memory and disk caching, reducing reloads.
  • Performance: Loads images faster with native optimizations.
  • Placeholder Support: Shows placeholders while images load, improving perceived performance.

Step 5: Implement Infinite Scrolling with React Query and FlashList

Now, let’s build the core of our app: an infinite scroll image gallery. We’ll use a free image API (Lorem Picsum) for demo data. Later, I’ll show how to connect to a real backend with Drizzle ORM.

Here’s the initial implementation:

import { useInfiniteQuery } from '@tanstack/react-query';
import { ActivityIndicator, RefreshControl, View } from 'react-native';
import { Image } from 'expo-image';
import { useMemo } from 'react';
import { FlashList } from '@shopify/flash-list';

interface ImageData {
  id: string;
  url: string;
}

interface Page {
  images: ImageData[];
  nextPage: number;
}

// Mock API call using Lorem Picsum
const fetchImages = async ({ pageParam = 1 }): Promise<Page> => {
  const images = Array.from({ length: 10 }, (_, index) => ({
    id: `${pageParam}-${index}`,
    url: `https://picsum.photos/300/200?random=${pageParam * 10 + index}`,
  }));
  return { images, nextPage: pageParam + 1 };
};

export default function ImageGallery() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    refetch,
    isRefetching,
  } = useInfiniteQuery({
    queryKey: ['images'],
    initialPageParam: 1,
    queryFn: fetchImages,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  const images = useMemo(
    () => data?.pages.flatMap((page) => page.images) || [],
    [data]
  );

  return (
    <View style={{ flex: 1, backgroundColor: 'white' }}>
      <FlashList
        data={images}
        keyExtractor={(item) => item.id}
        refreshControl={
          <RefreshControl
            tintColor={'blue'}
            refreshing={isRefetching}
            onRefresh={refetch}
          />
        }
        estimatedItemSize={210}
        renderItem={({ item }) => (
          <Image
            source={{ uri: item.url }}
            style={{ width: '100%', height: 200, marginVertical: 5 }}
            cachePolicy="memory-disk"
          />
        )}
        onEndReachedThreshold={0.2}
        onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}
        ListFooterComponent={
          isFetchingNextPage ? (
            <ActivityIndicator
              color="blue"
              size="small"
              style={{ marginBottom: 5 }}
            />
          ) : null
        }
      />
    </View>
  );
}

Code Breakdown

  • Interfaces: ImageData and Page define our data structure, ensuring TypeScript type safety.
  • fetchImages: Simulates an API call using Lorem Picsum, returning 10 images per page.
  • useMemo: Flattens the paginated data into a single array for FlashList.

useInfiniteQuery:

  • queryKey: [‘images’]: Uniquely identifies this query for caching.
  • initialPageParam: Starts at page 1 (most APIs expect this, adjust to 0 if your API uses zero-based indexing).
  • queryFn: Fetches data for each page.
  • getNextPageParam: Extracts the next page number from the response.

FlashList:

  • keyExtractor: Uses id for unique keys (ensures proper recycling).
  • refreshControl: Adds pull-to-refresh functionality.
  • estimatedItemSize: Set to 210 (image height 200 + margin 10), optimizing rendering.
  • renderItem: Renders each image with expo-image.
  • onEndReachedThreshold: Triggers fetchNextPage when 20% from the bottom.
  • ListFooterComponent: Shows a loading indicator while fetching.

This setup works, but there’s a catch: if you scroll quickly, you might notice expo-image displaying the wrong image briefly(I added a video showing this issue, it is a little hard to see in the video). Let’s fix that.

Expo-Image displaying incorrect images while scrolling fast

Step 6: Fix Image Flickering with FlashList and Expo Image

When using FlashList with expo-image, rapid scrolling can cause images to flicker or show the wrong image momentarily. This happens because FlashList recycles views aggressively for performance, and expo-image doesn’t always update the image source in time. I’ve encountered this issue in my own projects, and it’s a common pain point reported by developers.

The Fix: Use recyclingKey

expo-image provides a recyclingKey prop to address this. According to the Expo docs, 

“Changing this prop resets the image view content to blank or a placeholder before loading and rendering the final image. This is especially useful for any kinds of recycling views like FlashList to prevent showing the previous source before the new one fully loads.”

Let’s update our implementation:

import { useInfiniteQuery } from '@tanstack/react-query';
import { ActivityIndicator, RefreshControl, View } from 'react-native';
import { Image } from 'expo-image';
import { useMemo, useCallback } from 'react';
import { FlashList } from '@shopify/flash-list';
import { debounce } from 'lodash';

interface ImageData {
  id: string;
  url: string;
}

interface Page {
  images: ImageData[];
  nextPage: number;
}

const fetchImages = async ({ pageParam = 1 }): Promise<Page> => {
  const images = Array.from({ length: 10 }, (_, index) => ({
    id: `${pageParam}-${index}`,
    url: `https://picsum.photos/300/200?random=${pageParam * 10 + index}`,
  }));
  // Preload images for the next page to reduce flicker
  const nextPageUrls = Array.from({ length: 10 }, (_, index) =>
    `https://picsum.photos/300/200?random=${(pageParam + 1) * 10 + index}`
  );
  Image.prefetch(nextPageUrls);
  return { images, nextPage: pageParam + 1 };
};

export default function ImageGallery() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    refetch,
    isRefetching,
  } = useInfiniteQuery({
    queryKey: ['images'],
    initialPageParam: 1,
    queryFn: fetchImages,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  const images = useMemo(
    () => data?.pages.flatMap((page) => page.images) || [],
    [data]
  );

  const handleEndReached = useCallback(
    debounce(() => {
      if (hasNextPage && !isFetchingNextPage) {
        fetchNextPage();
      }
    }, 300),
    [hasNextPage, isFetchingNextPage, fetchNextPage]
  );

  return (
    <View style={{ flex: 1, backgroundColor: 'white' }}>
      <FlashList
        data={images}
        keyExtractor={(item) => item.id}
        refreshControl={
          <RefreshControl
            tintColor={'blue'}
            refreshing={isRefetching}
            onRefresh={refetch}
          />
        }
        estimatedItemSize={210}
        drawDistance={600}
        renderItem={({ item }) => (
          <Image
            source={{ uri: item.url }}
            style={{ width: '100%', height: 200, marginVertical: 5 }}
            cachePolicy="memory-disk"
            recyclingKey={item.id} // Fix image flickering
            contentFit="cover" // Ensure smooth rendering
            placeholder={{ uri: 'https://via.placeholder.com/300x200' }} // Add placeholder
          />
        )}
        onEndReachedThreshold={0.4}
        onEndReached={handleEndReached}
        ListFooterComponent={
          isFetchingNextPage ? (
            <ActivityIndicator
              color="blue"
              size="small"
              style={{ marginBottom: 5 }}
            />
          ) : null
        }
      />
    </View>
  );
}
Final Results

Additional Optimizations

  • recyclingKey={item.id}: Forces expo-image to reset the view when the image changes, preventing flicker. Use a unique key (e.g., item.id) to ensure correct behavior.
  • contentFit=”cover”: Ensures images fill the view without layout shifts, improving visual consistency.
  • placeholder: Adds a placeholder image while the real image loads, enhancing perceived performance.
  • drawDistance={600}: Renders items 600px ahead, giving expo-image more time to load.
  • Preload Images: Prefetches the next page’s images in fetchImages, reducing load delays.
  • onEndReachedThreshold={0.4}: Adjusted to 0.4 (from 0.2) since we’re fetching 10 items at a time. This triggers fetchNextPage when the 6th item is visible, giving more buffer for loading.

Why These Changes Matter

Without recyclingKey, FlashList’s aggressive recycling can cause expo-image to display stale images. The additional props (contentFit, placeholder, drawDistance) and debouncing ensure a smoother experience, especially on slower devices or networks. Preloading images further minimizes delays, aligning with lazy loading best practices.

Step 7: Key FlashList Parameters Explained

Let’s dive deeper into the FlashList parameters we used and how to tune them for your app.

recyclingKey (Expo Image)

As mentioned, recyclingKey is critical for FlashList. Only use it with recycling views like FlashList or custom implementations, otherwise it’s unnecessary and may hurt performance.

onEndReachedThreshold

This determines how early onEndReached is triggered, measured as a fraction of the list’s visible length. A value of 0.4 means the callback fires when the user is 40% from the bottom. For 10 items per page, this triggers at the 6th item; for 20 items, it’d be the 12th. Adjust based on your pageSize:

  • Smaller batches (e.g., 5 items): Use 0.6–0.8 to fetch earlier.
  • Larger batches (e.g., 20 items): Use 0.3–0.4 to avoid premature fetching.

estimatedItemSize

This is a performance optimization for FlashList. It’s the estimated height of each item in pixels. Our images are 200px tall with a 10px vertical margin (5px top + 5px bottom), so estimatedItemSize={210}. If your items have dynamic heights, estimate an average. Being off by a few pixels (e.g., 200 instead of 210) won’t ruin performance, but accuracy improves scroll smoothness.

drawDistance

This controls how far ahead FlashList renders items (in pixels). A higher value (e.g., 600) ensures images are loaded before they’re visible, reducing flicker at the cost of more memory usage. Adjust based on device performance:

  • Low-end devices: Use 300–400.
  • High-end devices: Use 600–800.

refreshControl

Pull-to-refresh is a breeze with React Query. We pass isRefetching to show the spinner and refetch to reload the data. Customize the tintColor to match your app’s theme (e.g., blue here).

Step 8: Key React Query Parameters Explained

React Query’s useInfiniteQuery offers several parameters to fine-tune infinite scrolling.

initialPageParam

This sets the starting page for your API. Most APIs (like Unsplash or Pexels) expect page 1, so initialPageParam: 1 is standard. Some APIs use zero-based indexing (page 0), but that’s rare. Always check your API’s docs. In our Lorem Picsum example, we used 1 for consistency with typical pagination APIs.

isFetchingNextPage

A boolean indicating if the next page is being fetched. We use it in ListFooterComponent to show a loading indicator. Ideally, with a well-tuned onEndReachedThreshold, users won’t see this often, unless they’re on a slow connection, where it provides essential feedback.

getNextPageParam

This function determines the next page to fetch and sets hasNextPage. It receives the last page’s data and returns the next pageParam (or undefined if there’s no more data). In our mock API, we return nextPage: pageParam + 1, simulating an endless list. In a real API, you’d return undefined when there’s no more data.

Step 9: Simple backend implementation:

This example is assuming you are using Drizzle ORM, and some sort of typescript backend. Now our getNextPageParam: (lastPage) => lastPage.nextPage, will return undefined or the next page automatically from your API! You can also follow this same pattern for getPreviousPageParam if your useCase needs this functionality.

app.get('/images', async (req: Request, res: Response) => {
  const pageSize = 10; // Match your FlashList batch size
  const pageParam = req.query.page ? parseInt(req.query.page as string) : 0; // Default to page 0

  try {
    const userImages = await db.query.images.findMany({
      columns: { id: true, url: true },
      orderBy: [desc(images.id)], // Newest images first
      limit: pageSize,
      offset: pageParam * pageSize, // Calculate offset
    });

    const hasMore = userImages.length === pageSize; // Check if there's another page

    const response = {
      data: userImages, // Array of { id: number; url: string }
      nextPage: hasMore ? pageParam + 1 : undefined, // Next page number if more data
    };

    res.json(response);
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

Conclusion: Building Performant Infinite Scroll Apps

In this tutorial, we’ve built a performant infinite scroll image gallery with React Native, leveraging React Query for data fetching, FlashList for efficient rendering, and expo-image for optimized image loading. We tackled common issues like image flickering with recyclingKey and other optimizations, and showed how to implement a simple backend query with Drizzle ORM. React Query’s useInfiniteQuery makes infinite scrolling a breeze, while FlashList and expo-image ensure top-notch performance. Whether you’re building a social media feed, photo gallery, or product list, this approach scales beautifully.

Try BanKan Board — The Project Management App Made for Developers, by Developers

If you’re tired of complicated project management tools with story points, sprints, and endless processes, BanKan Board is here to simplify your workflow. Built with developers in mind, BanKan Board lets you manage your projects without the clutter.

Key Features:

  • No complicated processes: Focus on what matters without the overhead of traditional project management systems.
  • Claude AI Assistant: Get smart assistance to streamline your tasks and improve productivity.
  • Free to Use: Start using it without any upfront cost.
  • Premium Features: Upgrade to unlock advanced functionality tailored to your team’s needs.

Whether you’re building a side project, managing a team, or collaborating on open-source software, BanKan Board is designed to make your life easier. Try it today!

Get Started with BanKan Board — It’s Free!