Carl Rippon

Building SPAs

Carl Rippon
BlogBooks / CoursesAbout
This site uses cookies. Click here to find out more

Lazy Loading with React Query

March 31, 2021
reacttypescript

This is another post in a series of posts using React Query with TypeScript.

Previous posts:

This post will cover how to use React Query to render a list that is lazily loaded. When the user scrolls to the bottom of the list, more data will be requested and rendered.

Lazy loading

Query client provider

React Query requires a QueryClientProvider component above the components using it:

import { QueryClient, QueryClientProvider } from "react-query";
...

const queryClient = new QueryClient();
render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>,
  rootElement
);

We’ve added QueryClientProvider at the top of the component tree to make React Query available to any component.

Fetching function

Our fetching function is as follows:

type CharactersPage = {
  results: Character[];
  next: number | undefined;
};
type Character = {
  name: string;
};

async function getData({ pageParam = 1 }) {
  const response = await fetch(
    `https://swapi.dev/api/people/?page=${pageParam}`
  );
  if (!response.ok) {
    throw new Error("Problem fetching data");
  }
  const dataFromServer = await response.json();
  assertIsCharacterResponse(dataFromServer);
  const data: CharactersPage = {
    results: dataFromServer.results,
    next: dataFromServer.next === null ? undefined : pageParam + 1,
  };
  return data;
}

The function takes in an object parameter containing a pageParam property which is the page number being requested. pageParam will be passed in as undefined in the very first request, so this is defaulted to 1.

The function makes a simple request and raises an error if unsuccessful.

The response data type is asserted to be CharacterResponse with the assertIsCharacterResponse type assert function, which we will look at a little later.

The response data is mapped into a slightly different object. The next property is the next page number or undefined if there is no next page.

Here is the the assertIsCharacterResponse type assert function:

type CharacterResponse = {
  results: Character[];
  next: string;
};
function assertIsCharacterResponse(
  response: any
): asserts response is CharacterResponse {
  if (!("results" in response && "next" in response)) {
    throw new Error("Not results");
  }
  if (response.results.length > 0) {
    const firstResult = response.results[0];
    if (!("name" in firstResult)) {
      throw new Error("Not characters");
    }
  }
}

Query

In previous posts, we have used the useQuery hook to call the fetching function and return useful state variables. There is another hook called useInfiniteQuery which is useful when rendering a list in pages that are lazily loaded.

We can use useInfiniteQuery as follows:

import { useInfiniteQuery } from "react-query";

export function App() {
  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery<CharactersPage, Error>("characters", getData, {
    getNextPageParam: (lastPage) => lastPage.next,
  });
}

useInfiniteQuery is very similar to useQuery. We have passed in the following parameters:

  • A key, "characters", for the query.
  • The fetching function, getData.
  • A function that returns the next page. This is called to pass the next page number into the fetching function. The function takes in the current response data and returns the next page number.

We have destructured the following state variables:

  • data: This contains all the pages of data in the follwoing structure:
{ 
  pages: [
    [arrayOfItemsInPage1],
    [arrayOfItemsInPage2], 
    ...
  ], 
  pageParams: [page1Number, page2Number, ...]
}
  • error: The error object if an error has been raised.
  • fetchNextPage: The function to call to get the next page of data.
  • hasNextPage: Whether there is a next page.
  • isFetchingNextPage: Whether the next page is currently being requested.
  • status: The current status of the fetching process.

Rendering the list

The list is rendered as follows:

export function App() {
  ...
  if (status === "loading") {
    return <div>...</div>;
  }
  if (status === "error") {
    return <div>{error!.message}</div>;
  }
  return (
    <div>
      <div>
        {data &&
          data.pages.map((group, i) => (
            <React.Fragment key={i}>
              {group.results.map((character) => (
                <div key={character.name}>{character.name}</div>
              ))}
            </React.Fragment>
          ))}
      </div>
      <div>
        {hasNextPage && (
          <button
            onClick={() => fetchNextPage()}
            disabled={!hasNextPage || isFetchingNextPage}
          >
            {isFetchingNextPage ? "Loading ..." : "More"}
          </button>
        )}
      </div>
    </div>
  );
}

The status state is used to display a loading indicator and to display the error if it exists.

If there is data, all the pages of data are rendered. Remember, the data is structured into arrays of arrays where the root array are the pages, and the child arrays are the data within each page. So, we have to map through both these arrays.

We also render a More button that requests the next page of data when clicked.

Here’s what it looks like:

Button load

Loading when scrolled to the bottom of the list

This isn’t bad, but we can improve the user experience by automatically loading the next page when the user scrolls to the bottom of the list. So, the user will no longer have to click a More button to get more data - it will automatically appear.

We can make use of a package called react-infinite-scroll-component to help us. We can install this by running the following command in a terminal:

npm install react-infinite-scroll-component

We use this as follows:

import InfiniteScroll from "react-infinite-scroll-component";
...
export function App() {
  ...
  if (data === undefined) {
    return null;
  }
  const dataLength = data.pages.reduce((counter, page) => {
    return counter + page.results.length;
  }, 0);
  return (
    <InfiniteScroll
      dataLength={dataLength}
      next={fetchNextPage}
      hasMore={!!hasNextPage}
      loader={<div>Loading...</div>}
    >
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.results.map((character) => (
            <p key={character.name}>{character.name}</p>
          ))}
        </React.Fragment>
      ))}
    </InfiniteScroll>
  );
}

We use the InfiniteScroll component within the react-infinite-scroll-component package and pass the following into it:

  • dataLength: This is the number of items in the list across all pages. We use a reducer function to calculate this.
  • next: This is the function to fetch the next page of data.
  • hasMore: Whether there are more pages. !! is used before hasNextPage to convert it to a boolean without undefined.
  • loader: This is a render prop to render a loading indicator when a page is being fetched.

Here’s the result:

Scroll load

Nice. 😊

The code in this post is available in CodeSandbox at https://codesandbox.io/s/react-query-lazy-loading-ttuc8?file=/src/App.tsx

Did you find this post useful?

Let me know by sharing it on Twitter.
Click here to share this post on Twitter

If you to learn more about using TypeScript with React, you may find my course useful:

Using TypeScript with React

Find out more